mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-01-26 22:49:01 -06:00
Compare commits
85 Commits
e3c29fc626
...
feature-69
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e925972e8b | ||
|
|
7fcadd2887 | ||
|
|
517349c3af | ||
|
|
4f8d12d170 | ||
|
|
f92d99f2aa | ||
|
|
ce712e30ad | ||
|
|
2ee0b1abc8 | ||
|
|
c140674b13 | ||
|
|
cd5d62a297 | ||
|
|
1d146e9311 | ||
|
|
b1fc0b79fa | ||
|
|
9db89bac3e | ||
|
|
0f242c2d3a | ||
|
|
6997a2ab8b | ||
|
|
f82f31f383 | ||
|
|
ac76710296 | ||
|
|
df07b8a03e | ||
|
|
0fe48f8dad | ||
|
|
d6e32fa1ea | ||
|
|
cac1b721b9 | ||
|
|
4428354150 | ||
|
|
b9feba2ca4 | ||
|
|
63bf29188e | ||
|
|
4f0882c344 | ||
|
|
df1aa13551 | ||
|
|
e9e138e62c | ||
|
|
cafb0f2022 | ||
|
|
1d2e3393ac | ||
|
|
857aaca493 | ||
|
|
891f4a2faf | ||
|
|
ae816a01b2 | ||
|
|
b6531aed2f | ||
|
|
991d3cef88 | ||
|
|
f2bb6c9725 | ||
|
|
2312314aa7 | ||
|
|
72e8b73108 | ||
|
|
978906a3aa | ||
|
|
b3324e16f6 | ||
|
|
444ff6951e | ||
|
|
5c9ff367e3 | ||
|
|
aecf42d1ab | ||
|
|
45f5025f78 | ||
|
|
67d10572ed | ||
|
|
d3aca7a77f | ||
|
|
7fdfe292bb | ||
|
|
741a72fd1c | ||
|
|
e02dbb1db6 | ||
|
|
2b223294e0 | ||
|
|
1de884be56 | ||
|
|
8b0f450fa5 | ||
|
|
00aead8b53 | ||
|
|
33f0f56207 | ||
|
|
188b87a971 | ||
|
|
93b5f81ecd | ||
|
|
5fad60ae06 | ||
|
|
1bc9a0fe86 | ||
|
|
30ccf42c85 | ||
|
|
0ffde9e71b | ||
|
|
0e3487b257 | ||
|
|
95fac4ba26 | ||
|
|
09cc8315e4 | ||
|
|
caca37c72b | ||
|
|
8512292efb | ||
|
|
ad2cf38847 | ||
|
|
3d02af81b0 | ||
|
|
5da74ac36a | ||
|
|
486982bfd4 | ||
|
|
9146e7fa11 | ||
|
|
83c0bda282 | ||
|
|
ec847441d0 | ||
|
|
9e8bef5c22 | ||
|
|
ec2ec7d7d9 | ||
|
|
1da935ec01 | ||
|
|
7ee0fe139d | ||
|
|
cf89d81b9e | ||
|
|
d0032c18be | ||
|
|
4271812c2d | ||
|
|
94f6b8d36d | ||
|
|
32d04e1fd3 | ||
|
|
56c744fd56 | ||
|
|
2d9717a330 | ||
|
|
213bd7e244 | ||
|
|
32b236cfa2 | ||
|
|
c06e1e7cba | ||
|
|
51b466a86b |
@@ -4,8 +4,7 @@
|
||||
|
||||
set -eu
|
||||
|
||||
for command in decrypt_documents \
|
||||
document_archiver \
|
||||
for command in document_archiver \
|
||||
document_exporter \
|
||||
document_importer \
|
||||
mail_fetcher \
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py management_command "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py management_command "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
s6-setuidgid paperless python3 manage.py management_command "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py convert_mariadb_uuid "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py convert_mariadb_uuid "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py createsuperuser "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py createsuperuser "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
|
||||
fi
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/command/with-contenv /usr/bin/bash
|
||||
# shellcheck shell=bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py decrypt_documents "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py decrypt_documents "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
fi
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_archiver "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_archiver "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_archiver "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_archiver "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_create_classifier "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_create_classifier "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_exporter "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_exporter "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_exporter "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_exporter "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_fuzzy_match "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_fuzzy_match "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_importer "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_importer "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_importer "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_importer "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_index "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_index "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_index "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_index "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_renamer "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_renamer "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_renamer "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_renamer "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_retagger "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_retagger "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_retagger "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_retagger "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_sanity_checker "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_sanity_checker "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_thumbnails "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_thumbnails "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py mail_fetcher "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py mail_fetcher "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py manage_superuser "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py manage_superuser "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py manage_superuser "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py manage_superuser "$@"
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,8 @@ set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py prune_audit_logs "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py prune_audit_logs "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
|
||||
fi
|
||||
|
||||
@@ -580,36 +580,6 @@ document.
|
||||
documents, such as encrypted PDF documents. The archiver will skip over
|
||||
these documents each time it sees them.
|
||||
|
||||
### Managing encryption {#encryption}
|
||||
|
||||
!!! warning
|
||||
|
||||
Encryption was removed in [paperless-ng 0.9](changelog.md#paperless-ng-090)
|
||||
because it did not really provide any additional security, the passphrase
|
||||
was stored in a configuration file on the same system as the documents.
|
||||
Furthermore, the entire text content of the documents is stored plain in
|
||||
the database, even if your documents are encrypted. Filenames are not
|
||||
encrypted as well. Finally, the web server provides transparent access to
|
||||
your encrypted documents.
|
||||
|
||||
Consider running paperless on an encrypted filesystem instead, which
|
||||
will then at least provide security against physical hardware theft.
|
||||
|
||||
#### Enabling encryption
|
||||
|
||||
Enabling encryption is no longer supported.
|
||||
|
||||
#### Disabling encryption
|
||||
|
||||
Basic usage to disable encryption of your document store:
|
||||
|
||||
(Note: If `PAPERLESS_PASSPHRASE` isn't set already, you need to specify
|
||||
it here)
|
||||
|
||||
```
|
||||
decrypt_documents [--passphrase SECR3TP4SSPHRA$E]
|
||||
```
|
||||
|
||||
### Detecting duplicates {#fuzzy_duplicate}
|
||||
|
||||
Paperless already catches and prevents upload of exactly matching documents,
|
||||
|
||||
@@ -501,7 +501,7 @@ The `datetime` filter formats a datetime string or datetime object using Python'
|
||||
See the [strftime format code documentation](https://docs.python.org/3.13/library/datetime.html#strftime-and-strptime-format-codes)
|
||||
for the possible codes and their meanings.
|
||||
|
||||
##### Date Localization
|
||||
##### Date Localization {#date-localization}
|
||||
|
||||
The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
|
||||
This takes into account the provided locale for translation. Since this must be used on a date or datetime object,
|
||||
@@ -851,8 +851,8 @@ followed by the even pages.
|
||||
|
||||
It's important that the scan files get consumed in the correct order, and one at a time.
|
||||
You therefore need to make sure that Paperless is running while you upload the files into
|
||||
the directory; and if you're using [polling](configuration.md#polling), make sure that
|
||||
`CONSUMER_POLLING` is set to a value lower than it takes for the second scan to appear,
|
||||
the directory; and if you're using polling, make sure that
|
||||
`CONSUMER_POLLING_INTERVAL` is set to a value lower than it takes for the second scan to appear,
|
||||
like 5-10 or even lower.
|
||||
|
||||
Another thing that might happen is that you start a double sided scan, but then forget
|
||||
|
||||
10
docs/api.md
10
docs/api.md
@@ -8,7 +8,7 @@ Further documentation is provided here for some endpoints and features.
|
||||
|
||||
## Authorization
|
||||
|
||||
The REST api provides four different forms of authentication.
|
||||
The REST api provides five different forms of authentication.
|
||||
|
||||
1. Basic authentication
|
||||
|
||||
@@ -52,6 +52,14 @@ The REST api provides four different forms of authentication.
|
||||
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||
you can authenticate against the API using Remote User auth.
|
||||
|
||||
5. Headless OIDC via [`django-allauth`](https://codeberg.org/allauth/django-allauth)
|
||||
|
||||
`django-allauth` exposes API endpoints under `api/auth/` which enable tools
|
||||
like third-party apps to authenticate with social accounts that are
|
||||
configured. See
|
||||
[here](advanced_usage.md#openid-connect-and-social-authentication) for more
|
||||
information on social accounts.
|
||||
|
||||
## Searching for documents
|
||||
|
||||
Full text searching is available on the `/api/documents/` endpoint. Two
|
||||
|
||||
@@ -659,7 +659,7 @@ system. See the corresponding
|
||||
|
||||
: Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html).
|
||||
|
||||
: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.:
|
||||
: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", or the custom groups claim configured in [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM) e.g.:
|
||||
|
||||
```json
|
||||
{"openid_connect":{"SCOPE": ["openid","profile","email","groups"]...
|
||||
@@ -667,6 +667,12 @@ system. See the corresponding
|
||||
|
||||
Defaults to False
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM}
|
||||
|
||||
: Allows you to define a custom groups claim. See [PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) which is required for this setting to take effect.
|
||||
|
||||
Defaults to "groups"
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||
|
||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||
@@ -1146,8 +1152,9 @@ via the consumption directory, you can disable the consumer to save resources.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
||||
|
||||
: When the consumer detects a duplicate document, it will not touch
|
||||
the original document. This default behavior can be changed here.
|
||||
: As of version 3.0 Paperless-ngx allows duplicate documents to be consumed by default, _except_ when
|
||||
this setting is enabled. When enabled, Paperless will check if a document with the same hash already
|
||||
exists in the system and delete the duplicate file from the consumption directory without consuming it.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
@@ -1175,21 +1182,45 @@ don't exist yet.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_IGNORE_PATTERNS=<json>`](#PAPERLESS_CONSUMER_IGNORE_PATTERNS) {#PAPERLESS_CONSUMER_IGNORE_PATTERNS}
|
||||
|
||||
: By default, paperless ignores certain files and folders in the
|
||||
consumption directory, such as system files created by the Mac OS
|
||||
or hidden folders some tools use to store data.
|
||||
: Additional regex patterns for files to ignore in the consumption directory. Patterns are matched against filenames only (not full paths)
|
||||
using Python's `re.match()`, which anchors at the start of the filename.
|
||||
|
||||
This can be adjusted by configuring a custom json array with
|
||||
patterns to exclude.
|
||||
See the [watchfiles documentation](https://watchfiles.helpmanual.io/api/filters/#watchfiles.BaseFilter.ignore_entity_patterns)
|
||||
|
||||
For example, `.DS_STORE/*` will ignore any files found in a folder
|
||||
named `.DS_STORE`, including `.DS_STORE/bar.pdf` and `foo/.DS_STORE/bar.pdf`
|
||||
This setting is for additional patterns beyond the built-in defaults. Common system files and directories are already ignored automatically.
|
||||
The patterns will be compiled via Python's standard `re` module.
|
||||
|
||||
A pattern like `._*` will ignore anything starting with `._`, including:
|
||||
`._foo.pdf` and `._bar/foo.pdf`
|
||||
Example custom patterns:
|
||||
|
||||
Defaults to
|
||||
`[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]`.
|
||||
```json
|
||||
["^temp_", "\\.bak$", "^~"]
|
||||
```
|
||||
|
||||
This would ignore:
|
||||
|
||||
- Files starting with `temp_` (e.g., `temp_scan.pdf`)
|
||||
- Files ending with `.bak` (e.g., `document.pdf.bak`)
|
||||
- Files starting with `~` (e.g., `~$document.docx`)
|
||||
|
||||
Defaults to `[]` (empty list, uses only built-in defaults).
|
||||
|
||||
The default ignores are `[.DS_Store, .DS_STORE, ._*, desktop.ini, Thumbs.db]` and cannot be overridden.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_IGNORE_DIRS=<json>`](#PAPERLESS_CONSUMER_IGNORE_DIRS) {#PAPERLESS_CONSUMER_IGNORE_DIRS}
|
||||
|
||||
: Additional directory names to ignore in the consumption directory. Directories matching these names (and all their contents) will be skipped.
|
||||
|
||||
This setting is for additional directories beyond the built-in defaults. Matching is done by directory name only, not full path.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
["temp", "incoming", ".hidden"]
|
||||
```
|
||||
|
||||
Defaults to `[]` (empty list, uses only built-in defaults).
|
||||
|
||||
The default ignores are `[.stfolder, .stversions, .localized, @eaDir, .Spotlight-V100, .Trashes, __MACOSX]` and cannot be overridden.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
||||
|
||||
@@ -1288,48 +1319,24 @@ within your documents.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
### Polling {#polling}
|
||||
#### [`PAPERLESS_CONSUMER_POLLING_INTERVAL=<num>`](#PAPERLESS_CONSUMER_POLLING_INTERVAL) {#PAPERLESS_CONSUMER_POLLING_INTERVAL}
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
|
||||
: Configures how the consumer detects new files in the consumption directory.
|
||||
|
||||
: If paperless won't find documents added to your consume folder, it
|
||||
might not be able to automatically detect filesystem changes. In
|
||||
that case, specify a polling interval in seconds here, which will
|
||||
then cause paperless to periodically check your consumption
|
||||
directory for changes. This will also disable listening for file
|
||||
system changes with `inotify`.
|
||||
When set to `0` (default), paperless uses native filesystem notifications for efficient, immediate detection of new files.
|
||||
|
||||
Defaults to 0, which disables polling and uses filesystem
|
||||
notifications.
|
||||
When set to a positive number, paperless polls the consumption directory at that interval in seconds. Use polling for network filesystems (NFS, SMB/CIFS) where native notifications may not work reliably.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT}
|
||||
Defaults to 0.
|
||||
|
||||
: If consumer polling is enabled, sets the maximum number of times
|
||||
paperless will check for a file to remain unmodified. If a file's
|
||||
modification time and size are identical for two consecutive checks, it
|
||||
will be consumed.
|
||||
#### [`PAPERLESS_CONSUMER_STABILITY_DELAY=<num>`](#PAPERLESS_CONSUMER_STABILITY_DELAY) {#PAPERLESS_CONSUMER_STABILITY_DELAY}
|
||||
|
||||
Defaults to 5.
|
||||
: Sets the time in seconds that a file must remain unchanged (same size and modification time) before paperless will begin consuming it.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_POLLING_DELAY=<num>`](#PAPERLESS_CONSUMER_POLLING_DELAY) {#PAPERLESS_CONSUMER_POLLING_DELAY}
|
||||
Increase this value if you experience issues with files being consumed before they are fully written, particularly on slower network storage or
|
||||
with certain scanner quirks
|
||||
|
||||
: If consumer polling is enabled, sets the delay in seconds between
|
||||
each check (above) paperless will do while waiting for a file to
|
||||
remain unmodified.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
### iNotify {#inotify}
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_INOTIFY_DELAY=<num>`](#PAPERLESS_CONSUMER_INOTIFY_DELAY) {#PAPERLESS_CONSUMER_INOTIFY_DELAY}
|
||||
|
||||
: Sets the time in seconds the consumer will wait for additional
|
||||
events from inotify before the consumer will consider a file ready
|
||||
and begin consumption. Certain scanners or network setups may
|
||||
generate multiple events for a single file, leading to multiple
|
||||
consumers working on the same file. Configure this to prevent that.
|
||||
|
||||
Defaults to 0.5 seconds.
|
||||
Defaults to 5.0 seconds.
|
||||
|
||||
## Workflow webhooks
|
||||
|
||||
@@ -1610,6 +1617,16 @@ processing. This only has an effect if
|
||||
|
||||
Defaults to `0 1 * * *`, once per day.
|
||||
|
||||
## Share links
|
||||
|
||||
#### [`PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON=<cron expression>`](#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON) {#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON}
|
||||
|
||||
: Controls how often Paperless-ngx removes expired share link bundles (and their generated ZIP archives).
|
||||
|
||||
: If set to the string "disable", expired bundles are not cleaned up automatically.
|
||||
|
||||
Defaults to `0 2 * * *`, once per day at 02:00.
|
||||
|
||||
## Binaries
|
||||
|
||||
There are a few external software packages that Paperless expects to
|
||||
|
||||
25
docs/migration.md
Normal file
25
docs/migration.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# v3 Migration Guide
|
||||
|
||||
## Consumer Settings Changes
|
||||
|
||||
The v3 consumer command uses a [different library](https://watchfiles.helpmanual.io/) to unify
|
||||
the watching for new files in the consume directory. For the user, this removes several configuration options related to delays and retries
|
||||
and replaces with a single unified setting. It also adjusts how the consumer ignore filtering happens, replaced `fnmatch` with `regex` and
|
||||
separating the directory ignore from the file ignore.
|
||||
|
||||
### Summary
|
||||
|
||||
| Old Setting | New Setting | Notes |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `CONSUMER_POLLING` | [`CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL) | Renamed for clarity |
|
||||
| `CONSUMER_INOTIFY_DELAY` | [`CONSUMER_STABILITY_DELAY`](configuration.md#PAPERLESS_CONSUMER_STABILITY_DELAY) | Unified for all modes |
|
||||
| `CONSUMER_POLLING_DELAY` | _Removed_ | Use `CONSUMER_STABILITY_DELAY` |
|
||||
| `CONSUMER_POLLING_RETRY_COUNT` | _Removed_ | Automatic with stability tracking |
|
||||
| `CONSUMER_IGNORE_PATTERNS` | [`CONSUMER_IGNORE_PATTERNS`](configuration.md#PAPERLESS_CONSUMER_IGNORE_PATTERNS) | **Now regex, not fnmatch**; user patterns are added to (not replacing) default ones |
|
||||
| _New_ | [`CONSUMER_IGNORE_DIRS`](configuration.md#PAPERLESS_CONSUMER_IGNORE_DIRS) | Additional directories to ignore; user entries are added to (not replacing) defaults |
|
||||
|
||||
## Encryption Support
|
||||
|
||||
Document and thumbnail encryption is no longer supported. This was previously deprecated in [paperless-ng 0.9.3](https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/changelog.md#paperless-ng-093)
|
||||
|
||||
Users must decrypt their document using the `decrypt_documents` command before upgrading.
|
||||
@@ -124,8 +124,7 @@ account. The script essentially automatically performs the steps described in [D
|
||||
system notifications with `inotify`. When storing the consumption
|
||||
directory on such a file system, paperless will not pick up new
|
||||
files with the default configuration. You will need to use
|
||||
[`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING), which will disable inotify. See
|
||||
[here](configuration.md#polling).
|
||||
[`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL), which will disable inotify.
|
||||
|
||||
5. Run `docker compose pull`. This will pull the image from the GitHub container registry
|
||||
by default but you can change the image to pull from Docker Hub by changing the `image`
|
||||
|
||||
@@ -46,9 +46,9 @@ run:
|
||||
If you notice that the consumer will only pickup files in the
|
||||
consumption directory at startup, but won't find any other files added
|
||||
later, you will need to enable filesystem polling with the configuration
|
||||
option [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING).
|
||||
option [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL).
|
||||
|
||||
This will disable listening to filesystem changes with inotify and
|
||||
This will disable automatic listening for filesystem changes and
|
||||
paperless will manually check the consumption directory for changes
|
||||
instead.
|
||||
|
||||
@@ -234,47 +234,9 @@ FileNotFoundError: [Errno 2] No such file or directory: '/tmp/ocrmypdf.io.yhk3zb
|
||||
|
||||
This probably indicates paperless tried to consume the same file twice.
|
||||
This can happen for a number of reasons, depending on how documents are
|
||||
placed into the consume folder. If paperless is using inotify (the
|
||||
default) to check for documents, try adjusting the
|
||||
[inotify configuration](configuration.md#inotify). If polling is enabled, try adjusting the
|
||||
[polling configuration](configuration.md#polling).
|
||||
|
||||
## Consumer fails waiting for file to remain unmodified.
|
||||
|
||||
You might find messages like these in your log files:
|
||||
|
||||
```
|
||||
[ERROR] [paperless.management.consumer] Timeout while waiting on file /usr/src/paperless/src/../consume/SCN_0001.pdf to remain unmodified.
|
||||
```
|
||||
|
||||
This indicates paperless timed out while waiting for the file to be
|
||||
completely written to the consume folder. Adjusting
|
||||
[polling configuration](configuration.md#polling) values should resolve the issue.
|
||||
|
||||
!!! note
|
||||
|
||||
The user will need to manually move the file out of the consume folder
|
||||
and back in, for the initial failing file to be consumed.
|
||||
|
||||
## Consumer fails reporting "OS reports file as busy still".
|
||||
|
||||
You might find messages like these in your log files:
|
||||
|
||||
```
|
||||
[WARNING] [paperless.management.consumer] Not consuming file /usr/src/paperless/src/../consume/SCN_0001.pdf: OS reports file as busy still
|
||||
```
|
||||
|
||||
This indicates paperless was unable to open the file, as the OS reported
|
||||
the file as still being in use. To prevent a crash, paperless did not
|
||||
try to consume the file. If paperless is using inotify (the default) to
|
||||
check for documents, try adjusting the
|
||||
[inotify configuration](configuration.md#inotify). If polling is enabled, try adjusting the
|
||||
[polling configuration](configuration.md#polling).
|
||||
|
||||
!!! note
|
||||
|
||||
The user will need to manually move the file out of the consume folder
|
||||
and back in, for the initial failing file to be consumed.
|
||||
placed into the consume folder, such as how a scanner may modify a file multiple times as it scans.
|
||||
Try adjusting the
|
||||
[file stability delay](configuration.md#PAPERLESS_CONSUMER_STABILITY_DELAY) to a larger value.
|
||||
|
||||
## Log reports "Creating PaperlessTask failed".
|
||||
|
||||
|
||||
@@ -308,12 +308,14 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook)
|
||||
|
||||
### Share Links
|
||||
|
||||
"Share links" are shareable public links to files and can be created and managed under the 'Send' button on the document detail screen.
|
||||
"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor.
|
||||
|
||||
- Share links do not require a user to login and thus link directly to a file.
|
||||
- Share links do not require a user to login and thus link directly to a file or bundled download.
|
||||
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
||||
- Links can optionally have an expiration time set.
|
||||
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
||||
- From the document detail screen you can create a share link for that single document.
|
||||
- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links.
|
||||
|
||||
!!! tip
|
||||
|
||||
@@ -565,7 +567,7 @@ This allows for complex logic to be used to generate the title, including [logic
|
||||
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
||||
The template is provided as a string.
|
||||
|
||||
Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title.
|
||||
Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#date-localization) in the title.
|
||||
|
||||
The available inputs differ depending on the type of workflow trigger.
|
||||
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||
@@ -597,6 +599,7 @@ The following placeholders are only available for "added" or "updated" triggers
|
||||
- `{{created_day}}`: created day
|
||||
- `{{created_time}}`: created time in HH:MM format
|
||||
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
||||
- `{{doc_id}}`: Document ID
|
||||
|
||||
##### Examples
|
||||
|
||||
|
||||
@@ -69,8 +69,9 @@ nav:
|
||||
- development.md
|
||||
- 'FAQs': faq.md
|
||||
- troubleshooting.md
|
||||
- 'Migration to v3': migration.md
|
||||
- changelog.md
|
||||
copyright: Copyright © 2016 - 2023 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
|
||||
copyright: Copyright © 2016 - 2026 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
|
||||
@@ -55,10 +55,10 @@
|
||||
#PAPERLESS_TASK_WORKERS=1
|
||||
#PAPERLESS_THREADS_PER_WORKER=1
|
||||
#PAPERLESS_TIME_ZONE=UTC
|
||||
#PAPERLESS_CONSUMER_POLLING=10
|
||||
#PAPERLESS_CONSUMER_POLLING_INTERVAL=10
|
||||
#PAPERLESS_CONSUMER_DELETE_DUPLICATES=false
|
||||
#PAPERLESS_CONSUMER_RECURSIVE=false
|
||||
#PAPERLESS_CONSUMER_IGNORE_PATTERNS=[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini"]
|
||||
#PAPERLESS_CONSUMER_IGNORE_PATTERNS=[] # Defaults are built in; add filename regexes, e.g. ["^\\.DS_Store$", "^desktop\\.ini$"]
|
||||
#PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=false
|
||||
#PAPERLESS_CONSUMER_ENABLE_BARCODES=false
|
||||
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
|
||||
|
||||
@@ -27,7 +27,7 @@ dependencies = [
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
"django~=5.2.5",
|
||||
"django-allauth[mfa,socialaccount]~=65.12.1",
|
||||
"django-allauth[mfa,socialaccount]~=65.13.1",
|
||||
"django-auditlog~=3.4.1",
|
||||
"django-cachalot~=2.8.0",
|
||||
"django-celery-results~=2.6.0",
|
||||
@@ -50,7 +50,6 @@ dependencies = [
|
||||
"gotenberg-client~=0.13.1",
|
||||
"httpx-oauth~=0.16",
|
||||
"imap-tools~=1.11.0",
|
||||
"inotifyrecursive~=0.3",
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.12",
|
||||
@@ -79,7 +78,7 @@ dependencies = [
|
||||
"tika-client~=0.10.0",
|
||||
"torch~=2.9.1",
|
||||
"tqdm~=4.67.1",
|
||||
"watchdog~=6.0",
|
||||
"watchfiles>=1.1.1",
|
||||
"whitenoise~=6.9",
|
||||
"whoosh-reloaded>=2.7.5",
|
||||
"zxing-cpp~=2.3.0",
|
||||
@@ -258,7 +257,7 @@ lint.isort.force-single-line = true
|
||||
|
||||
[tool.codespell]
|
||||
write-changes = true
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober"
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,12 @@
|
||||
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
|
||||
}
|
||||
</ng-template>
|
||||
@if (task.duplicate_documents?.length > 0) {
|
||||
<div class="small text-warning-emphasis d-flex align-items-center gap-1">
|
||||
<i-bs class="lh-1" width="1em" height="1em" name="exclamation-triangle"></i-bs>
|
||||
<span i18n>Duplicate(s) detected</span>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="d-lg-none">
|
||||
|
||||
@@ -164,9 +164,11 @@
|
||||
{{ item.name }}
|
||||
<span class="ms-auto text-muted small">
|
||||
@if (item.dateEnd) {
|
||||
{{ item.date | customDate:'MMM d' }} – {{ item.dateEnd | customDate:'mediumDate' }}
|
||||
{{ item.date | customDate:'mediumDate' }} – {{ item.dateEnd | customDate:'mediumDate' }}
|
||||
} @else if (item.dateTilNow) {
|
||||
{{ item.dateTilNow | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
|
||||
} @else {
|
||||
{{ item.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
|
||||
{{ item.date | customDate:'mediumDate' }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -79,32 +79,34 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
|
||||
{
|
||||
id: RelativeDate.WITHIN_1_WEEK,
|
||||
name: $localize`Within 1 week`,
|
||||
date: new Date().setDate(new Date().getDate() - 7),
|
||||
dateTilNow: new Date().setDate(new Date().getDate() - 7),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.WITHIN_1_MONTH,
|
||||
name: $localize`Within 1 month`,
|
||||
date: new Date().setMonth(new Date().getMonth() - 1),
|
||||
dateTilNow: new Date().setMonth(new Date().getMonth() - 1),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.WITHIN_3_MONTHS,
|
||||
name: $localize`Within 3 months`,
|
||||
date: new Date().setMonth(new Date().getMonth() - 3),
|
||||
dateTilNow: new Date().setMonth(new Date().getMonth() - 3),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.WITHIN_1_YEAR,
|
||||
name: $localize`Within 1 year`,
|
||||
date: new Date().setFullYear(new Date().getFullYear() - 1),
|
||||
dateTilNow: new Date().setFullYear(new Date().getFullYear() - 1),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.THIS_YEAR,
|
||||
name: $localize`This year`,
|
||||
date: new Date('1/1/' + new Date().getFullYear()),
|
||||
dateEnd: new Date('12/31/' + new Date().getFullYear()),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.THIS_MONTH,
|
||||
name: $localize`This month`,
|
||||
date: new Date().setDate(1),
|
||||
dateEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.TODAY,
|
||||
|
||||
@@ -412,6 +412,9 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
return newFilter
|
||||
}
|
||||
|
||||
const correspondentAny = addFilterOfType(TriggerFilterType.CorrespondentAny)
|
||||
correspondentAny.get('values').setValue([11])
|
||||
|
||||
const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs)
|
||||
correspondentIs.get('values').setValue(1)
|
||||
|
||||
@@ -421,12 +424,18 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs)
|
||||
documentTypeIs.get('values').setValue(1)
|
||||
|
||||
const documentTypeAny = addFilterOfType(TriggerFilterType.DocumentTypeAny)
|
||||
documentTypeAny.get('values').setValue([12])
|
||||
|
||||
const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot)
|
||||
documentTypeNot.get('values').setValue([1])
|
||||
|
||||
const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs)
|
||||
storagePathIs.get('values').setValue(1)
|
||||
|
||||
const storagePathAny = addFilterOfType(TriggerFilterType.StoragePathAny)
|
||||
storagePathAny.get('values').setValue([13])
|
||||
|
||||
const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot)
|
||||
storagePathNot.get('values').setValue([1])
|
||||
|
||||
@@ -441,10 +450,13 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
expect(formValues.triggers[0].filter_has_tags).toEqual([1])
|
||||
expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3])
|
||||
expect(formValues.triggers[0].filter_has_not_tags).toEqual([4])
|
||||
expect(formValues.triggers[0].filter_has_any_correspondents).toEqual([11])
|
||||
expect(formValues.triggers[0].filter_has_correspondent).toEqual(1)
|
||||
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1])
|
||||
expect(formValues.triggers[0].filter_has_any_document_types).toEqual([12])
|
||||
expect(formValues.triggers[0].filter_has_document_type).toEqual(1)
|
||||
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1])
|
||||
expect(formValues.triggers[0].filter_has_any_storage_paths).toEqual([13])
|
||||
expect(formValues.triggers[0].filter_has_storage_path).toEqual(1)
|
||||
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1])
|
||||
expect(formValues.triggers[0].filter_custom_field_query).toEqual(
|
||||
@@ -507,16 +519,22 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
|
||||
setFilter(TriggerFilterType.TagsAll, 11)
|
||||
setFilter(TriggerFilterType.TagsNone, 12)
|
||||
setFilter(TriggerFilterType.CorrespondentAny, 16)
|
||||
setFilter(TriggerFilterType.CorrespondentNot, 13)
|
||||
setFilter(TriggerFilterType.DocumentTypeAny, 17)
|
||||
setFilter(TriggerFilterType.DocumentTypeNot, 14)
|
||||
setFilter(TriggerFilterType.StoragePathAny, 18)
|
||||
setFilter(TriggerFilterType.StoragePathNot, 15)
|
||||
|
||||
const formValues = component['getFormValues']()
|
||||
|
||||
expect(formValues.triggers[0].filter_has_all_tags).toEqual([11])
|
||||
expect(formValues.triggers[0].filter_has_not_tags).toEqual([12])
|
||||
expect(formValues.triggers[0].filter_has_any_correspondents).toEqual([16])
|
||||
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13])
|
||||
expect(formValues.triggers[0].filter_has_any_document_types).toEqual([17])
|
||||
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14])
|
||||
expect(formValues.triggers[0].filter_has_any_storage_paths).toEqual([18])
|
||||
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15])
|
||||
})
|
||||
|
||||
@@ -640,8 +658,11 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
filter_has_tags: [],
|
||||
filter_has_all_tags: [],
|
||||
filter_has_not_tags: [],
|
||||
filter_has_any_correspondents: [],
|
||||
filter_has_not_correspondents: [],
|
||||
filter_has_any_document_types: [],
|
||||
filter_has_not_document_types: [],
|
||||
filter_has_any_storage_paths: [],
|
||||
filter_has_not_storage_paths: [],
|
||||
filter_has_correspondent: null,
|
||||
filter_has_document_type: null,
|
||||
@@ -699,11 +720,14 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
trigger.filter_has_tags = [1]
|
||||
trigger.filter_has_all_tags = [2, 3]
|
||||
trigger.filter_has_not_tags = [4]
|
||||
trigger.filter_has_any_correspondents = [10] as any
|
||||
trigger.filter_has_correspondent = 5 as any
|
||||
trigger.filter_has_not_correspondents = [6] as any
|
||||
trigger.filter_has_document_type = 7 as any
|
||||
trigger.filter_has_any_document_types = [11] as any
|
||||
trigger.filter_has_not_document_types = [8] as any
|
||||
trigger.filter_has_storage_path = 9 as any
|
||||
trigger.filter_has_any_storage_paths = [12] as any
|
||||
trigger.filter_has_not_storage_paths = [10] as any
|
||||
trigger.filter_custom_field_query = JSON.stringify([
|
||||
'AND',
|
||||
@@ -714,8 +738,8 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
component.ngOnInit()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
const filters = component.getFiltersFormArray(triggerGroup)
|
||||
expect(filters.length).toBe(10)
|
||||
const customFieldFilter = filters.at(9) as FormGroup
|
||||
expect(filters.length).toBe(13)
|
||||
const customFieldFilter = filters.at(12) as FormGroup
|
||||
expect(customFieldFilter.get('type').value).toBe(
|
||||
TriggerFilterType.CustomFieldQuery
|
||||
)
|
||||
@@ -724,12 +748,27 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should expose select metadata helpers', () => {
|
||||
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentAny)).toBe(
|
||||
true
|
||||
)
|
||||
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe(
|
||||
true
|
||||
)
|
||||
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe(
|
||||
false
|
||||
)
|
||||
expect(component.isSelectMultiple(TriggerFilterType.DocumentTypeAny)).toBe(
|
||||
true
|
||||
)
|
||||
expect(component.isSelectMultiple(TriggerFilterType.DocumentTypeIs)).toBe(
|
||||
false
|
||||
)
|
||||
expect(component.isSelectMultiple(TriggerFilterType.StoragePathAny)).toBe(
|
||||
true
|
||||
)
|
||||
expect(component.isSelectMultiple(TriggerFilterType.StoragePathIs)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
component.correspondents = [{ id: 1, name: 'C1' } as any]
|
||||
component.documentTypes = [{ id: 2, name: 'DT' } as any]
|
||||
@@ -741,9 +780,15 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
|
||||
).toEqual(component.documentTypes)
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.DocumentTypeAny)
|
||||
).toEqual(component.documentTypes)
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
|
||||
).toEqual(component.storagePaths)
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.StoragePathAny)
|
||||
).toEqual(component.storagePaths)
|
||||
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -145,10 +145,13 @@ export enum TriggerFilterType {
|
||||
TagsAny = 'tags_any',
|
||||
TagsAll = 'tags_all',
|
||||
TagsNone = 'tags_none',
|
||||
CorrespondentAny = 'correspondent_any',
|
||||
CorrespondentIs = 'correspondent_is',
|
||||
CorrespondentNot = 'correspondent_not',
|
||||
DocumentTypeAny = 'document_type_any',
|
||||
DocumentTypeIs = 'document_type_is',
|
||||
DocumentTypeNot = 'document_type_not',
|
||||
StoragePathAny = 'storage_path_any',
|
||||
StoragePathIs = 'storage_path_is',
|
||||
StoragePathNot = 'storage_path_not',
|
||||
CustomFieldQuery = 'custom_field_query',
|
||||
@@ -172,8 +175,11 @@ type TriggerFilterAggregate = {
|
||||
filter_has_tags: number[]
|
||||
filter_has_all_tags: number[]
|
||||
filter_has_not_tags: number[]
|
||||
filter_has_any_correspondents: number[]
|
||||
filter_has_not_correspondents: number[]
|
||||
filter_has_any_document_types: number[]
|
||||
filter_has_not_document_types: number[]
|
||||
filter_has_any_storage_paths: number[]
|
||||
filter_has_not_storage_paths: number[]
|
||||
filter_has_correspondent: number | null
|
||||
filter_has_document_type: number | null
|
||||
@@ -219,6 +225,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.CorrespondentAny,
|
||||
name: $localize`Has any of these correspondents`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
selectItems: 'correspondents',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.CorrespondentIs,
|
||||
name: $localize`Has correspondent`,
|
||||
@@ -243,6 +257,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
|
||||
allowMultipleValues: false,
|
||||
selectItems: 'documentTypes',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.DocumentTypeAny,
|
||||
name: $localize`Has any of these document types`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
selectItems: 'documentTypes',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.DocumentTypeNot,
|
||||
name: $localize`Does not have document types`,
|
||||
@@ -259,6 +281,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
|
||||
allowMultipleValues: false,
|
||||
selectItems: 'storagePaths',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.StoragePathAny,
|
||||
name: $localize`Has any of these storage paths`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
selectItems: 'storagePaths',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.StoragePathNot,
|
||||
name: $localize`Does not have storage paths`,
|
||||
@@ -306,6 +336,15 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
|
||||
extract: (trigger) => trigger.filter_has_not_tags,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.CorrespondentAny]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_any_correspondents = Array.isArray(values)
|
||||
? [...values]
|
||||
: [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_any_correspondents,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.CorrespondentIs]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_correspondent = Array.isArray(values)
|
||||
@@ -333,6 +372,15 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
|
||||
extract: (trigger) => trigger.filter_has_document_type,
|
||||
hasValue: (value) => value !== null && value !== undefined,
|
||||
},
|
||||
[TriggerFilterType.DocumentTypeAny]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_any_document_types = Array.isArray(values)
|
||||
? [...values]
|
||||
: [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_any_document_types,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.DocumentTypeNot]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_not_document_types = Array.isArray(values)
|
||||
@@ -351,6 +399,15 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
|
||||
extract: (trigger) => trigger.filter_has_storage_path,
|
||||
hasValue: (value) => value !== null && value !== undefined,
|
||||
},
|
||||
[TriggerFilterType.StoragePathAny]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_any_storage_paths = Array.isArray(values)
|
||||
? [...values]
|
||||
: [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_any_storage_paths,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.StoragePathNot]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_not_storage_paths = Array.isArray(values)
|
||||
@@ -642,8 +699,11 @@ export class WorkflowEditDialogComponent
|
||||
filter_has_tags: [],
|
||||
filter_has_all_tags: [],
|
||||
filter_has_not_tags: [],
|
||||
filter_has_any_correspondents: [],
|
||||
filter_has_not_correspondents: [],
|
||||
filter_has_any_document_types: [],
|
||||
filter_has_not_document_types: [],
|
||||
filter_has_any_storage_paths: [],
|
||||
filter_has_not_storage_paths: [],
|
||||
filter_has_correspondent: null,
|
||||
filter_has_document_type: null,
|
||||
@@ -670,10 +730,16 @@ export class WorkflowEditDialogComponent
|
||||
trigger.filter_has_tags = aggregate.filter_has_tags
|
||||
trigger.filter_has_all_tags = aggregate.filter_has_all_tags
|
||||
trigger.filter_has_not_tags = aggregate.filter_has_not_tags
|
||||
trigger.filter_has_any_correspondents =
|
||||
aggregate.filter_has_any_correspondents
|
||||
trigger.filter_has_not_correspondents =
|
||||
aggregate.filter_has_not_correspondents
|
||||
trigger.filter_has_any_document_types =
|
||||
aggregate.filter_has_any_document_types
|
||||
trigger.filter_has_not_document_types =
|
||||
aggregate.filter_has_not_document_types
|
||||
trigger.filter_has_any_storage_paths =
|
||||
aggregate.filter_has_any_storage_paths
|
||||
trigger.filter_has_not_storage_paths =
|
||||
aggregate.filter_has_not_storage_paths
|
||||
trigger.filter_has_correspondent =
|
||||
@@ -856,8 +922,11 @@ export class WorkflowEditDialogComponent
|
||||
case TriggerFilterType.TagsAny:
|
||||
case TriggerFilterType.TagsAll:
|
||||
case TriggerFilterType.TagsNone:
|
||||
case TriggerFilterType.CorrespondentAny:
|
||||
case TriggerFilterType.CorrespondentNot:
|
||||
case TriggerFilterType.DocumentTypeAny:
|
||||
case TriggerFilterType.DocumentTypeNot:
|
||||
case TriggerFilterType.StoragePathAny:
|
||||
case TriggerFilterType.StoragePathNot:
|
||||
return true
|
||||
default:
|
||||
@@ -1179,8 +1248,11 @@ export class WorkflowEditDialogComponent
|
||||
filter_has_tags: [],
|
||||
filter_has_all_tags: [],
|
||||
filter_has_not_tags: [],
|
||||
filter_has_any_correspondents: [],
|
||||
filter_has_not_correspondents: [],
|
||||
filter_has_any_document_types: [],
|
||||
filter_has_not_document_types: [],
|
||||
filter_has_any_storage_paths: [],
|
||||
filter_has_not_storage_paths: [],
|
||||
filter_custom_field_query: null,
|
||||
filter_has_correspondent: null,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||
<div class="col-md text-truncate">
|
||||
<h3 class="text-truncate" style="line-height: 1.4">
|
||||
{{title}}
|
||||
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
|
||||
<span class="text-truncate">{{title}}</span>
|
||||
@if (id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
||||
@if (copied) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check"></i-bs> <ng-container i18n>Copied!</ng-container>
|
||||
} @else {
|
||||
ID: {{id}}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@if (subTitle) {
|
||||
<span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||
}
|
||||
@if (info) {
|
||||
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
h3 {
|
||||
min-height: calc(1.325rem + 0.9vw);
|
||||
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { Title } from '@angular/platform-browser'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -7,6 +8,7 @@ describe('PageHeaderComponent', () => {
|
||||
let component: PageHeaderComponent
|
||||
let fixture: ComponentFixture<PageHeaderComponent>
|
||||
let titleService: Title
|
||||
let clipboard: Clipboard
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -15,6 +17,7 @@ describe('PageHeaderComponent', () => {
|
||||
}).compileComponents()
|
||||
|
||||
titleService = TestBed.inject(Title)
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
fixture = TestBed.createComponent(PageHeaderComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
@@ -24,7 +27,8 @@ describe('PageHeaderComponent', () => {
|
||||
component.title = 'Foo'
|
||||
component.subTitle = 'Bar'
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).toContain('Foo Bar')
|
||||
expect(fixture.nativeElement.textContent).toContain('Foo')
|
||||
expect(fixture.nativeElement.textContent).toContain('Bar')
|
||||
})
|
||||
|
||||
it('should set html title', () => {
|
||||
@@ -32,4 +36,16 @@ describe('PageHeaderComponent', () => {
|
||||
component.title = 'Foo Bar'
|
||||
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
|
||||
})
|
||||
|
||||
it('should copy id to clipboard, reset after 3 seconds', () => {
|
||||
jest.useFakeTimers()
|
||||
component.id = 42 as any
|
||||
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||
component.copyID()
|
||||
expect(clipboard.copy).toHaveBeenCalledWith('42')
|
||||
expect(component.copied).toBe(true)
|
||||
|
||||
jest.advanceTimersByTime(3000)
|
||||
expect(component.copied).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { Component, Input, inject } from '@angular/core'
|
||||
import { Title } from '@angular/platform-browser'
|
||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
@@ -13,8 +14,11 @@ import { environment } from 'src/environments/environment'
|
||||
})
|
||||
export class PageHeaderComponent {
|
||||
private titleService = inject(Title)
|
||||
private clipboard = inject(Clipboard)
|
||||
|
||||
_title = ''
|
||||
private _title = ''
|
||||
public copied: boolean = false
|
||||
private copyTimeout: any
|
||||
|
||||
@Input()
|
||||
set title(title: string) {
|
||||
@@ -26,6 +30,9 @@ export class PageHeaderComponent {
|
||||
return this._title
|
||||
}
|
||||
|
||||
@Input()
|
||||
id: number
|
||||
|
||||
@Input()
|
||||
subTitle: string = ''
|
||||
|
||||
@@ -34,4 +41,12 @@ export class PageHeaderComponent {
|
||||
|
||||
@Input()
|
||||
infoLink: string
|
||||
|
||||
public copyID() {
|
||||
this.copied = this.clipboard.copy(this.id.toString())
|
||||
clearTimeout(this.copyTimeout)
|
||||
this.copyTimeout = setTimeout(() => {
|
||||
this.copied = false
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ title }}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (!createdBundle) {
|
||||
<form [formGroup]="form" class="d-flex flex-column gap-3">
|
||||
<div>
|
||||
<p class="mb-1">
|
||||
<ng-container i18n>Selected documents:</ng-container>
|
||||
{{ selectionCount }}
|
||||
</p>
|
||||
@if (documentPreview.length > 0) {
|
||||
<ul class="list-unstyled small mb-0">
|
||||
@for (doc of documentPreview; track doc.id) {
|
||||
<li>
|
||||
<strong>{{ doc.title | documentTitle }}</strong>
|
||||
</li>
|
||||
}
|
||||
@if (selectionCount > documentPreview.length) {
|
||||
<li>
|
||||
<ng-container i18n>+ {{ selectionCount - documentPreview.length }} more…</ng-container>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="expirationDays"><ng-container i18n>Expires</ng-container>:</label>
|
||||
<select class="form-select" id="expirationDays" formControlName="expirationDays">
|
||||
@for (option of expirationOptions; track option.value) {
|
||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check form-switch w-100 ms-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="shareArchiveSwitch"
|
||||
formControlName="shareArchiveVersion"
|
||||
aria-checked="{{ shareArchiveVersion }}"
|
||||
/>
|
||||
<label class="form-check-label" for="shareArchiveSwitch" i18n>Share archive version (if available)</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<div class="alert alert-success mb-0" role="status">
|
||||
<h6 class="alert-heading mb-1" i18n>Share link bundle requested</h6>
|
||||
<p class="mb-0 small" i18n>
|
||||
You can copy the share link below or open the manager to monitor progress. The link will start working once the bundle is ready.
|
||||
</p>
|
||||
</div>
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-4" i18n>Status</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge text-bg-secondary text-uppercase">{{ statusLabel(createdBundle.status) }}</span>
|
||||
</dd>
|
||||
<dt class="col-sm-4" i18n>Slug</dt>
|
||||
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
|
||||
<dt class="col-sm-4" i18n>Link</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
|
||||
<button
|
||||
class="btn btn-outline-primary"
|
||||
type="button"
|
||||
(click)="copy(createdBundle)"
|
||||
>
|
||||
@if (copied) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
@if (!copied) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy link</span>
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
<dt class="col-sm-4" i18n>Documents</dt>
|
||||
<dd class="col-sm-8">{{ createdBundle.document_count }}</dd>
|
||||
<dt class="col-sm-4" i18n>Expires</dt>
|
||||
<dd class="col-sm-8">
|
||||
@if (createdBundle.expiration) {
|
||||
{{ createdBundle.expiration | date: 'short' }}
|
||||
}
|
||||
@if (!createdBundle.expiration) {
|
||||
<span i18n>Never</span>
|
||||
}
|
||||
</dd>
|
||||
<dt class="col-sm-4" i18n>File version</dt>
|
||||
<dd class="col-sm-8">{{ fileVersionLabel(createdBundle.file_version) }}</dd>
|
||||
@if (createdBundle.size_bytes !== undefined && createdBundle.size_bytes !== null) {
|
||||
<dt class="col-sm-4" i18n>Size</dt>
|
||||
<dd class="col-sm-8">{{ createdBundle.size_bytes | fileSize }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="d-flex align-items-center gap-2 w-100">
|
||||
<div class="text-light fst-italic small">
|
||||
<ng-container i18n>A zip file containing the selected documents will be created for this share link bundle. This process happens in the background and may take some time, especially for large bundles.</ng-container>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm ms-auto" (click)="cancel()">{{ cancelBtnCaption }}</button>
|
||||
@if (createdBundle) {
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm text-nowrap" (click)="openManage()" i18n>Manage share link bundles</button>
|
||||
}
|
||||
|
||||
@if (!createdBundle) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
||||
(click)="submit()"
|
||||
[disabled]="loading || !buttonsEnabled">
|
||||
@if (loading) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
{{ btnCaption }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
import {
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ShareLinkBundleDialogComponent } from './share-link-bundle-dialog.component'
|
||||
|
||||
class MockToastService {
|
||||
showInfo = jest.fn()
|
||||
showError = jest.fn()
|
||||
}
|
||||
|
||||
describe('ShareLinkBundleDialogComponent', () => {
|
||||
let component: ShareLinkBundleDialogComponent
|
||||
let fixture: ComponentFixture<ShareLinkBundleDialogComponent>
|
||||
let clipboard: Clipboard
|
||||
let toastService: MockToastService
|
||||
let activeModal: NgbActiveModal
|
||||
let originalApiBaseUrl: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalApiBaseUrl = environment.apiBaseUrl
|
||||
toastService = new MockToastService()
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinkBundleDialogComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinkBundleDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
activeModal = TestBed.inject(NgbActiveModal)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers()
|
||||
environment.apiBaseUrl = originalApiBaseUrl
|
||||
})
|
||||
|
||||
it('builds payload and emits confirm on submit', () => {
|
||||
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||
component.documents = [
|
||||
{ id: 1, title: 'Doc 1' } as any,
|
||||
{ id: 2, title: 'Doc 2' } as any,
|
||||
]
|
||||
component.form.setValue({
|
||||
shareArchiveVersion: false,
|
||||
expirationDays: 3,
|
||||
})
|
||||
|
||||
component.submit()
|
||||
|
||||
expect(component.payload).toEqual({
|
||||
document_ids: [1, 2],
|
||||
file_version: FileVersion.Original,
|
||||
expiration_days: 3,
|
||||
})
|
||||
expect(component.buttonsEnabled).toBe(false)
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
|
||||
component.form.setValue({
|
||||
shareArchiveVersion: true,
|
||||
expirationDays: 7,
|
||||
})
|
||||
component.submit()
|
||||
|
||||
expect(component.payload).toEqual({
|
||||
document_ids: [1, 2],
|
||||
file_version: FileVersion.Archive,
|
||||
expiration_days: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores submit when bundle already created', () => {
|
||||
component.createdBundle = { id: 1 } as ShareLinkBundleSummary
|
||||
const confirmSpy = jest.spyOn(component, 'confirm')
|
||||
component.submit()
|
||||
expect(confirmSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('limits preview to ten documents', () => {
|
||||
const docs = Array.from({ length: 12 }).map((_, index) => ({
|
||||
id: index + 1,
|
||||
}))
|
||||
component.documents = docs as any
|
||||
|
||||
expect(component.selectionCount).toBe(12)
|
||||
expect(component.documentPreview).toHaveLength(10)
|
||||
expect(component.documentPreview[0].id).toBe(1)
|
||||
})
|
||||
|
||||
it('copies share link and resets state after timeout', fakeAsync(() => {
|
||||
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||
const bundle = {
|
||||
slug: 'bundle-slug',
|
||||
status: ShareLinkBundleStatus.Ready,
|
||||
} as ShareLinkBundleSummary
|
||||
|
||||
component.copy(bundle)
|
||||
|
||||
expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle))
|
||||
expect(component.copied).toBe(true)
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
|
||||
tick(3000)
|
||||
expect(component.copied).toBe(false)
|
||||
}))
|
||||
|
||||
it('generates share URLs based on API base URL', () => {
|
||||
environment.apiBaseUrl = 'https://example.com/api/'
|
||||
expect(
|
||||
component.getShareUrl({ slug: 'abc' } as ShareLinkBundleSummary)
|
||||
).toBe('https://example.com/share/abc')
|
||||
})
|
||||
|
||||
it('opens manage dialog when callback provided', () => {
|
||||
const manageSpy = jest.fn()
|
||||
component.onOpenManage = manageSpy
|
||||
component.openManage()
|
||||
expect(manageSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to cancel when manage callback missing', () => {
|
||||
const cancelSpy = jest.spyOn(component, 'cancel')
|
||||
component.onOpenManage = undefined
|
||||
component.openManage()
|
||||
expect(cancelSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps status and file version labels', () => {
|
||||
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||
'Processing'
|
||||
)
|
||||
expect(component.fileVersionLabel(FileVersion.Archive)).toContain('Archive')
|
||||
})
|
||||
|
||||
it('closes dialog when cancel invoked', () => {
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.cancel()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, Input, inject } from '@angular/core'
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import {
|
||||
FileVersion,
|
||||
SHARE_LINK_EXPIRATION_OPTIONS,
|
||||
} from 'src/app/data/share-link'
|
||||
import {
|
||||
SHARE_LINK_BUNDLE_FILE_VERSION_LABELS,
|
||||
SHARE_LINK_BUNDLE_STATUS_LABELS,
|
||||
ShareLinkBundleCreatePayload,
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-link-bundle-dialog',
|
||||
templateUrl: './share-link-bundle-dialog.component.html',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
FileSizePipe,
|
||||
DocumentTitlePipe,
|
||||
],
|
||||
providers: [],
|
||||
})
|
||||
export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
||||
private readonly formBuilder = inject(FormBuilder)
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
private _documents: Document[] = []
|
||||
|
||||
selectionCount = 0
|
||||
documentPreview: Document[] = []
|
||||
form: FormGroup = this.formBuilder.group({
|
||||
shareArchiveVersion: true,
|
||||
expirationDays: [7],
|
||||
})
|
||||
payload: ShareLinkBundleCreatePayload | null = null
|
||||
|
||||
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
|
||||
|
||||
createdBundle: ShareLinkBundleSummary | null = null
|
||||
copied = false
|
||||
onOpenManage?: () => void
|
||||
readonly statuses = ShareLinkBundleStatus
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.loading = false
|
||||
this.title = $localize`Create share link bundle`
|
||||
this.btnCaption = $localize`Create link`
|
||||
}
|
||||
|
||||
@Input()
|
||||
set documents(docs: Document[]) {
|
||||
this._documents = docs.concat()
|
||||
this.selectionCount = this._documents.length
|
||||
this.documentPreview = this._documents.slice(0, 10)
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.createdBundle) return
|
||||
this.payload = {
|
||||
document_ids: this._documents.map((doc) => doc.id),
|
||||
file_version: this.form.value.shareArchiveVersion
|
||||
? FileVersion.Archive
|
||||
: FileVersion.Original,
|
||||
expiration_days: this.form.value.expirationDays,
|
||||
}
|
||||
this.buttonsEnabled = false
|
||||
super.confirm()
|
||||
}
|
||||
|
||||
getShareUrl(bundle: ShareLinkBundleSummary): string {
|
||||
const apiURL = new URL(environment.apiBaseUrl)
|
||||
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||
bundle.slug
|
||||
}`
|
||||
}
|
||||
|
||||
copy(bundle: ShareLinkBundleSummary): void {
|
||||
const success = this.clipboard.copy(this.getShareUrl(bundle))
|
||||
if (success) {
|
||||
this.copied = true
|
||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
||||
setTimeout(() => {
|
||||
this.copied = false
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
openManage(): void {
|
||||
if (this.onOpenManage) {
|
||||
this.onOpenManage()
|
||||
} else {
|
||||
this.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
statusLabel(status: ShareLinkBundleSummary['status']): string {
|
||||
return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
fileVersionLabel(version: FileVersion): string {
|
||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ title }}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
@if (loading) {
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span i18n>Loading share link bundles…</span>
|
||||
</div>
|
||||
}
|
||||
@if (!loading && error) {
|
||||
<div class="alert alert-danger mb-0" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
@if (!loading && !error) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<p class="mb-0 text-muted small">
|
||||
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
|
||||
</p>
|
||||
</div>
|
||||
@if (bundles.length === 0) {
|
||||
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
||||
}
|
||||
@if (bundles.length > 0) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" i18n>Status</th>
|
||||
<th scope="col" i18n>Size</th>
|
||||
<th scope="col" i18n>Expires</th>
|
||||
<th scope="col" i18n>Documents</th>
|
||||
<th scope="col" i18n>File version</th>
|
||||
<th scope="col" class="text-end" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (bundle of bundles; track bundle.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div>{{ bundle.created | date: 'short' }}</div>
|
||||
@if (bundle.built_at) {
|
||||
<div class="small text-muted">
|
||||
<ng-container i18n>Built:</ng-container> {{ bundle.built_at | date: 'short' }}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if (bundle.status === statuses.Failed && bundle.last_error) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link p-0 text-danger"
|
||||
[ngbPopover]="errorDetail"
|
||||
popoverClass="popover-sm"
|
||||
triggers="mouseover:mouseleave"
|
||||
placement="auto"
|
||||
aria-label="View error details"
|
||||
i18n-aria-label
|
||||
>
|
||||
<span class="badge text-bg-warning text-uppercase me-2">{{ statusLabel(bundle.status) }}</span>
|
||||
<i-bs name="exclamation-triangle-fill" class="text-warning"></i-bs>
|
||||
</button>
|
||||
<ng-template #errorDetail>
|
||||
@if (bundle.last_error.timestamp) {
|
||||
<div class="text-muted small mb-1">
|
||||
{{ bundle.last_error.timestamp | date: 'short' }}
|
||||
</div>
|
||||
}
|
||||
<h6>{{ bundle.last_error.exception_type || ($localize`Unknown error`) }}</h6>
|
||||
@if (bundle.last_error.message) {
|
||||
<pre class="text-muted small"><code>{{ bundle.last_error.message }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
}
|
||||
@if (bundle.status === statuses.Processing || bundle.status === statuses.Pending) {
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
}
|
||||
@if (bundle.status !== statuses.Failed) {
|
||||
<span class="badge text-bg-secondary text-uppercase">{{ statusLabel(bundle.status) }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (bundle.size_bytes !== undefined && bundle.size_bytes !== null) {
|
||||
{{ bundle.size_bytes | fileSize }}
|
||||
}
|
||||
@if (bundle.size_bytes === undefined || bundle.size_bytes === null) {
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (bundle.expiration) {
|
||||
{{ bundle.expiration | date: 'short' }}
|
||||
}
|
||||
@if (!bundle.expiration) {
|
||||
<span i18n>Never</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ bundle.document_count }}</td>
|
||||
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
[disabled]="bundle.status !== statuses.Ready"
|
||||
(click)="copy(bundle)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedSlug === bundle.slug) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
@if (copiedSlug !== bundle.slug) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
@if (bundle.status === statuses.Failed) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-warning"
|
||||
[disabled]="loading"
|
||||
(click)="retry(bundle)"
|
||||
>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
<span class="visually-hidden" i18n>Retry</span>
|
||||
</button>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
[disabled]="loading"
|
||||
(confirm)="delete(bundle)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
:host ::ng-deep .popover {
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
import {
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
|
||||
|
||||
class MockShareLinkBundleService {
|
||||
listAllBundles = jest.fn()
|
||||
delete = jest.fn()
|
||||
rebuildBundle = jest.fn()
|
||||
}
|
||||
|
||||
class MockToastService {
|
||||
showInfo = jest.fn()
|
||||
showError = jest.fn()
|
||||
}
|
||||
|
||||
describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
let component: ShareLinkBundleManageDialogComponent
|
||||
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
|
||||
let service: MockShareLinkBundleService
|
||||
let toastService: MockToastService
|
||||
let clipboard: Clipboard
|
||||
let activeModal: NgbActiveModal
|
||||
let originalApiBaseUrl: string
|
||||
|
||||
beforeEach(() => {
|
||||
service = new MockShareLinkBundleService()
|
||||
toastService = new MockToastService()
|
||||
originalApiBaseUrl = environment.apiBaseUrl
|
||||
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.delete.mockReturnValue(of(true))
|
||||
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
{ provide: ShareLinkBundleService, useValue: service },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
activeModal = TestBed.inject(NgbActiveModal)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
component.ngOnDestroy()
|
||||
fixture.destroy()
|
||||
environment.apiBaseUrl = originalApiBaseUrl
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
const sampleBundle = (overrides: Partial<ShareLinkBundleSummary> = {}) =>
|
||||
({
|
||||
id: 1,
|
||||
slug: 'bundle-slug',
|
||||
created: new Date().toISOString(),
|
||||
document_count: 1,
|
||||
documents: [1],
|
||||
status: ShareLinkBundleStatus.Pending,
|
||||
file_version: FileVersion.Archive,
|
||||
last_error: undefined,
|
||||
...overrides,
|
||||
}) as ShareLinkBundleSummary
|
||||
|
||||
it('loads bundles on init and polls periodically', fakeAsync(() => {
|
||||
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
||||
service.listAllBundles.mockReset()
|
||||
service.listAllBundles
|
||||
.mockReturnValueOnce(of(bundles))
|
||||
.mockReturnValue(of(bundles))
|
||||
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
|
||||
expect(component.bundles).toEqual(bundles)
|
||||
expect(component.loading).toBe(false)
|
||||
expect(component.error).toBeNull()
|
||||
|
||||
tick(5000)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
}))
|
||||
|
||||
it('handles errors when loading bundles', fakeAsync(() => {
|
||||
service.listAllBundles.mockReset()
|
||||
service.listAllBundles
|
||||
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
||||
.mockReturnValue(of([]))
|
||||
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
expect(component.error).toContain('Failed to load share link bundles.')
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
expect(component.loading).toBe(false)
|
||||
|
||||
tick(5000)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
}))
|
||||
|
||||
it('copies bundle links when ready', fakeAsync(() => {
|
||||
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
const readyBundle = sampleBundle({
|
||||
slug: 'ready-slug',
|
||||
status: ShareLinkBundleStatus.Ready,
|
||||
})
|
||||
component.copy(readyBundle)
|
||||
|
||||
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||
component.getShareUrl(readyBundle)
|
||||
)
|
||||
expect(component.copiedSlug).toBe('ready-slug')
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
|
||||
tick(3000)
|
||||
expect(component.copiedSlug).toBeNull()
|
||||
}))
|
||||
|
||||
it('ignores copy requests for non-ready bundles', fakeAsync(() => {
|
||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
component.copy(sampleBundle({ status: ShareLinkBundleStatus.Pending }))
|
||||
expect(copySpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('deletes bundles and refreshes list', fakeAsync(() => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.delete.mockReturnValue(of(true))
|
||||
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
component.delete(sampleBundle())
|
||||
tick()
|
||||
|
||||
expect(service.delete).toHaveBeenCalled()
|
||||
expect(toastService.showInfo).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deleted.')
|
||||
)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
expect(component.loading).toBe(false)
|
||||
}))
|
||||
|
||||
it('handles delete errors gracefully', fakeAsync(() => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
||||
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
component.delete(sampleBundle())
|
||||
tick()
|
||||
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
expect(component.loading).toBe(false)
|
||||
}))
|
||||
|
||||
it('retries bundle build and replaces existing entry', fakeAsync(() => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
||||
service.rebuildBundle.mockReturnValue(of(updated))
|
||||
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
component.bundles = [sampleBundle()]
|
||||
component.retry(component.bundles[0])
|
||||
tick()
|
||||
|
||||
expect(service.rebuildBundle).toHaveBeenCalledWith(updated.id)
|
||||
expect(component.bundles[0].status).toBe(ShareLinkBundleStatus.Ready)
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('adds new bundle when retry returns unknown entry', fakeAsync(() => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.rebuildBundle.mockReturnValue(
|
||||
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
component.bundles = [sampleBundle()]
|
||||
component.retry({ id: 99 } as ShareLinkBundleSummary)
|
||||
tick()
|
||||
|
||||
expect(component.bundles.find((bundle) => bundle.id === 99)).toBeTruthy()
|
||||
}))
|
||||
|
||||
it('handles retry errors', fakeAsync(() => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
||||
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
component.retry(sampleBundle())
|
||||
tick()
|
||||
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('maps helpers and closes dialog', fakeAsync(() => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
fixture.detectChanges()
|
||||
tick()
|
||||
|
||||
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||
'Processing'
|
||||
)
|
||||
expect(component.fileVersionLabel(FileVersion.Original)).toContain(
|
||||
'Original'
|
||||
)
|
||||
|
||||
environment.apiBaseUrl = 'https://example.com/api/'
|
||||
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
||||
expect(url).toBe('https://example.com/share/sluggy')
|
||||
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
import {
|
||||
SHARE_LINK_BUNDLE_FILE_VERSION_LABELS,
|
||||
SHARE_LINK_BUNDLE_STATUS_LABELS,
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-link-bundle-manage-dialog',
|
||||
templateUrl: './share-link-bundle-manage-dialog.component.html',
|
||||
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
|
||||
imports: [
|
||||
ConfirmButtonComponent,
|
||||
CommonModule,
|
||||
NgbPopoverModule,
|
||||
NgxBootstrapIconsModule,
|
||||
FileSizePipe,
|
||||
],
|
||||
})
|
||||
export class ShareLinkBundleManageDialogComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private readonly activeModal = inject(NgbActiveModal)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
|
||||
title = $localize`Share link bundles`
|
||||
|
||||
bundles: ShareLinkBundleSummary[] = []
|
||||
error: string | null = null
|
||||
copiedSlug: string | null = null
|
||||
|
||||
readonly statuses = ShareLinkBundleStatus
|
||||
readonly fileVersions = FileVersion
|
||||
|
||||
private readonly refresh$ = new Subject<boolean>()
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refresh$
|
||||
.pipe(
|
||||
switchMap((silent) => {
|
||||
if (!silent) {
|
||||
this.loading = true
|
||||
}
|
||||
this.error = null
|
||||
return this.shareLinkBundleService.listAllBundles().pipe(
|
||||
catchError((error) => {
|
||||
if (!silent) {
|
||||
this.loading = false
|
||||
}
|
||||
this.error = $localize`Failed to load share link bundles.`
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share link bundles.`,
|
||||
error
|
||||
)
|
||||
return of(null)
|
||||
})
|
||||
)
|
||||
}),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
.subscribe((results) => {
|
||||
if (results) {
|
||||
this.bundles = results
|
||||
this.copiedSlug = null
|
||||
}
|
||||
this.loading = false
|
||||
})
|
||||
|
||||
this.triggerRefresh(false)
|
||||
timer(5000, 5000)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => this.triggerRefresh(true))
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
super.ngOnDestroy()
|
||||
}
|
||||
|
||||
getShareUrl(bundle: ShareLinkBundleSummary): string {
|
||||
const apiURL = new URL(environment.apiBaseUrl)
|
||||
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||
bundle.slug
|
||||
}`
|
||||
}
|
||||
|
||||
copy(bundle: ShareLinkBundleSummary): void {
|
||||
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
||||
return
|
||||
}
|
||||
const success = this.clipboard.copy(this.getShareUrl(bundle))
|
||||
if (success) {
|
||||
this.copiedSlug = bundle.slug
|
||||
setTimeout(() => {
|
||||
this.copiedSlug = null
|
||||
}, 3000)
|
||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
||||
}
|
||||
}
|
||||
|
||||
delete(bundle: ShareLinkBundleSummary): void {
|
||||
this.error = null
|
||||
this.loading = true
|
||||
this.shareLinkBundleService.delete(bundle).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
||||
this.triggerRefresh(false)
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading = false
|
||||
this.toastService.showError(
|
||||
$localize`Error deleting share link bundle.`,
|
||||
e
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
retry(bundle: ShareLinkBundleSummary): void {
|
||||
this.error = null
|
||||
this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({
|
||||
next: (updated) => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Share link bundle rebuild requested.`
|
||||
)
|
||||
this.replaceBundle(updated)
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error requesting rebuild.`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
statusLabel(status: ShareLinkBundleStatus): string {
|
||||
return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
fileVersionLabel(version: FileVersion): string {
|
||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
||||
const index = this.bundles.findIndex((bundle) => bundle.id === updated.id)
|
||||
if (index >= 0) {
|
||||
this.bundles = [
|
||||
...this.bundles.slice(0, index),
|
||||
updated,
|
||||
...this.bundles.slice(index + 1),
|
||||
]
|
||||
} else {
|
||||
this.bundles = [updated, ...this.bundles]
|
||||
}
|
||||
}
|
||||
|
||||
private triggerRefresh(silent: boolean): void {
|
||||
this.refresh$.next(silent)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="input-group w-100 mt-2">
|
||||
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
|
||||
<select class="form-select fs-6" [(ngModel)]="expirationDays">
|
||||
@for (option of EXPIRATION_OPTIONS; track option) {
|
||||
@for (option of expirationOptions; track option) {
|
||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@@ -4,7 +4,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first } from 'rxjs'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
import {
|
||||
FileVersion,
|
||||
SHARE_LINK_EXPIRATION_OPTIONS,
|
||||
ShareLink,
|
||||
} from 'src/app/data/share-link'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -21,12 +25,7 @@ export class ShareLinksDialogComponent implements OnInit {
|
||||
private toastService = inject(ToastService)
|
||||
private clipboard = inject(Clipboard)
|
||||
|
||||
EXPIRATION_OPTIONS = [
|
||||
{ label: $localize`1 day`, value: 1 },
|
||||
{ label: $localize`7 days`, value: 7 },
|
||||
{ label: $localize`30 days`, value: 30 },
|
||||
{ label: $localize`Never`, value: null },
|
||||
]
|
||||
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
|
||||
|
||||
@Input()
|
||||
title = $localize`Share Links`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<pngx-page-header [(title)]="title">
|
||||
<pngx-page-header [(title)]="title" [id]="documentId">
|
||||
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
||||
@if (previewNumPages) {
|
||||
<div class="input-group input-group-sm d-none d-md-flex">
|
||||
@@ -370,6 +370,37 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (document?.duplicate_documents?.length) {
|
||||
<li [ngbNavItem]="DocumentDetailNavIDs.Duplicates">
|
||||
<a class="text-nowrap" ngbNavLink i18n>
|
||||
Duplicates
|
||||
<span class="badge text-bg-secondary ms-1">{{ document.duplicate_documents.length }}</span>
|
||||
</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div class="fst-italic" i18n>Duplicate documents detected:</div>
|
||||
<div class="list-group">
|
||||
@for (duplicate of document.duplicate_documents; track duplicate.id) {
|
||||
<a
|
||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||
[routerLink]="['/documents', duplicate.id, 'details']"
|
||||
[class.disabled]="duplicate.deleted_at"
|
||||
>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span>{{ duplicate.title || ('#' + duplicate.id) }}</span>
|
||||
@if (duplicate.deleted_at) {
|
||||
<span class="badge text-bg-secondary" i18n>In trash</span>
|
||||
}
|
||||
</span>
|
||||
<span class="text-secondary">#{{ duplicate.id }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
|
||||
@@ -301,16 +301,16 @@ describe('DocumentDetailComponent', () => {
|
||||
.spyOn(openDocumentsService, 'openDocument')
|
||||
.mockReturnValueOnce(of(true))
|
||||
fixture.detectChanges()
|
||||
expect(component.activeNavID).toEqual(5) // DocumentDetailNavIDs.Notes
|
||||
expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes)
|
||||
})
|
||||
|
||||
it('should change url on tab switch', () => {
|
||||
initNormally()
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
component.nav.select(5)
|
||||
component.nav.select(component.DocumentDetailNavIDs.Notes)
|
||||
component.nav.navChange.next({
|
||||
activeId: 1,
|
||||
nextId: 5,
|
||||
nextId: component.DocumentDetailNavIDs.Notes,
|
||||
preventDefault: () => {},
|
||||
})
|
||||
fixture.detectChanges()
|
||||
@@ -352,6 +352,18 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.document).toEqual(doc)
|
||||
})
|
||||
|
||||
it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
|
||||
initNormally()
|
||||
component.activeNavID = component.DocumentDetailNavIDs.Duplicates
|
||||
const noDupDoc = { ...doc, duplicate_documents: [] }
|
||||
|
||||
component.updateComponent(noDupDoc)
|
||||
|
||||
expect(component.activeNavID).toEqual(
|
||||
component.DocumentDetailNavIDs.Details
|
||||
)
|
||||
})
|
||||
|
||||
it('should load already-opened document via param', () => {
|
||||
initNormally()
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
|
||||
@@ -367,6 +379,38 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.document).toEqual(doc)
|
||||
})
|
||||
|
||||
it('should update cached open document duplicates when reloading an open doc', () => {
|
||||
const openDoc = { ...doc, duplicate_documents: [{ id: 1, title: 'Old' }] }
|
||||
const updatedDuplicates = [
|
||||
{ id: 2, title: 'Newer duplicate', deleted_at: null },
|
||||
]
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
|
||||
jest.spyOn(documentService, 'get').mockReturnValue(
|
||||
of({
|
||||
...doc,
|
||||
modified: new Date('2024-01-02T00:00:00Z'),
|
||||
duplicate_documents: updatedDuplicates,
|
||||
})
|
||||
)
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
const saveSpy = jest.spyOn(openDocumentsService, 'save')
|
||||
jest.spyOn(openDocumentsService, 'openDocument').mockReturnValue(of(true))
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: customFields.length,
|
||||
all: customFields.map((f) => f.id),
|
||||
results: customFields,
|
||||
})
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(openDoc.duplicate_documents).toEqual(updatedDuplicates)
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable form if user cannot edit', () => {
|
||||
currentUserHasObjectPermissions = false
|
||||
initNormally()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDateStruct,
|
||||
NgbDropdownModule,
|
||||
@@ -124,6 +124,7 @@ enum DocumentDetailNavIDs {
|
||||
Notes = 5,
|
||||
Permissions = 6,
|
||||
History = 7,
|
||||
Duplicates = 8,
|
||||
}
|
||||
|
||||
enum ContentRenderType {
|
||||
@@ -181,6 +182,7 @@ export enum ZoomSetting {
|
||||
NgxBootstrapIconsModule,
|
||||
PdfViewerModule,
|
||||
TextAreaComponent,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
export class DocumentDetailComponent
|
||||
@@ -454,6 +456,11 @@ export class DocumentDetailComponent
|
||||
const openDocument = this.openDocumentService.getOpenDocument(
|
||||
this.documentId
|
||||
)
|
||||
// update duplicate documents if present
|
||||
if (openDocument && doc?.duplicate_documents) {
|
||||
openDocument.duplicate_documents = doc.duplicate_documents
|
||||
this.openDocumentService.save()
|
||||
}
|
||||
const useDoc = openDocument || doc
|
||||
if (openDocument) {
|
||||
if (
|
||||
@@ -704,6 +711,13 @@ export class DocumentDetailComponent
|
||||
}
|
||||
this.title = this.documentTitlePipe.transform(doc.title)
|
||||
this.prepareForm(doc)
|
||||
|
||||
if (
|
||||
this.activeNavID === DocumentDetailNavIDs.Duplicates &&
|
||||
!doc?.duplicate_documents?.length
|
||||
) {
|
||||
this.activeNavID = DocumentDetailNavIDs.Details
|
||||
}
|
||||
}
|
||||
|
||||
get customFieldFormFields(): FormArray {
|
||||
|
||||
@@ -96,14 +96,36 @@
|
||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||
</button>
|
||||
@if (emailEnabled) {
|
||||
<button ngbDropdownItem (click)="emailSelected()">
|
||||
<i-bs name="envelope"></i-bs> <ng-container i18n>Email</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-toolbar" ngbDropdown>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
id="dropdownSend"
|
||||
ngbDropdownToggle
|
||||
[disabled]="disabled || list.selected.size === 0"
|
||||
>
|
||||
<i-bs name="send"></i-bs>
|
||||
<div class="d-none d-sm-inline">
|
||||
<ng-container i18n>Send</ng-container>
|
||||
</div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
||||
<button ngbDropdownItem (click)="createShareLinkBundle()">
|
||||
<i-bs name="link"></i-bs> <ng-container i18n>Create a share link bundle</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
||||
<i-bs name="list-ul"></i-bs> <ng-container i18n>Manage share link bundles</ng-container>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
@if (emailEnabled) {
|
||||
<button ngbDropdownItem (click)="emailSelected()">
|
||||
<i-bs name="envelope"></i-bs> <ng-container i18n>Email</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||
@if (!awaitingDownload) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing'
|
||||
import { EventEmitter } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
SelectionData,
|
||||
} from 'src/app/services/rest/document.service'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
@@ -38,6 +40,8 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
||||
import { BulkEditorComponent } from './bulk-editor.component'
|
||||
|
||||
const selectionData: SelectionData = {
|
||||
@@ -72,6 +76,7 @@ describe('BulkEditorComponent', () => {
|
||||
let storagePathService: StoragePathService
|
||||
let customFieldsService: CustomFieldsService
|
||||
let httpTestingController: HttpTestingController
|
||||
let shareLinkBundleService: ShareLinkBundleService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -152,6 +157,15 @@ describe('BulkEditorComponent', () => {
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ShareLinkBundleService,
|
||||
useValue: {
|
||||
createBundle: jest.fn(),
|
||||
listAllBundles: jest.fn(),
|
||||
rebuildBundle: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
@@ -168,6 +182,7 @@ describe('BulkEditorComponent', () => {
|
||||
storagePathService = TestBed.inject(StoragePathService)
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||
|
||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
@@ -1454,4 +1469,130 @@ describe('BulkEditorComponent', () => {
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should create share link bundle and enable manage callback', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 5 }, { id: 7 }] as any)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([5, 7]))
|
||||
|
||||
const confirmClicked = new EventEmitter<void>()
|
||||
const modalRef: Partial<NgbModalRef> = {
|
||||
close: jest.fn(),
|
||||
componentInstance: {
|
||||
documents: [],
|
||||
confirmClicked,
|
||||
payload: {
|
||||
document_ids: [5, 7],
|
||||
file_version: 'archive',
|
||||
expiration_days: 7,
|
||||
},
|
||||
loading: false,
|
||||
buttonsEnabled: true,
|
||||
copied: false,
|
||||
},
|
||||
}
|
||||
|
||||
const openSpy = jest.spyOn(modalService, 'open')
|
||||
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||
openSpy.mockReturnValueOnce({} as NgbModalRef)
|
||||
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
||||
of({ id: 42 })
|
||||
)
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
component.createShareLinkBundle()
|
||||
|
||||
expect(openSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
ShareLinkBundleDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
|
||||
const dialogInstance = modalRef.componentInstance as any
|
||||
expect(dialogInstance.documents).toEqual([{ id: 5 }, { id: 7 }])
|
||||
|
||||
confirmClicked.emit()
|
||||
|
||||
expect(shareLinkBundleService.createBundle).toHaveBeenCalledWith({
|
||||
document_ids: [5, 7],
|
||||
file_version: 'archive',
|
||||
expiration_days: 7,
|
||||
})
|
||||
expect(dialogInstance.loading).toBe(false)
|
||||
expect(dialogInstance.buttonsEnabled).toBe(false)
|
||||
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
|
||||
expect(typeof dialogInstance.onOpenManage).toBe('function')
|
||||
expect(toastInfoSpy).toHaveBeenCalledWith(
|
||||
$localize`Share link bundle creation requested.`
|
||||
)
|
||||
|
||||
dialogInstance.onOpenManage()
|
||||
expect(modalRef.close).toHaveBeenCalled()
|
||||
expect(openSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle share link bundle creation errors', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 9 }] as any)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([9]))
|
||||
|
||||
const confirmClicked = new EventEmitter<void>()
|
||||
const modalRef: Partial<NgbModalRef> = {
|
||||
componentInstance: {
|
||||
documents: [],
|
||||
confirmClicked,
|
||||
payload: {
|
||||
document_ids: [9],
|
||||
file_version: 'original',
|
||||
expiration_days: null,
|
||||
},
|
||||
loading: false,
|
||||
buttonsEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
const openSpy = jest
|
||||
.spyOn(modalService, 'open')
|
||||
.mockReturnValue(modalRef as NgbModalRef)
|
||||
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
||||
throwError(() => new Error('bundle failure'))
|
||||
)
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
component.createShareLinkBundle()
|
||||
|
||||
const dialogInstance = modalRef.componentInstance as any
|
||||
confirmClicked.emit()
|
||||
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||
$localize`Share link bundle creation is not available yet.`,
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(dialogInstance.loading).toBe(false)
|
||||
expect(dialogInstance.buttonsEnabled).toBe(true)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should open share link bundle management dialog', () => {
|
||||
const openSpy = jest.spyOn(modalService, 'open')
|
||||
component.manageShareLinkBundles()
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectionDataItem,
|
||||
} from 'src/app/services/rest/document.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
@@ -54,6 +55,8 @@ import {
|
||||
} from '../../common/filterable-dropdown/filterable-dropdown.component'
|
||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||
|
||||
@@ -87,6 +90,7 @@ export class BulkEditorComponent
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
private permissionService = inject(PermissionsService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -908,6 +912,58 @@ export class BulkEditorComponent
|
||||
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||
}
|
||||
|
||||
createShareLinkBundle() {
|
||||
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
const dialog = modal.componentInstance as ShareLinkBundleDialogComponent
|
||||
const selectedDocuments = this.list.documents.filter((d) =>
|
||||
this.list.selected.has(d.id)
|
||||
)
|
||||
dialog.documents = selectedDocuments
|
||||
dialog.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
dialog.loading = true
|
||||
dialog.buttonsEnabled = false
|
||||
this.shareLinkBundleService
|
||||
.createBundle(dialog.payload)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
dialog.loading = false
|
||||
dialog.buttonsEnabled = false
|
||||
dialog.createdBundle = result
|
||||
dialog.copied = false
|
||||
dialog.payload = null
|
||||
dialog.onOpenManage = () => {
|
||||
modal.close()
|
||||
this.manageShareLinkBundles()
|
||||
}
|
||||
this.toastService.showInfo(
|
||||
$localize`Share link bundle creation requested.`
|
||||
)
|
||||
},
|
||||
error: (error) => {
|
||||
dialog.loading = false
|
||||
dialog.buttonsEnabled = true
|
||||
this.toastService.showError(
|
||||
$localize`Share link bundle creation is not available yet.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
manageShareLinkBundles() {
|
||||
this.modalService.open(ShareLinkBundleManageDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
}
|
||||
|
||||
emailSelected() {
|
||||
const allHaveArchiveVersion = this.list.documents
|
||||
.filter((d) => this.list.selected.has(d.id))
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
@@ -36,6 +37,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
@@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||
<i-bs name="text-indent-left"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||
@if (selectedObjects.size > 0) {
|
||||
<pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
}
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||
<button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
|
||||
<button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
|
||||
<button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0" i18n>Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (selectedObjects.size > 0) {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
|
||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
|
||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
|
||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row mb-3">
|
||||
@@ -31,7 +62,7 @@
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-objects"></label>
|
||||
</div>
|
||||
</th>
|
||||
|
||||
@@ -163,8 +163,7 @@ describe('ManagementListComponent', () => {
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[4]
|
||||
createButton.triggerEventHandler('click')
|
||||
component.openCreateDialog()
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as EditDialogComponent<Tag>
|
||||
@@ -187,8 +186,7 @@ describe('ManagementListComponent', () => {
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
const editButton = fixture.debugElement.queryAll(By.css('button'))[7]
|
||||
editButton.triggerEventHandler('click')
|
||||
component.openEditDialog(tags[0])
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as EditDialogComponent<Tag>
|
||||
@@ -212,8 +210,7 @@ describe('ManagementListComponent', () => {
|
||||
const deleteSpy = jest.spyOn(tagService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8]
|
||||
deleteButton.triggerEventHandler('click')
|
||||
component.openDeleteDialog(tags[0])
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
@@ -230,6 +227,21 @@ describe('ManagementListComponent', () => {
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use the all list length for collection size when provided', fakeAsync(() => {
|
||||
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
||||
of({
|
||||
count: 1,
|
||||
all: [1, 2, 3],
|
||||
results: tags.slice(0, 1),
|
||||
})
|
||||
)
|
||||
|
||||
component.reloadData()
|
||||
tick(100)
|
||||
|
||||
expect(component.collectionSize).toBe(3)
|
||||
}))
|
||||
|
||||
it('should support quick filter for objects', () => {
|
||||
const expectedUrl = documentListViewService.getQuickFilterUrl([
|
||||
{ rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() },
|
||||
@@ -264,19 +276,84 @@ describe('ManagementListComponent', () => {
|
||||
expect(component.page).toEqual(1)
|
||||
})
|
||||
|
||||
it('should support toggle all items in view', () => {
|
||||
it('should support toggle select page in vew', () => {
|
||||
expect(component.selectedObjects.size).toEqual(0)
|
||||
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
|
||||
const selectPageSpy = jest.spyOn(component, 'selectPage')
|
||||
const checkButton = fixture.debugElement.queryAll(
|
||||
By.css('input.form-check-input')
|
||||
)[0]
|
||||
checkButton.nativeElement.dispatchEvent(new Event('click'))
|
||||
checkButton.nativeElement.dispatchEvent(new Event('change'))
|
||||
checkButton.nativeElement.checked = true
|
||||
checkButton.nativeElement.dispatchEvent(new Event('click'))
|
||||
expect(toggleAllSpy).toHaveBeenCalled()
|
||||
checkButton.nativeElement.dispatchEvent(new Event('change'))
|
||||
expect(selectPageSpy).toHaveBeenCalled()
|
||||
expect(component.selectedObjects.size).toEqual(tags.length)
|
||||
})
|
||||
|
||||
it('selectNone should clear selection and reset toggle flag', () => {
|
||||
component.selectedObjects = new Set([tags[0].id, tags[1].id])
|
||||
component.togggleAll = true
|
||||
|
||||
component.selectNone()
|
||||
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
|
||||
it('selectPage should select current page items or clear selection', () => {
|
||||
component.selectPage(true)
|
||||
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
|
||||
expect(component.togggleAll).toBe(true)
|
||||
|
||||
component.togggleAll = true
|
||||
component.selectPage(false)
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
|
||||
it('selectAll should use all IDs when collection size exists', () => {
|
||||
;(component as any).allIDs = [1, 2, 3, 4]
|
||||
component.collectionSize = 4
|
||||
|
||||
component.selectAll()
|
||||
|
||||
expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4]))
|
||||
expect(component.togggleAll).toBe(true)
|
||||
})
|
||||
|
||||
it('selectAll should clear selection when collection size is zero', () => {
|
||||
component.selectedObjects = new Set([1])
|
||||
component.collectionSize = 0
|
||||
component.togggleAll = true
|
||||
|
||||
component.selectAll()
|
||||
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleSelected should toggle object selection and update toggle state', () => {
|
||||
component.toggleSelected(tags[0])
|
||||
expect(component.selectedObjects.has(tags[0].id)).toBe(true)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
|
||||
component.toggleSelected(tags[1])
|
||||
component.toggleSelected(tags[2])
|
||||
expect(component.togggleAll).toBe(true)
|
||||
|
||||
component.toggleSelected(tags[1])
|
||||
expect(component.selectedObjects.has(tags[1].id)).toBe(false)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
|
||||
it('areAllPageItemsSelected should return false when page has no selectable items', () => {
|
||||
component.data = []
|
||||
component.selectedObjects.clear()
|
||||
|
||||
expect((component as any).areAllPageItemsSelected()).toBe(false)
|
||||
|
||||
component.data = tags
|
||||
})
|
||||
|
||||
it('should support bulk edit permissions', () => {
|
||||
const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
||||
component.toggleSelected(tags[0])
|
||||
|
||||
@@ -84,6 +84,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
|
||||
public data: T[] = []
|
||||
private unfilteredData: T[] = []
|
||||
private allIDs: number[] = []
|
||||
|
||||
public page = 1
|
||||
|
||||
@@ -171,7 +172,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
tap((c) => {
|
||||
this.unfilteredData = c.results
|
||||
this.data = this.filterData(c.results)
|
||||
this.collectionSize = c.count
|
||||
this.collectionSize = c.all?.length ?? c.count
|
||||
this.allIDs = c.all
|
||||
}),
|
||||
delay(100)
|
||||
)
|
||||
@@ -300,16 +302,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
return ownsAll
|
||||
}
|
||||
|
||||
toggleAll(event: PointerEvent) {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
this.togggleAll = checked
|
||||
if (checked) {
|
||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||
} else {
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
protected getSelectableIDs(objects: T[]): number[] {
|
||||
return objects.map((o) => o.id)
|
||||
}
|
||||
@@ -319,10 +311,38 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
this.selectedObjects.clear()
|
||||
}
|
||||
|
||||
selectNone() {
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
selectPage(select: boolean) {
|
||||
if (select) {
|
||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||
this.togggleAll = this.areAllPageItemsSelected()
|
||||
} else {
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
if (!this.collectionSize) {
|
||||
this.clearSelection()
|
||||
return
|
||||
}
|
||||
this.selectedObjects = new Set(this.allIDs)
|
||||
this.togggleAll = this.areAllPageItemsSelected()
|
||||
}
|
||||
|
||||
toggleSelected(object) {
|
||||
this.selectedObjects.has(object.id)
|
||||
? this.selectedObjects.delete(object.id)
|
||||
: this.selectedObjects.add(object.id)
|
||||
this.togggleAll = this.areAllPageItemsSelected()
|
||||
}
|
||||
|
||||
protected areAllPageItemsSelected(): boolean {
|
||||
const ids = this.getSelectableIDs(this.data)
|
||||
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
|
||||
}
|
||||
|
||||
setPermissions() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
@@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class StoragePathListComponent extends ManagementListComponent<StoragePath> {
|
||||
|
||||
@@ -138,16 +138,12 @@ describe('TagListComponent', () => {
|
||||
}
|
||||
|
||||
component.data = [parent as any]
|
||||
const selectEvent = { target: { checked: true } } as unknown as PointerEvent
|
||||
component.toggleAll(selectEvent)
|
||||
component.selectPage(true)
|
||||
|
||||
expect(component.selectedObjects.has(10)).toBe(true)
|
||||
expect(component.selectedObjects.has(11)).toBe(true)
|
||||
|
||||
const deselectEvent = {
|
||||
target: { checked: false },
|
||||
} as unknown as PointerEvent
|
||||
component.toggleAll(deselectEvent)
|
||||
component.selectPage(false)
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
@@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class TagListComponent extends ManagementListComponent<Tag> {
|
||||
|
||||
@@ -159,6 +159,8 @@ export interface Document extends ObjectWithPermissions {
|
||||
|
||||
page_count?: number
|
||||
|
||||
duplicate_documents?: Document[]
|
||||
|
||||
// Frontend only
|
||||
__changedFields?: string[]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Document } from './document'
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export enum PaperlessTaskType {
|
||||
@@ -42,5 +43,7 @@ export interface PaperlessTask extends ObjectWithId {
|
||||
|
||||
related_document?: number
|
||||
|
||||
duplicate_documents?: Document[]
|
||||
|
||||
owner?: number
|
||||
}
|
||||
|
||||
53
src-ui/src/app/data/share-link-bundle.ts
Normal file
53
src-ui/src/app/data/share-link-bundle.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { FileVersion } from './share-link'
|
||||
|
||||
export enum ShareLinkBundleStatus {
|
||||
Pending = 'pending',
|
||||
Processing = 'processing',
|
||||
Ready = 'ready',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
export type ShareLinkBundleError = {
|
||||
bundle_id: number
|
||||
message?: string
|
||||
exception_type?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export interface ShareLinkBundleSummary {
|
||||
id: number
|
||||
slug: string
|
||||
created: string // Date
|
||||
expiration?: string // Date
|
||||
documents: number[]
|
||||
document_count: number
|
||||
file_version: FileVersion
|
||||
status: ShareLinkBundleStatus
|
||||
built_at?: string
|
||||
size_bytes?: number
|
||||
last_error?: ShareLinkBundleError
|
||||
}
|
||||
|
||||
export interface ShareLinkBundleCreatePayload {
|
||||
document_ids: number[]
|
||||
file_version: FileVersion
|
||||
expiration_days: number | null
|
||||
}
|
||||
|
||||
export const SHARE_LINK_BUNDLE_STATUS_LABELS: Record<
|
||||
ShareLinkBundleStatus,
|
||||
string
|
||||
> = {
|
||||
[ShareLinkBundleStatus.Pending]: $localize`Pending`,
|
||||
[ShareLinkBundleStatus.Processing]: $localize`Processing`,
|
||||
[ShareLinkBundleStatus.Ready]: $localize`Ready`,
|
||||
[ShareLinkBundleStatus.Failed]: $localize`Failed`,
|
||||
}
|
||||
|
||||
export const SHARE_LINK_BUNDLE_FILE_VERSION_LABELS: Record<
|
||||
FileVersion,
|
||||
string
|
||||
> = {
|
||||
[FileVersion.Archive]: $localize`Archive`,
|
||||
[FileVersion.Original]: $localize`Original`,
|
||||
}
|
||||
@@ -5,6 +5,18 @@ export enum FileVersion {
|
||||
Original = 'original',
|
||||
}
|
||||
|
||||
export interface ShareLinkExpirationOption {
|
||||
label: string
|
||||
value: number | null
|
||||
}
|
||||
|
||||
export const SHARE_LINK_EXPIRATION_OPTIONS: ShareLinkExpirationOption[] = [
|
||||
{ label: $localize`1 day`, value: 1 },
|
||||
{ label: $localize`7 days`, value: 7 },
|
||||
{ label: $localize`30 days`, value: 30 },
|
||||
{ label: $localize`Never`, value: null },
|
||||
]
|
||||
|
||||
export interface ShareLink extends ObjectWithPermissions {
|
||||
created: string // Date
|
||||
|
||||
|
||||
@@ -44,10 +44,16 @@ export interface WorkflowTrigger extends ObjectWithId {
|
||||
|
||||
filter_has_not_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_any_correspondents?: number[] // Correspondent.id[]
|
||||
|
||||
filter_has_not_correspondents?: number[] // Correspondent.id[]
|
||||
|
||||
filter_has_any_document_types?: number[] // DocumentType.id[]
|
||||
|
||||
filter_has_not_document_types?: number[] // DocumentType.id[]
|
||||
|
||||
filter_has_any_storage_paths?: number[] // StoragePath.id[]
|
||||
|
||||
filter_has_not_storage_paths?: number[] // StoragePath.id[]
|
||||
|
||||
filter_custom_field_query?: string
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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 { ShareLinkBundleService } from './share-link-bundle.service'
|
||||
|
||||
const endpoint = 'share_link_bundles'
|
||||
|
||||
commonAbstractPaperlessServiceTests(endpoint, ShareLinkBundleService)
|
||||
|
||||
describe('ShareLinkBundleService', () => {
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: ShareLinkBundleService
|
||||
let subscription: Subscription | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(ShareLinkBundleService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
subscription?.unsubscribe()
|
||||
httpTestingController.verify()
|
||||
})
|
||||
|
||||
it('creates bundled share links', () => {
|
||||
const payload = {
|
||||
document_ids: [1, 2],
|
||||
file_version: 'archive',
|
||||
expiration_days: 7,
|
||||
}
|
||||
subscription = service.createBundle(payload as any).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/`
|
||||
)
|
||||
expect(req.request.method).toBe('POST')
|
||||
expect(req.request.body).toEqual(payload)
|
||||
req.flush({})
|
||||
})
|
||||
|
||||
it('rebuilds bundles', () => {
|
||||
subscription = service.rebuildBundle(12).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/12/rebuild/`
|
||||
)
|
||||
expect(req.request.method).toBe('POST')
|
||||
expect(req.request.body).toEqual({})
|
||||
req.flush({})
|
||||
})
|
||||
|
||||
it('lists bundles with expected parameters', () => {
|
||||
subscription = service.listAllBundles().subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
|
||||
)
|
||||
expect(req.request.method).toBe('GET')
|
||||
req.flush({ results: [] })
|
||||
})
|
||||
})
|
||||
41
src-ui/src/app/services/rest/share-link-bundle.service.ts
Normal file
41
src-ui/src/app/services/rest/share-link-bundle.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import {
|
||||
ShareLinkBundleCreatePayload,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { AbstractNameFilterService } from './abstract-name-filter-service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkBundleSummary> {
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'share_link_bundles'
|
||||
}
|
||||
|
||||
createBundle(
|
||||
payload: ShareLinkBundleCreatePayload
|
||||
): Observable<ShareLinkBundleSummary> {
|
||||
this.clearCache()
|
||||
return this.http.post<ShareLinkBundleSummary>(
|
||||
this.getResourceUrl(),
|
||||
payload
|
||||
)
|
||||
}
|
||||
rebuildBundle(bundleId: number): Observable<ShareLinkBundleSummary> {
|
||||
this.clearCache()
|
||||
return this.http.post<ShareLinkBundleSummary>(
|
||||
this.getResourceUrl(bundleId, 'rebuild'),
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
|
||||
return this.list(1, 1000, 'created', true).pipe(
|
||||
map((response) => response.results)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
}
|
||||
|
||||
@mixin dark-mode {
|
||||
color-scheme: dark;
|
||||
--pngx-body-color-accent: #{$text-color-dark-bg-accent};
|
||||
--pngx-bg-alt: #242529;
|
||||
--pngx-bg-alt2: #232323;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# this is here so that django finds the checks.
|
||||
from documents.checks import changed_password_check
|
||||
from documents.checks import parser_check
|
||||
|
||||
__all__ = ["changed_password_check", "parser_check"]
|
||||
__all__ = ["parser_check"]
|
||||
|
||||
@@ -13,6 +13,7 @@ from documents.models import PaperlessTask
|
||||
from documents.models import SavedView
|
||||
from documents.models import SavedViewFilterRule
|
||||
from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tasks import update_document_parent_tags
|
||||
@@ -60,7 +61,6 @@ class DocumentAdmin(GuardedModelAdmin):
|
||||
"added",
|
||||
"modified",
|
||||
"mime_type",
|
||||
"storage_type",
|
||||
"filename",
|
||||
"checksum",
|
||||
"archive_filename",
|
||||
@@ -185,6 +185,22 @@ class ShareLinksAdmin(GuardedModelAdmin):
|
||||
return super().get_queryset(request).select_related("document__correspondent")
|
||||
|
||||
|
||||
class ShareLinkBundleAdmin(GuardedModelAdmin):
|
||||
list_display = ("created", "status", "expiration", "owner", "slug")
|
||||
list_filter = ("status", "created", "expiration", "owner")
|
||||
search_fields = ("slug",)
|
||||
|
||||
def get_queryset(self, request): # pragma: no cover
|
||||
return (
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.select_related("owner")
|
||||
.prefetch_related(
|
||||
"documents",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldsAdmin(GuardedModelAdmin):
|
||||
fields = ("name", "created", "data_type")
|
||||
readonly_fields = ("created", "data_type")
|
||||
@@ -216,6 +232,7 @@ admin.site.register(StoragePath, StoragePathAdmin)
|
||||
admin.site.register(PaperlessTask, TaskAdmin)
|
||||
admin.site.register(Note, NotesAdmin)
|
||||
admin.site.register(ShareLink, ShareLinksAdmin)
|
||||
admin.site.register(ShareLinkBundle, ShareLinkBundleAdmin)
|
||||
admin.site.register(CustomField, CustomFieldsAdmin)
|
||||
admin.site.register(CustomFieldInstance, CustomFieldInstancesAdmin)
|
||||
|
||||
|
||||
@@ -1,60 +1,12 @@
|
||||
import textwrap
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.checks import Error
|
||||
from django.core.checks import Warning
|
||||
from django.core.checks import register
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.utils import OperationalError
|
||||
from django.db.utils import ProgrammingError
|
||||
|
||||
from documents.signals import document_consumer_declaration
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
|
||||
|
||||
@register()
|
||||
def changed_password_check(app_configs, **kwargs):
|
||||
from documents.models import Document
|
||||
from paperless.db import GnuPG
|
||||
|
||||
try:
|
||||
encrypted_doc = (
|
||||
Document.objects.filter(
|
||||
storage_type=Document.STORAGE_TYPE_GPG,
|
||||
)
|
||||
.only("pk", "storage_type")
|
||||
.first()
|
||||
)
|
||||
except (OperationalError, ProgrammingError, FieldError):
|
||||
return [] # No documents table yet
|
||||
|
||||
if encrypted_doc:
|
||||
if not settings.PASSPHRASE:
|
||||
return [
|
||||
Error(
|
||||
"The database contains encrypted documents but no password is set.",
|
||||
),
|
||||
]
|
||||
|
||||
if not GnuPG.decrypted(encrypted_doc.source_file):
|
||||
return [
|
||||
Error(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
The current password doesn't match the password of the
|
||||
existing documents.
|
||||
|
||||
If you intend to change your password, you must first export
|
||||
all of the old documents, start fresh with the new password
|
||||
and then re-import them."
|
||||
""",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@register()
|
||||
def parser_check(app_configs, **kwargs):
|
||||
parsers = []
|
||||
|
||||
@@ -128,7 +128,7 @@ def thumbnail_last_modified(request, pk: int) -> datetime | None:
|
||||
Cache should be (slightly?) faster than filesystem
|
||||
"""
|
||||
try:
|
||||
doc = Document.objects.only("storage_type").get(pk=pk)
|
||||
doc = Document.objects.only("pk").get(pk=pk)
|
||||
if not doc.thumbnail_path.exists():
|
||||
return None
|
||||
doc_key = get_thumbnail_modified_key(pk)
|
||||
|
||||
@@ -497,7 +497,6 @@ class ConsumerPlugin(
|
||||
create_source_path_directory(document.source_path)
|
||||
|
||||
self._write(
|
||||
document.storage_type,
|
||||
self.unmodified_original
|
||||
if self.unmodified_original is not None
|
||||
else self.working_copy,
|
||||
@@ -505,7 +504,6 @@ class ConsumerPlugin(
|
||||
)
|
||||
|
||||
self._write(
|
||||
document.storage_type,
|
||||
thumbnail,
|
||||
document.thumbnail_path,
|
||||
)
|
||||
@@ -517,7 +515,6 @@ class ConsumerPlugin(
|
||||
)
|
||||
create_source_path_directory(document.archive_path)
|
||||
self._write(
|
||||
document.storage_type,
|
||||
archive_path,
|
||||
document.archive_path,
|
||||
)
|
||||
@@ -637,8 +634,6 @@ class ConsumerPlugin(
|
||||
)
|
||||
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
||||
|
||||
storage_type = Document.STORAGE_TYPE_UNENCRYPTED
|
||||
|
||||
if self.metadata.filename:
|
||||
title = Path(self.metadata.filename).stem
|
||||
else:
|
||||
@@ -665,7 +660,6 @@ class ConsumerPlugin(
|
||||
checksum=hashlib.md5(file_for_checksum.read_bytes()).hexdigest(),
|
||||
created=create_date,
|
||||
modified=create_date,
|
||||
storage_type=storage_type,
|
||||
page_count=page_count,
|
||||
original_filename=self.filename,
|
||||
)
|
||||
@@ -736,7 +730,7 @@ class ConsumerPlugin(
|
||||
}
|
||||
CustomFieldInstance.objects.create(**args) # adds to document
|
||||
|
||||
def _write(self, storage_type, source, target):
|
||||
def _write(self, source, target):
|
||||
with (
|
||||
Path(source).open("rb") as read_file,
|
||||
Path(target).open("wb") as write_file,
|
||||
@@ -785,19 +779,45 @@ class ConsumerPreflightPlugin(
|
||||
Q(checksum=checksum) | Q(archive_checksum=checksum),
|
||||
)
|
||||
if existing_doc.exists():
|
||||
msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS
|
||||
log_msg = f"Not consuming {self.filename}: It is a duplicate of {existing_doc.get().title} (#{existing_doc.get().pk})."
|
||||
existing_doc = existing_doc.order_by("-created")
|
||||
duplicates_in_trash = existing_doc.filter(deleted_at__isnull=False)
|
||||
log_msg = (
|
||||
f"Consuming duplicate {self.filename}: "
|
||||
f"{existing_doc.count()} existing document(s) share the same content."
|
||||
)
|
||||
|
||||
if existing_doc.first().deleted_at is not None:
|
||||
msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH
|
||||
log_msg += " Note: existing document is in the trash."
|
||||
if duplicates_in_trash.exists():
|
||||
log_msg += " Note: at least one existing document is in the trash."
|
||||
|
||||
self.log.warning(log_msg)
|
||||
|
||||
if settings.CONSUMER_DELETE_DUPLICATES:
|
||||
duplicate = existing_doc.first()
|
||||
duplicate_label = (
|
||||
duplicate.title
|
||||
or duplicate.original_filename
|
||||
or (Path(duplicate.filename).name if duplicate.filename else None)
|
||||
or str(duplicate.pk)
|
||||
)
|
||||
|
||||
Path(self.input_doc.original_file).unlink()
|
||||
self._fail(
|
||||
msg,
|
||||
log_msg,
|
||||
)
|
||||
|
||||
failure_msg = (
|
||||
f"Not consuming {self.filename}: "
|
||||
f"It is a duplicate of {duplicate_label} (#{duplicate.pk})"
|
||||
)
|
||||
status_msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS
|
||||
|
||||
if duplicates_in_trash.exists():
|
||||
status_msg = (
|
||||
ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH
|
||||
)
|
||||
failure_msg += " Note: existing document is in the trash."
|
||||
|
||||
self._fail(
|
||||
status_msg,
|
||||
failure_msg,
|
||||
)
|
||||
|
||||
def pre_check_directories(self):
|
||||
"""
|
||||
|
||||
@@ -118,7 +118,7 @@ class DocumentMetadataOverrides:
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
overrides.custom_fields = {
|
||||
custom_field.id: custom_field.value
|
||||
custom_field.field.id: custom_field.value
|
||||
for custom_field in doc.custom_fields.all()
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ def generate_filename(
|
||||
doc: Document,
|
||||
*,
|
||||
counter=0,
|
||||
append_gpg=True,
|
||||
archive_filename=False,
|
||||
) -> Path:
|
||||
base_path: Path | None = None
|
||||
@@ -170,8 +169,4 @@ def generate_filename(
|
||||
final_filename = f"{doc.pk:07}{counter_str}{filetype_str}"
|
||||
full_path = Path(final_filename)
|
||||
|
||||
# Add GPG extension if needed
|
||||
if append_gpg and doc.storage_type == doc.STORAGE_TYPE_GPG:
|
||||
full_path = full_path.with_suffix(full_path.suffix + ".gpg")
|
||||
|
||||
return full_path
|
||||
|
||||
@@ -39,6 +39,7 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
|
||||
@@ -796,6 +797,29 @@ class ShareLinkFilterSet(FilterSet):
|
||||
}
|
||||
|
||||
|
||||
class ShareLinkBundleFilterSet(FilterSet):
|
||||
documents = Filter(method="filter_documents")
|
||||
|
||||
class Meta:
|
||||
model = ShareLinkBundle
|
||||
fields = {
|
||||
"created": DATETIME_KWARGS,
|
||||
"expiration": DATETIME_KWARGS,
|
||||
"status": ["exact"],
|
||||
}
|
||||
|
||||
def filter_documents(self, queryset, name, value):
|
||||
ids = []
|
||||
if value:
|
||||
try:
|
||||
ids = [int(item) for item in value.split(",") if item]
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
if not ids:
|
||||
return queryset
|
||||
return queryset.filter(documents__in=ids).distinct()
|
||||
|
||||
|
||||
class PaperlessTaskFilterSet(FilterSet):
|
||||
acknowledged = BooleanFilter(
|
||||
label="Acknowledged",
|
||||
|
||||
@@ -602,7 +602,7 @@ def rewrite_natural_date_keywords(query_string: str) -> str:
|
||||
|
||||
case "this year":
|
||||
start = datetime(local_now.year, 1, 1, 0, 0, 0, tzinfo=tz)
|
||||
end = datetime.combine(today, time.max, tzinfo=tz)
|
||||
end = datetime(local_now.year, 12, 31, 23, 59, 59, tzinfo=tz)
|
||||
|
||||
case "previous week":
|
||||
days_since_monday = local_now.weekday()
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
from documents.models import Document
|
||||
from paperless.db import GnuPG
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"This is how you migrate your stored documents from an encrypted "
|
||||
"state to an unencrypted one (or vice-versa)"
|
||||
)
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument(
|
||||
"--passphrase",
|
||||
help=(
|
||||
"If PAPERLESS_PASSPHRASE isn't set already, you need to specify it here"
|
||||
),
|
||||
)
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
try:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"\n\n"
|
||||
"WARNING: This script is going to work directly on your "
|
||||
"document originals, so\n"
|
||||
"WARNING: you probably shouldn't run "
|
||||
"this unless you've got a recent backup\n"
|
||||
"WARNING: handy. It "
|
||||
"*should* work without a hitch, but be safe and backup your\n"
|
||||
"WARNING: stuff first.\n\n"
|
||||
"Hit Ctrl+C to exit now, or Enter to "
|
||||
"continue.\n\n",
|
||||
),
|
||||
)
|
||||
_ = input()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
|
||||
passphrase = options["passphrase"] or settings.PASSPHRASE
|
||||
if not passphrase:
|
||||
raise CommandError(
|
||||
"Passphrase not defined. Please set it with --passphrase or "
|
||||
"by declaring it in your environment or your config.",
|
||||
)
|
||||
|
||||
self.__gpg_to_unencrypted(passphrase)
|
||||
|
||||
def __gpg_to_unencrypted(self, passphrase: str) -> None:
|
||||
encrypted_files = Document.objects.filter(
|
||||
storage_type=Document.STORAGE_TYPE_GPG,
|
||||
)
|
||||
|
||||
for document in encrypted_files:
|
||||
self.stdout.write(f"Decrypting {document}")
|
||||
|
||||
old_paths = [document.source_path, document.thumbnail_path]
|
||||
|
||||
with document.source_file as file_handle:
|
||||
raw_document = GnuPG.decrypted(file_handle, passphrase)
|
||||
with document.thumbnail_file as file_handle:
|
||||
raw_thumb = GnuPG.decrypted(file_handle, passphrase)
|
||||
|
||||
document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
|
||||
|
||||
ext: str = Path(document.filename).suffix
|
||||
|
||||
if not ext == ".gpg":
|
||||
raise CommandError(
|
||||
f"Abort: encrypted file {document.source_path} does not "
|
||||
f"end with .gpg",
|
||||
)
|
||||
|
||||
document.filename = Path(document.filename).stem
|
||||
|
||||
with document.source_path.open("wb") as f:
|
||||
f.write(raw_document)
|
||||
|
||||
with document.thumbnail_path.open("wb") as f:
|
||||
f.write(raw_thumb)
|
||||
|
||||
Document.objects.filter(id=document.id).update(
|
||||
storage_type=document.storage_type,
|
||||
filename=document.filename,
|
||||
)
|
||||
|
||||
for path in old_paths:
|
||||
path.unlink()
|
||||
@@ -1,135 +1,343 @@
|
||||
"""
|
||||
Document consumer management command.
|
||||
|
||||
Watches a consumption directory for new documents and queues them for processing.
|
||||
Uses watchfiles for efficient file system monitoring with support for both
|
||||
native OS notifications and polling fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from fnmatch import filter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from pathlib import PurePath
|
||||
from threading import Event
|
||||
from time import monotonic
|
||||
from time import sleep
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Final
|
||||
|
||||
from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import CommandError
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers.polling import PollingObserver
|
||||
from watchfiles import Change
|
||||
from watchfiles import DefaultFilter
|
||||
from watchfiles import watch
|
||||
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import Tag
|
||||
from documents.parsers import is_file_ext_supported
|
||||
from documents.parsers import get_supported_file_extensions
|
||||
from documents.tasks import consume_file
|
||||
|
||||
try:
|
||||
from inotifyrecursive import INotify
|
||||
from inotifyrecursive import flags
|
||||
except ImportError: # pragma: no cover
|
||||
INotify = flags = None
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
logger = logging.getLogger("paperless.management.consumer")
|
||||
|
||||
|
||||
def _tags_from_path(filepath: Path) -> list[int]:
|
||||
@dataclass
|
||||
class TrackedFile:
|
||||
"""Represents a file being tracked for stability."""
|
||||
|
||||
path: Path
|
||||
last_event_time: float
|
||||
last_mtime: float | None = None
|
||||
last_size: int | None = None
|
||||
|
||||
def update_stats(self) -> bool:
|
||||
"""
|
||||
Update file stats. Returns True if file exists and stats were updated.
|
||||
"""
|
||||
try:
|
||||
stat = self.path.stat()
|
||||
self.last_mtime = stat.st_mtime
|
||||
self.last_size = stat.st_size
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def is_unchanged(self) -> bool:
|
||||
"""
|
||||
Check if file stats match the previously recorded values.
|
||||
Returns False if file doesn't exist or stats changed.
|
||||
"""
|
||||
try:
|
||||
stat = self.path.stat()
|
||||
return stat.st_mtime == self.last_mtime and stat.st_size == self.last_size
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class FileStabilityTracker:
|
||||
"""
|
||||
Walk up the directory tree from filepath to CONSUMPTION_DIR
|
||||
Tracks file events and determines when files are stable for consumption.
|
||||
|
||||
A file is considered stable when:
|
||||
1. No new events have been received for it within the stability delay
|
||||
2. Its size and modification time haven't changed
|
||||
3. It still exists as a regular file
|
||||
|
||||
This handles various edge cases:
|
||||
- Network copies that write in chunks
|
||||
- Scanners that open/close files multiple times
|
||||
- Temporary files that get renamed
|
||||
- Files that are deleted before becoming stable
|
||||
"""
|
||||
|
||||
def __init__(self, stability_delay: float = 1.0) -> None:
|
||||
"""
|
||||
Initialize the tracker.
|
||||
|
||||
Args:
|
||||
stability_delay: Time in seconds a file must remain unchanged
|
||||
before being considered stable.
|
||||
"""
|
||||
self.stability_delay = stability_delay
|
||||
self._tracked: dict[Path, TrackedFile] = {}
|
||||
|
||||
def track(self, path: Path, change: Change) -> None:
|
||||
"""
|
||||
Register a file event.
|
||||
|
||||
Args:
|
||||
path: The file path that changed.
|
||||
change: The type of change (added, modified, deleted).
|
||||
"""
|
||||
path = path.resolve()
|
||||
|
||||
match change:
|
||||
case Change.deleted:
|
||||
self._tracked.pop(path, None)
|
||||
logger.debug(f"Stopped tracking deleted file: {path}")
|
||||
case Change.added | Change.modified:
|
||||
current_time = monotonic()
|
||||
if path in self._tracked:
|
||||
tracked = self._tracked[path]
|
||||
tracked.last_event_time = current_time
|
||||
tracked.update_stats()
|
||||
logger.debug(f"Updated tracking for: {path}")
|
||||
else:
|
||||
tracked = TrackedFile(path=path, last_event_time=current_time)
|
||||
if tracked.update_stats():
|
||||
self._tracked[path] = tracked
|
||||
logger.debug(f"Started tracking: {path}")
|
||||
else:
|
||||
logger.debug(f"Could not stat file, not tracking: {path}")
|
||||
|
||||
def get_stable_files(self) -> Iterator[Path]:
|
||||
"""
|
||||
Yield files that have been stable for the configured delay.
|
||||
|
||||
Files are removed from tracking once yielded or determined to be invalid.
|
||||
"""
|
||||
current_time = monotonic()
|
||||
to_remove: list[Path] = []
|
||||
to_yield: list[Path] = []
|
||||
|
||||
for path, tracked in self._tracked.items():
|
||||
time_since_event = current_time - tracked.last_event_time
|
||||
|
||||
if time_since_event < self.stability_delay:
|
||||
continue
|
||||
|
||||
# File has waited long enough, verify it's unchanged
|
||||
if not tracked.is_unchanged():
|
||||
# Stats changed or file gone - update and wait again
|
||||
if tracked.update_stats():
|
||||
tracked.last_event_time = current_time
|
||||
logger.debug(f"File changed during stability check: {path}")
|
||||
else:
|
||||
# File no longer exists, remove from tracking
|
||||
to_remove.append(path)
|
||||
logger.debug(f"File disappeared during stability check: {path}")
|
||||
continue
|
||||
|
||||
# File is stable, we can return it
|
||||
to_yield.append(path)
|
||||
logger.info(f"File is stable: {path}")
|
||||
|
||||
# Remove files that are no longer valid
|
||||
for path in to_remove:
|
||||
self._tracked.pop(path, None)
|
||||
|
||||
# Remove and yield stable files
|
||||
for path in to_yield:
|
||||
self._tracked.pop(path, None)
|
||||
yield path
|
||||
|
||||
def has_pending_files(self) -> bool:
|
||||
"""Check if there are files waiting for stability check."""
|
||||
return len(self._tracked) > 0
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
"""Number of files being tracked."""
|
||||
return len(self._tracked)
|
||||
|
||||
|
||||
class ConsumerFilter(DefaultFilter):
|
||||
"""
|
||||
Filter for watchfiles that accepts only supported document types
|
||||
and ignores system files/directories.
|
||||
|
||||
Extends DefaultFilter leveraging its built-in filtering:
|
||||
- `ignore_dirs`: Directory names to ignore (and all their contents)
|
||||
- `ignore_entity_patterns`: Regex patterns matched against filename/dirname only
|
||||
|
||||
We add custom logic for file extension filtering (only accept supported
|
||||
document types), which the library doesn't provide.
|
||||
"""
|
||||
|
||||
# Regex patterns for files to always ignore (matched against filename only)
|
||||
# These are passed to DefaultFilter.ignore_entity_patterns
|
||||
DEFAULT_IGNORE_PATTERNS: Final[tuple[str, ...]] = (
|
||||
r"^\.DS_Store$",
|
||||
r"^\.DS_STORE$",
|
||||
r"^\._.*",
|
||||
r"^desktop\.ini$",
|
||||
r"^Thumbs\.db$",
|
||||
)
|
||||
|
||||
# Directories to always ignore (passed to DefaultFilter.ignore_dirs)
|
||||
# These are matched by directory name, not full path
|
||||
DEFAULT_IGNORE_DIRS: Final[tuple[str, ...]] = (
|
||||
".stfolder", # Syncthing
|
||||
".stversions", # Syncthing
|
||||
".localized", # macOS
|
||||
"@eaDir", # Synology NAS
|
||||
".Spotlight-V100", # macOS
|
||||
".Trashes", # macOS
|
||||
"__MACOSX", # macOS archive artifacts
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
supported_extensions: frozenset[str] | None = None,
|
||||
ignore_patterns: list[str] | None = None,
|
||||
ignore_dirs: list[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the consumer filter.
|
||||
|
||||
Args:
|
||||
supported_extensions: Set of file extensions to accept (e.g., {".pdf", ".png"}).
|
||||
If None, uses get_supported_file_extensions().
|
||||
ignore_patterns: Additional regex patterns to ignore (matched against filename).
|
||||
ignore_dirs: Additional directory names to ignore (merged with defaults).
|
||||
"""
|
||||
# Get supported extensions
|
||||
if supported_extensions is None:
|
||||
supported_extensions = frozenset(get_supported_file_extensions())
|
||||
self._supported_extensions = supported_extensions
|
||||
|
||||
# Combine default and user patterns
|
||||
all_patterns: list[str] = list(self.DEFAULT_IGNORE_PATTERNS)
|
||||
if ignore_patterns:
|
||||
all_patterns.extend(ignore_patterns)
|
||||
|
||||
# Combine default and user ignore_dirs
|
||||
all_ignore_dirs: list[str] = list(self.DEFAULT_IGNORE_DIRS)
|
||||
if ignore_dirs:
|
||||
all_ignore_dirs.extend(ignore_dirs)
|
||||
|
||||
# Let DefaultFilter handle all the pattern and directory filtering
|
||||
super().__init__(
|
||||
ignore_dirs=tuple(all_ignore_dirs),
|
||||
ignore_entity_patterns=tuple(all_patterns),
|
||||
ignore_paths=(),
|
||||
)
|
||||
|
||||
def __call__(self, change: Change, path: str) -> bool:
|
||||
"""
|
||||
Filter function for watchfiles.
|
||||
|
||||
Returns True if the path should be watched, False to ignore.
|
||||
|
||||
The parent DefaultFilter handles:
|
||||
- Hidden files/directories (starting with .)
|
||||
- Directories in ignore_dirs
|
||||
- Files/directories matching ignore_entity_patterns
|
||||
|
||||
We additionally filter files by extension.
|
||||
"""
|
||||
# Let parent filter handle directory ignoring and pattern matching
|
||||
if not super().__call__(change, path):
|
||||
return False
|
||||
|
||||
path_obj = Path(path)
|
||||
|
||||
# For directories, parent filter already handled everything
|
||||
if path_obj.is_dir():
|
||||
return True
|
||||
|
||||
# For files, check extension
|
||||
return self._has_supported_extension(path_obj)
|
||||
|
||||
def _has_supported_extension(self, path: Path) -> bool:
|
||||
"""Check if the file has a supported extension."""
|
||||
suffix = path.suffix.lower()
|
||||
return suffix in self._supported_extensions
|
||||
|
||||
|
||||
def _tags_from_path(filepath: Path, consumption_dir: Path) -> list[int]:
|
||||
"""
|
||||
Walk up the directory tree from filepath to consumption_dir
|
||||
and get or create Tag IDs for every directory.
|
||||
|
||||
Returns set of Tag models
|
||||
Returns list of Tag primary keys.
|
||||
"""
|
||||
db.close_old_connections()
|
||||
tag_ids = set()
|
||||
path_parts = filepath.relative_to(settings.CONSUMPTION_DIR).parent.parts
|
||||
tag_ids: set[int] = set()
|
||||
path_parts = filepath.relative_to(consumption_dir).parent.parts
|
||||
|
||||
for part in path_parts:
|
||||
tag_ids.add(
|
||||
Tag.objects.get_or_create(name__iexact=part, defaults={"name": part})[0].pk,
|
||||
tag, _ = Tag.objects.get_or_create(
|
||||
name__iexact=part,
|
||||
defaults={"name": part},
|
||||
)
|
||||
tag_ids.add(tag.pk)
|
||||
|
||||
return list(tag_ids)
|
||||
|
||||
|
||||
def _is_ignored(filepath: Path) -> bool:
|
||||
def _consume_file(
|
||||
filepath: Path,
|
||||
consumption_dir: Path,
|
||||
*,
|
||||
subdirs_as_tags: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Checks if the given file should be ignored, based on configured
|
||||
patterns.
|
||||
Queue a file for consumption.
|
||||
|
||||
Returns True if the file is ignored, False otherwise
|
||||
Args:
|
||||
filepath: Path to the file to consume.
|
||||
consumption_dir: Base consumption directory.
|
||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||
"""
|
||||
# Trim out the consume directory, leaving only filename and it's
|
||||
# path relative to the consume directory
|
||||
filepath_relative = PurePath(filepath).relative_to(settings.CONSUMPTION_DIR)
|
||||
|
||||
# March through the components of the path, including directories and the filename
|
||||
# looking for anything matching
|
||||
# foo/bar/baz/file.pdf -> (foo, bar, baz, file.pdf)
|
||||
parts = []
|
||||
for part in filepath_relative.parts:
|
||||
# If the part is not the name (ie, it's a dir)
|
||||
# Need to append the trailing slash or fnmatch doesn't match
|
||||
# fnmatch("dir", "dir/*") == False
|
||||
# fnmatch("dir/", "dir/*") == True
|
||||
if part != filepath_relative.name:
|
||||
part = part + "/"
|
||||
parts.append(part)
|
||||
|
||||
for pattern in settings.CONSUMER_IGNORE_PATTERNS:
|
||||
if len(filter(parts, pattern)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _consume(filepath: Path) -> None:
|
||||
# Check permissions early
|
||||
# Verify file still exists and is accessible
|
||||
try:
|
||||
filepath.stat()
|
||||
except (PermissionError, OSError):
|
||||
logger.warning(f"Not consuming file {filepath}: Permission denied.")
|
||||
if not filepath.is_file():
|
||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||
return
|
||||
except OSError as e:
|
||||
logger.warning(f"Not consuming {filepath}: {e}")
|
||||
return
|
||||
|
||||
if filepath.is_dir() or _is_ignored(filepath):
|
||||
return
|
||||
|
||||
if not filepath.is_file():
|
||||
logger.debug(f"Not consuming file {filepath}: File has moved.")
|
||||
return
|
||||
|
||||
if not is_file_ext_supported(filepath.suffix):
|
||||
logger.warning(f"Not consuming file {filepath}: Unknown file extension.")
|
||||
return
|
||||
|
||||
# Total wait time: up to 500ms
|
||||
os_error_retry_count: Final[int] = 50
|
||||
os_error_retry_wait: Final[float] = 0.01
|
||||
|
||||
read_try_count = 0
|
||||
file_open_ok = False
|
||||
os_error_str = None
|
||||
|
||||
while (read_try_count < os_error_retry_count) and not file_open_ok:
|
||||
# Get tags from path if configured
|
||||
tag_ids: list[int] | None = None
|
||||
if subdirs_as_tags:
|
||||
try:
|
||||
with filepath.open("rb"):
|
||||
file_open_ok = True
|
||||
except OSError as e:
|
||||
read_try_count += 1
|
||||
os_error_str = str(e)
|
||||
sleep(os_error_retry_wait)
|
||||
tag_ids = _tags_from_path(filepath, consumption_dir)
|
||||
except Exception:
|
||||
logger.exception(f"Error creating tags from path for {filepath}")
|
||||
|
||||
if read_try_count >= os_error_retry_count:
|
||||
logger.warning(f"Not consuming file {filepath}: OS reports {os_error_str}")
|
||||
return
|
||||
|
||||
tag_ids = None
|
||||
# Queue for consumption
|
||||
try:
|
||||
if settings.CONSUMER_SUBDIRS_AS_TAGS:
|
||||
tag_ids = _tags_from_path(filepath)
|
||||
except Exception:
|
||||
logger.exception("Error creating tags from path")
|
||||
|
||||
try:
|
||||
logger.info(f"Adding {filepath} to the task queue.")
|
||||
logger.info(f"Adding {filepath} to the task queue")
|
||||
consume_file.delay(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
@@ -138,228 +346,209 @@ def _consume(filepath: Path) -> None:
|
||||
DocumentMetadataOverrides(tag_ids=tag_ids),
|
||||
)
|
||||
except Exception:
|
||||
# Catch all so that the consumer won't crash.
|
||||
# This is also what the test case is listening for to check for
|
||||
# errors.
|
||||
logger.exception("Error while consuming document")
|
||||
|
||||
|
||||
def _consume_wait_unmodified(file: Path) -> None:
|
||||
"""
|
||||
Waits for the given file to appear unmodified based on file size
|
||||
and modification time. Will wait a configured number of seconds
|
||||
and retry a configured number of times before either consuming or
|
||||
giving up
|
||||
"""
|
||||
if _is_ignored(file):
|
||||
return
|
||||
|
||||
logger.debug(f"Waiting for file {file} to remain unmodified")
|
||||
mtime = -1
|
||||
size = -1
|
||||
current_try = 0
|
||||
while current_try < settings.CONSUMER_POLLING_RETRY_COUNT:
|
||||
try:
|
||||
stat_data = file.stat()
|
||||
new_mtime = stat_data.st_mtime
|
||||
new_size = stat_data.st_size
|
||||
except FileNotFoundError:
|
||||
logger.debug(
|
||||
f"File {file} moved while waiting for it to remain unmodified.",
|
||||
)
|
||||
return
|
||||
if new_mtime == mtime and new_size == size:
|
||||
_consume(file)
|
||||
return
|
||||
mtime = new_mtime
|
||||
size = new_size
|
||||
sleep(settings.CONSUMER_POLLING_DELAY)
|
||||
current_try += 1
|
||||
|
||||
logger.error(f"Timeout while waiting on file {file} to remain unmodified.")
|
||||
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
def __init__(self, pool: ThreadPoolExecutor) -> None:
|
||||
super().__init__()
|
||||
self._pool = pool
|
||||
|
||||
def on_created(self, event):
|
||||
self._pool.submit(_consume_wait_unmodified, Path(event.src_path))
|
||||
|
||||
def on_moved(self, event):
|
||||
self._pool.submit(_consume_wait_unmodified, Path(event.dest_path))
|
||||
logger.exception(f"Error while queuing document {filepath}")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
On every iteration of an infinite loop, consume what we can from the
|
||||
consumption directory.
|
||||
Watch a consumption directory and queue new documents for processing.
|
||||
|
||||
Uses watchfiles for efficient file system monitoring. Supports both
|
||||
native OS notifications (inotify on Linux, FSEvents on macOS) and
|
||||
polling for network filesystems.
|
||||
"""
|
||||
|
||||
# This is here primarily for the tests and is irrelevant in production.
|
||||
stop_flag = Event()
|
||||
# Also only for testing, configures in one place the timeout used before checking
|
||||
# the stop flag
|
||||
testing_timeout_s: Final[float] = 0.5
|
||||
testing_timeout_ms: Final[float] = testing_timeout_s * 1000.0
|
||||
help = "Watch the consumption directory for new documents"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# For testing - allows tests to stop the consumer
|
||||
stop_flag: Event = Event()
|
||||
|
||||
# Testing timeout in seconds
|
||||
testing_timeout_s: Final[float] = 0.5
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument(
|
||||
"directory",
|
||||
default=settings.CONSUMPTION_DIR,
|
||||
default=None,
|
||||
nargs="?",
|
||||
help="The consumption directory.",
|
||||
help="The consumption directory (defaults to CONSUMPTION_DIR setting)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--oneshot",
|
||||
action="store_true",
|
||||
help="Process existing files and exit without watching",
|
||||
)
|
||||
parser.add_argument("--oneshot", action="store_true", help="Run only once.")
|
||||
|
||||
# Only use during unit testing, will configure a timeout
|
||||
# Leaving it unset or false and the consumer will exit when it
|
||||
# receives SIGINT
|
||||
parser.add_argument(
|
||||
"--testing",
|
||||
action="store_true",
|
||||
help="Flag used only for unit testing",
|
||||
help="Enable testing mode with shorter timeouts",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
directory = options["directory"]
|
||||
recursive = settings.CONSUMER_RECURSIVE
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
# Resolve consumption directory
|
||||
directory = options.get("directory")
|
||||
if not directory:
|
||||
raise CommandError("CONSUMPTION_DIR does not appear to be set.")
|
||||
directory = getattr(settings, "CONSUMPTION_DIR", None)
|
||||
if not directory:
|
||||
raise CommandError("CONSUMPTION_DIR is not configured")
|
||||
|
||||
directory = Path(directory).resolve()
|
||||
|
||||
if not directory.is_dir():
|
||||
raise CommandError(f"Consumption directory {directory} does not exist")
|
||||
if not directory.exists():
|
||||
raise CommandError(f"Consumption directory does not exist: {directory}")
|
||||
|
||||
# Consumer will need this
|
||||
if not directory.is_dir():
|
||||
raise CommandError(f"Consumption path is not a directory: {directory}")
|
||||
|
||||
# Ensure scratch directory exists
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if recursive:
|
||||
for dirpath, _, filenames in os.walk(directory):
|
||||
for filename in filenames:
|
||||
filepath = Path(dirpath) / filename
|
||||
_consume(filepath)
|
||||
else:
|
||||
for filepath in directory.iterdir():
|
||||
_consume(filepath)
|
||||
# Get settings
|
||||
recursive: bool = settings.CONSUMER_RECURSIVE
|
||||
subdirs_as_tags: bool = settings.CONSUMER_SUBDIRS_AS_TAGS
|
||||
polling_interval: float = settings.CONSUMER_POLLING_INTERVAL
|
||||
stability_delay: float = settings.CONSUMER_STABILITY_DELAY
|
||||
ignore_patterns: list[str] = settings.CONSUMER_IGNORE_PATTERNS
|
||||
ignore_dirs: list[str] = settings.CONSUMER_IGNORE_DIRS
|
||||
is_testing: bool = options.get("testing", False)
|
||||
is_oneshot: bool = options.get("oneshot", False)
|
||||
|
||||
if options["oneshot"]:
|
||||
# Create filter
|
||||
consumer_filter = ConsumerFilter(
|
||||
ignore_patterns=ignore_patterns,
|
||||
ignore_dirs=ignore_dirs,
|
||||
)
|
||||
|
||||
# Process existing files
|
||||
self._process_existing_files(
|
||||
directory=directory,
|
||||
recursive=recursive,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
consumer_filter=consumer_filter,
|
||||
)
|
||||
|
||||
if is_oneshot:
|
||||
logger.info("Oneshot mode: processed existing files, exiting")
|
||||
return
|
||||
|
||||
if settings.CONSUMER_POLLING == 0 and INotify:
|
||||
self.handle_inotify(directory, recursive, is_testing=options["testing"])
|
||||
# Start watching
|
||||
self._watch_directory(
|
||||
directory=directory,
|
||||
recursive=recursive,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
consumer_filter=consumer_filter,
|
||||
polling_interval=polling_interval,
|
||||
stability_delay=stability_delay,
|
||||
is_testing=is_testing,
|
||||
)
|
||||
|
||||
logger.debug("Consumer exiting")
|
||||
|
||||
def _process_existing_files(
|
||||
self,
|
||||
*,
|
||||
directory: Path,
|
||||
recursive: bool,
|
||||
subdirs_as_tags: bool,
|
||||
consumer_filter: ConsumerFilter,
|
||||
) -> None:
|
||||
"""Process any existing files in the consumption directory."""
|
||||
logger.info(f"Processing existing files in {directory}")
|
||||
|
||||
glob_pattern = "**/*" if recursive else "*"
|
||||
|
||||
for filepath in directory.glob(glob_pattern):
|
||||
# Use filter to check if file should be processed
|
||||
if not filepath.is_file():
|
||||
continue
|
||||
|
||||
if not consumer_filter(Change.added, str(filepath)):
|
||||
continue
|
||||
|
||||
_consume_file(
|
||||
filepath=filepath,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
|
||||
def _watch_directory(
|
||||
self,
|
||||
*,
|
||||
directory: Path,
|
||||
recursive: bool,
|
||||
subdirs_as_tags: bool,
|
||||
consumer_filter: ConsumerFilter,
|
||||
polling_interval: float,
|
||||
stability_delay: float,
|
||||
is_testing: bool,
|
||||
) -> None:
|
||||
"""Watch directory for changes and process stable files."""
|
||||
use_polling = polling_interval > 0
|
||||
poll_delay_ms = int(polling_interval * 1000) if use_polling else 0
|
||||
|
||||
if use_polling:
|
||||
logger.info(
|
||||
f"Watching {directory} using polling (interval: {polling_interval}s)",
|
||||
)
|
||||
else:
|
||||
if INotify is None and settings.CONSUMER_POLLING == 0: # pragma: no cover
|
||||
logger.warning("Using polling as INotify import failed")
|
||||
self.handle_polling(directory, recursive, is_testing=options["testing"])
|
||||
logger.info(f"Watching {directory} using native file system events")
|
||||
|
||||
logger.debug("Consumer exiting.")
|
||||
# Create stability tracker
|
||||
tracker = FileStabilityTracker(stability_delay=stability_delay)
|
||||
|
||||
def handle_polling(self, directory, recursive, *, is_testing: bool):
|
||||
logger.info(f"Polling directory for changes: {directory}")
|
||||
# Calculate timeouts
|
||||
stability_timeout_ms = int(stability_delay * 1000)
|
||||
testing_timeout_ms = int(self.testing_timeout_s * 1000)
|
||||
|
||||
timeout = None
|
||||
if is_testing:
|
||||
timeout = self.testing_timeout_s
|
||||
logger.debug(f"Configuring timeout to {timeout}s")
|
||||
# Start with no timeout (wait indefinitely for first event)
|
||||
# unless in testing mode
|
||||
timeout_ms = testing_timeout_ms if is_testing else 0
|
||||
|
||||
polling_interval = settings.CONSUMER_POLLING
|
||||
if polling_interval == 0: # pragma: no cover
|
||||
# Only happens if INotify failed to import
|
||||
logger.warning("Using polling of 10s, consider setting this")
|
||||
polling_interval = 10
|
||||
self.stop_flag.clear()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
observer = PollingObserver(timeout=polling_interval)
|
||||
observer.schedule(Handler(pool), directory, recursive=recursive)
|
||||
observer.start()
|
||||
while not self.stop_flag.is_set():
|
||||
try:
|
||||
while observer.is_alive():
|
||||
observer.join(timeout)
|
||||
if self.stop_flag.is_set():
|
||||
observer.stop()
|
||||
except KeyboardInterrupt:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
|
||||
def handle_inotify(self, directory, recursive, *, is_testing: bool):
|
||||
logger.info(f"Using inotify to watch directory for changes: {directory}")
|
||||
|
||||
timeout_ms = None
|
||||
if is_testing:
|
||||
timeout_ms = self.testing_timeout_ms
|
||||
logger.debug(f"Configuring timeout to {timeout_ms}ms")
|
||||
|
||||
inotify = INotify()
|
||||
inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO | flags.MODIFY
|
||||
if recursive:
|
||||
inotify.add_watch_recursive(directory, inotify_flags)
|
||||
else:
|
||||
inotify.add_watch(directory, inotify_flags)
|
||||
|
||||
inotify_debounce_secs: Final[float] = settings.CONSUMER_INOTIFY_DELAY
|
||||
inotify_debounce_ms: Final[int] = inotify_debounce_secs * 1000
|
||||
|
||||
finished = False
|
||||
|
||||
notified_files = {}
|
||||
|
||||
try:
|
||||
while not finished:
|
||||
try:
|
||||
for event in inotify.read(timeout=timeout_ms):
|
||||
path = inotify.get_path(event.wd) if recursive else directory
|
||||
filepath = Path(path) / event.name
|
||||
if flags.MODIFY in flags.from_mask(event.mask):
|
||||
notified_files.pop(filepath, None)
|
||||
else:
|
||||
notified_files[filepath] = monotonic()
|
||||
|
||||
# Check the files against the timeout
|
||||
still_waiting = {}
|
||||
# last_event_time is time of the last inotify event for this file
|
||||
for filepath, last_event_time in notified_files.items():
|
||||
# Current time - last time over the configured timeout
|
||||
waited_long_enough = (
|
||||
monotonic() - last_event_time
|
||||
) > inotify_debounce_secs
|
||||
|
||||
# Also make sure the file exists still, some scanners might write a
|
||||
# temporary file first
|
||||
try:
|
||||
file_still_exists = filepath.exists() and filepath.is_file()
|
||||
except (PermissionError, OSError): # pragma: no cover
|
||||
# If we can't check, let it fail in the _consume function
|
||||
file_still_exists = True
|
||||
for changes in watch(
|
||||
directory,
|
||||
watch_filter=consumer_filter,
|
||||
rust_timeout=timeout_ms,
|
||||
yield_on_timeout=True,
|
||||
force_polling=use_polling,
|
||||
poll_delay_ms=poll_delay_ms,
|
||||
recursive=recursive,
|
||||
stop_event=self.stop_flag,
|
||||
):
|
||||
# Process each change
|
||||
for change_type, path in changes:
|
||||
path = Path(path).resolve()
|
||||
if not path.is_file():
|
||||
continue
|
||||
logger.debug(f"Event: {change_type.name} for {path}")
|
||||
tracker.track(path, change_type)
|
||||
|
||||
if waited_long_enough and file_still_exists:
|
||||
_consume(filepath)
|
||||
elif file_still_exists:
|
||||
still_waiting[filepath] = last_event_time
|
||||
# Check for stable files
|
||||
for stable_path in tracker.get_stable_files():
|
||||
_consume_file(
|
||||
filepath=stable_path,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
|
||||
# These files are still waiting to hit the timeout
|
||||
notified_files = still_waiting
|
||||
# Exit watch loop to reconfigure timeout
|
||||
break
|
||||
|
||||
# If files are waiting, need to exit read() to check them
|
||||
# Otherwise, go back to infinite sleep time, but only if not testing
|
||||
if len(notified_files) > 0:
|
||||
timeout_ms = inotify_debounce_ms
|
||||
elif is_testing:
|
||||
timeout_ms = self.testing_timeout_ms
|
||||
else:
|
||||
timeout_ms = None
|
||||
# Determine next timeout
|
||||
if tracker.has_pending_files():
|
||||
# Check pending files at stability interval
|
||||
timeout_ms = stability_timeout_ms
|
||||
elif is_testing:
|
||||
# In testing, use short timeout to check stop flag
|
||||
timeout_ms = testing_timeout_ms
|
||||
else: # pragma: nocover
|
||||
# No pending files, wait indefinitely
|
||||
timeout_ms = 0
|
||||
|
||||
if self.stop_flag.is_set():
|
||||
logger.debug("Finishing because event is set")
|
||||
finished = True
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received SIGINT, stopping inotify")
|
||||
finished = True
|
||||
finally:
|
||||
inotify.close()
|
||||
except KeyboardInterrupt: # pragma: nocover
|
||||
logger.info("Received interrupt, stopping consumer")
|
||||
self.stop_flag.set()
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -56,7 +55,6 @@ from documents.settings import EXPORTER_FILE_NAME
|
||||
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from paperless import version
|
||||
from paperless.db import GnuPG
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
@@ -316,20 +314,17 @@ class Command(CryptMixin, BaseCommand):
|
||||
total=len(document_manifest),
|
||||
disable=self.no_progress_bar,
|
||||
):
|
||||
# 3.1. store files unencrypted
|
||||
document_dict["fields"]["storage_type"] = Document.STORAGE_TYPE_UNENCRYPTED
|
||||
|
||||
document = document_map[document_dict["pk"]]
|
||||
|
||||
# 3.2. generate a unique filename
|
||||
# 3.1. generate a unique filename
|
||||
base_name = self.generate_base_name(document)
|
||||
|
||||
# 3.3. write filenames into manifest
|
||||
# 3.2. write filenames into manifest
|
||||
original_target, thumbnail_target, archive_target = (
|
||||
self.generate_document_targets(document, base_name, document_dict)
|
||||
)
|
||||
|
||||
# 3.4. write files to target folder
|
||||
# 3.3. write files to target folder
|
||||
if not self.data_only:
|
||||
self.copy_document_files(
|
||||
document,
|
||||
@@ -423,7 +418,6 @@ class Command(CryptMixin, BaseCommand):
|
||||
base_name = generate_filename(
|
||||
document,
|
||||
counter=filename_counter,
|
||||
append_gpg=False,
|
||||
)
|
||||
else:
|
||||
base_name = document.get_public_filename(counter=filename_counter)
|
||||
@@ -482,46 +476,24 @@ class Command(CryptMixin, BaseCommand):
|
||||
|
||||
If the document is encrypted, the files are decrypted before copying them to the target location.
|
||||
"""
|
||||
if document.storage_type == Document.STORAGE_TYPE_GPG:
|
||||
t = int(time.mktime(document.created.timetuple()))
|
||||
self.check_and_copy(
|
||||
document.source_path,
|
||||
document.checksum,
|
||||
original_target,
|
||||
)
|
||||
|
||||
original_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with document.source_file as out_file:
|
||||
original_target.write_bytes(GnuPG.decrypted(out_file))
|
||||
os.utime(original_target, times=(t, t))
|
||||
if thumbnail_target:
|
||||
self.check_and_copy(document.thumbnail_path, None, thumbnail_target)
|
||||
|
||||
if thumbnail_target:
|
||||
thumbnail_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with document.thumbnail_file as out_file:
|
||||
thumbnail_target.write_bytes(GnuPG.decrypted(out_file))
|
||||
os.utime(thumbnail_target, times=(t, t))
|
||||
|
||||
if archive_target:
|
||||
archive_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(document.archive_path, Path)
|
||||
with document.archive_path as out_file:
|
||||
archive_target.write_bytes(GnuPG.decrypted(out_file))
|
||||
os.utime(archive_target, times=(t, t))
|
||||
else:
|
||||
if archive_target:
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(document.archive_path, Path)
|
||||
self.check_and_copy(
|
||||
document.source_path,
|
||||
document.checksum,
|
||||
original_target,
|
||||
document.archive_path,
|
||||
document.archive_checksum,
|
||||
archive_target,
|
||||
)
|
||||
|
||||
if thumbnail_target:
|
||||
self.check_and_copy(document.thumbnail_path, None, thumbnail_target)
|
||||
|
||||
if archive_target:
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(document.archive_path, Path)
|
||||
self.check_and_copy(
|
||||
document.archive_path,
|
||||
document.archive_checksum,
|
||||
archive_target,
|
||||
)
|
||||
|
||||
def check_and_write_json(
|
||||
self,
|
||||
content: list[dict] | dict,
|
||||
|
||||
@@ -383,8 +383,6 @@ class Command(CryptMixin, BaseCommand):
|
||||
else:
|
||||
archive_path = None
|
||||
|
||||
document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
|
||||
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
if Path(document.source_path).is_file():
|
||||
raise FileExistsError(document.source_path)
|
||||
|
||||
@@ -403,6 +403,18 @@ def existing_document_matches_workflow(
|
||||
f"Document tags {list(document.tags.all())} include excluded tags {list(trigger_has_not_tags_qs)}",
|
||||
)
|
||||
|
||||
allowed_correspondent_ids = set(
|
||||
trigger.filter_has_any_correspondents.values_list("id", flat=True),
|
||||
)
|
||||
if (
|
||||
allowed_correspondent_ids
|
||||
and document.correspondent_id not in allowed_correspondent_ids
|
||||
):
|
||||
return (
|
||||
False,
|
||||
f"Document correspondent {document.correspondent} is not one of {list(trigger.filter_has_any_correspondents.all())}",
|
||||
)
|
||||
|
||||
# Document correspondent vs trigger has_correspondent
|
||||
if (
|
||||
trigger.filter_has_correspondent_id is not None
|
||||
@@ -424,6 +436,17 @@ def existing_document_matches_workflow(
|
||||
f"Document correspondent {document.correspondent} is excluded by {list(trigger.filter_has_not_correspondents.all())}",
|
||||
)
|
||||
|
||||
allowed_document_type_ids = set(
|
||||
trigger.filter_has_any_document_types.values_list("id", flat=True),
|
||||
)
|
||||
if allowed_document_type_ids and (
|
||||
document.document_type_id not in allowed_document_type_ids
|
||||
):
|
||||
return (
|
||||
False,
|
||||
f"Document doc type {document.document_type} is not one of {list(trigger.filter_has_any_document_types.all())}",
|
||||
)
|
||||
|
||||
# Document document_type vs trigger has_document_type
|
||||
if (
|
||||
trigger.filter_has_document_type_id is not None
|
||||
@@ -445,6 +468,17 @@ def existing_document_matches_workflow(
|
||||
f"Document doc type {document.document_type} is excluded by {list(trigger.filter_has_not_document_types.all())}",
|
||||
)
|
||||
|
||||
allowed_storage_path_ids = set(
|
||||
trigger.filter_has_any_storage_paths.values_list("id", flat=True),
|
||||
)
|
||||
if allowed_storage_path_ids and (
|
||||
document.storage_path_id not in allowed_storage_path_ids
|
||||
):
|
||||
return (
|
||||
False,
|
||||
f"Document storage path {document.storage_path} is not one of {list(trigger.filter_has_any_storage_paths.all())}",
|
||||
)
|
||||
|
||||
# Document storage_path vs trigger has_storage_path
|
||||
if (
|
||||
trigger.filter_has_storage_path_id is not None
|
||||
@@ -532,6 +566,10 @@ def prefilter_documents_by_workflowtrigger(
|
||||
|
||||
# Correspondent, DocumentType, etc. filtering
|
||||
|
||||
if trigger.filter_has_any_correspondents.exists():
|
||||
documents = documents.filter(
|
||||
correspondent__in=trigger.filter_has_any_correspondents.all(),
|
||||
)
|
||||
if trigger.filter_has_correspondent is not None:
|
||||
documents = documents.filter(
|
||||
correspondent=trigger.filter_has_correspondent,
|
||||
@@ -541,6 +579,10 @@ def prefilter_documents_by_workflowtrigger(
|
||||
correspondent__in=trigger.filter_has_not_correspondents.all(),
|
||||
)
|
||||
|
||||
if trigger.filter_has_any_document_types.exists():
|
||||
documents = documents.filter(
|
||||
document_type__in=trigger.filter_has_any_document_types.all(),
|
||||
)
|
||||
if trigger.filter_has_document_type is not None:
|
||||
documents = documents.filter(
|
||||
document_type=trigger.filter_has_document_type,
|
||||
@@ -550,6 +592,10 @@ def prefilter_documents_by_workflowtrigger(
|
||||
document_type__in=trigger.filter_has_not_document_types.all(),
|
||||
)
|
||||
|
||||
if trigger.filter_has_any_storage_paths.exists():
|
||||
documents = documents.filter(
|
||||
storage_path__in=trigger.filter_has_any_storage_paths.all(),
|
||||
)
|
||||
if trigger.filter_has_storage_path is not None:
|
||||
documents = documents.filter(
|
||||
storage_path=trigger.filter_has_storage_path,
|
||||
@@ -604,8 +650,11 @@ def document_matches_workflow(
|
||||
"filter_has_tags",
|
||||
"filter_has_all_tags",
|
||||
"filter_has_not_tags",
|
||||
"filter_has_any_document_types",
|
||||
"filter_has_not_document_types",
|
||||
"filter_has_any_correspondents",
|
||||
"filter_has_not_correspondents",
|
||||
"filter_has_any_storage_paths",
|
||||
"filter_has_not_storage_paths",
|
||||
)
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
# Generated by Django 1.9 on 2015-12-26 13:16
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="document",
|
||||
options={"ordering": ("sender", "title")},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="created",
|
||||
field=models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,50 +1,49 @@
|
||||
# Generated by Django 4.1.5 on 2023-03-04 22:33
|
||||
# Generated by Django 5.2.9 on 2026-01-20 18:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("documents", "1032_alter_correspondent_matching_algorithm_and_more"),
|
||||
("documents", "0001_initial"),
|
||||
("paperless_mail", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="documenttype",
|
||||
options={
|
||||
"ordering": ("name",),
|
||||
"verbose_name": "document type",
|
||||
"verbose_name_plural": "document types",
|
||||
},
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_mailrule",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="paperless_mail.mailrule",
|
||||
verbose_name="filter documents from this mail rule",
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="tag",
|
||||
options={
|
||||
"ordering": ("name",),
|
||||
"verbose_name": "tag",
|
||||
"verbose_name_plural": "tags",
|
||||
},
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="schedule_date_custom_field",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.customfield",
|
||||
verbose_name="schedule date custom field",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="correspondent",
|
||||
name="name",
|
||||
field=models.CharField(max_length=128, verbose_name="name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="documenttype",
|
||||
name="name",
|
||||
field=models.CharField(max_length=128, verbose_name="name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="storagepath",
|
||||
name="name",
|
||||
field=models.CharField(max_length=128, verbose_name="name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="tag",
|
||||
name="name",
|
||||
field=models.CharField(max_length=128, verbose_name="name"),
|
||||
migrations.AddField(
|
||||
model_name="workflow",
|
||||
name="triggers",
|
||||
field=models.ManyToManyField(
|
||||
related_name="workflows",
|
||||
to="documents.workflowtrigger",
|
||||
verbose_name="triggers",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="correspondent",
|
||||
@@ -61,6 +60,13 @@ class Migration(migrations.Migration):
|
||||
name="documents_correspondent_name_uniq",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="customfieldinstance",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("document", "field"),
|
||||
name="documents_customfieldinstance_unique_document_field",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="documenttype",
|
||||
constraint=models.UniqueConstraint(
|
||||
@@ -1,70 +0,0 @@
|
||||
# Generated by Django 1.9 on 2016-01-11 12:21
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import slugify
|
||||
|
||||
DOCUMENT_SENDER_MAP = {}
|
||||
|
||||
|
||||
def move_sender_strings_to_sender_model(apps, schema_editor):
|
||||
sender_model = apps.get_model("documents", "Sender")
|
||||
document_model = apps.get_model("documents", "Document")
|
||||
|
||||
# Create the sender and log the relationship with the document
|
||||
for document in document_model.objects.all():
|
||||
if document.sender:
|
||||
(
|
||||
DOCUMENT_SENDER_MAP[document.pk],
|
||||
_,
|
||||
) = sender_model.objects.get_or_create(
|
||||
name=document.sender,
|
||||
defaults={"slug": slugify(document.sender)},
|
||||
)
|
||||
|
||||
|
||||
def realign_senders(apps, schema_editor):
|
||||
document_model = apps.get_model("documents", "Document")
|
||||
for pk, sender in DOCUMENT_SENDER_MAP.items():
|
||||
document_model.objects.filter(pk=pk).update(sender=sender)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0002_auto_20151226_1316"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Sender",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=128, unique=True)),
|
||||
("slug", models.SlugField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(move_sender_strings_to_sender_model),
|
||||
migrations.RemoveField(
|
||||
model_name="document",
|
||||
name="sender",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="sender",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="documents.Sender",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(realign_senders),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-21 21:51
|
||||
# Generated by Django 5.2.9 on 2026-01-20 20:06
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
@@ -6,13 +6,13 @@ from django.db import models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0003_auto_20201118_1940"),
|
||||
("documents", "0002_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="mailrule",
|
||||
model_name="workflowaction",
|
||||
name="order",
|
||||
field=models.IntegerField(default=0),
|
||||
field=models.PositiveIntegerField(default=0, verbose_name="order"),
|
||||
),
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
# Generated by Django 1.9 on 2016-01-14 18:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0003_sender"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="sender",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="documents",
|
||||
to="documents.Sender",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,178 +0,0 @@
|
||||
# Generated by Django 4.2.13 on 2024-06-28 17:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [
|
||||
("documents", "0004_auto_20160114_1844"),
|
||||
("documents", "0005_auto_20160123_0313"),
|
||||
("documents", "0006_auto_20160123_0430"),
|
||||
("documents", "0007_auto_20160126_2114"),
|
||||
("documents", "0008_document_file_type"),
|
||||
("documents", "0009_auto_20160214_0040"),
|
||||
("documents", "0010_log"),
|
||||
("documents", "0011_auto_20160303_1929"),
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
("documents", "0003_sender"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="sender",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="documents",
|
||||
to="documents.sender",
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="sender",
|
||||
options={"ordering": ("name",)},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Tag",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=128, unique=True)),
|
||||
("slug", models.SlugField(blank=True)),
|
||||
(
|
||||
"colour",
|
||||
models.PositiveIntegerField(
|
||||
choices=[
|
||||
(1, "#a6cee3"),
|
||||
(2, "#1f78b4"),
|
||||
(3, "#b2df8a"),
|
||||
(4, "#33a02c"),
|
||||
(5, "#fb9a99"),
|
||||
(6, "#e31a1c"),
|
||||
(7, "#fdbf6f"),
|
||||
(8, "#ff7f00"),
|
||||
(9, "#cab2d6"),
|
||||
(10, "#6a3d9a"),
|
||||
(11, "#b15928"),
|
||||
(12, "#000000"),
|
||||
(13, "#cccccc"),
|
||||
],
|
||||
default=1,
|
||||
),
|
||||
),
|
||||
("match", models.CharField(blank=True, max_length=256)),
|
||||
(
|
||||
"matching_algorithm",
|
||||
models.PositiveIntegerField(
|
||||
choices=[
|
||||
(1, "Any"),
|
||||
(2, "All"),
|
||||
(3, "Literal"),
|
||||
(4, "Regular Expression"),
|
||||
],
|
||||
default=1,
|
||||
help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. If you don\'t know what a regex is, you probably don\'t want this option.',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="sender",
|
||||
name="slug",
|
||||
field=models.SlugField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="file_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("pdf", "PDF"),
|
||||
("png", "PNG"),
|
||||
("jpg", "JPG"),
|
||||
("gif", "GIF"),
|
||||
("tiff", "TIFF"),
|
||||
],
|
||||
default="pdf",
|
||||
editable=False,
|
||||
max_length=4,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="tags",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="documents",
|
||||
to="documents.tag",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Log",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("group", models.UUIDField(blank=True)),
|
||||
("message", models.TextField()),
|
||||
(
|
||||
"level",
|
||||
models.PositiveIntegerField(
|
||||
choices=[
|
||||
(10, "Debugging"),
|
||||
(20, "Informational"),
|
||||
(30, "Warning"),
|
||||
(40, "Error"),
|
||||
(50, "Critical"),
|
||||
],
|
||||
default=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"component",
|
||||
models.PositiveIntegerField(
|
||||
choices=[(1, "Consumer"), (2, "Mail Fetcher")],
|
||||
),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("modified", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-modified",),
|
||||
},
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name="Sender",
|
||||
new_name="Correspondent",
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="document",
|
||||
options={"ordering": ("correspondent", "title")},
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="document",
|
||||
old_name="sender",
|
||||
new_name="correspondent",
|
||||
),
|
||||
]
|
||||
@@ -1,16 +1,16 @@
|
||||
# Generated by Django 3.2.12 on 2022-03-11 15:18
|
||||
# Generated by Django 5.2.9 on 2026-01-24 23:05
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0010_auto_20220311_1602"),
|
||||
("documents", "0003_workflowaction_order"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="mailrule",
|
||||
name="assign_tag",
|
||||
model_name="document",
|
||||
name="storage_type",
|
||||
),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
# Generated by Django 1.9 on 2016-01-23 03:13
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0004_auto_20160114_1844"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="sender",
|
||||
options={"ordering": ("name",)},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-17 22:25
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0004_remove_document_storage_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_has_any_correspondents",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_any_correspondent",
|
||||
to="documents.correspondent",
|
||||
verbose_name="has one of these correspondents",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_has_any_document_types",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_any_document_type",
|
||||
to="documents.documenttype",
|
||||
verbose_name="has one of these document types",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_has_any_storage_paths",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_any_storage_path",
|
||||
to="documents.storagepath",
|
||||
verbose_name="has one of these storage paths",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-14 17:45
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0005_workflowtrigger_filter_has_any_correspondents_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="checksum",
|
||||
field=models.CharField(
|
||||
editable=False,
|
||||
max_length=32,
|
||||
verbose_name="checksum",
|
||||
help_text="The checksum of the original document.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,64 +0,0 @@
|
||||
# Generated by Django 1.9 on 2016-01-23 04:30
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0005_auto_20160123_0313"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Tag",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=128, unique=True)),
|
||||
("slug", models.SlugField(blank=True)),
|
||||
(
|
||||
"colour",
|
||||
models.PositiveIntegerField(
|
||||
choices=[
|
||||
(1, "#a6cee3"),
|
||||
(2, "#1f78b4"),
|
||||
(3, "#b2df8a"),
|
||||
(4, "#33a02c"),
|
||||
(5, "#fb9a99"),
|
||||
(6, "#e31a1c"),
|
||||
(7, "#fdbf6f"),
|
||||
(8, "#ff7f00"),
|
||||
(9, "#cab2d6"),
|
||||
(10, "#6a3d9a"),
|
||||
(11, "#ffff99"),
|
||||
(12, "#b15928"),
|
||||
(13, "#000000"),
|
||||
(14, "#cccccc"),
|
||||
],
|
||||
default=1,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="sender",
|
||||
name="slug",
|
||||
field=models.SlugField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="tags",
|
||||
field=models.ManyToManyField(related_name="documents", to="documents.Tag"),
|
||||
),
|
||||
]
|
||||
@@ -1,55 +0,0 @@
|
||||
# Generated by Django 1.9 on 2016-01-26 21:14
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0006_auto_20160123_0430"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tag",
|
||||
name="match",
|
||||
field=models.CharField(blank=True, max_length=256),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tag",
|
||||
name="matching_algorithm",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
choices=[
|
||||
(1, "Any"),
|
||||
(2, "All"),
|
||||
(3, "Literal"),
|
||||
(4, "Regular Expression"),
|
||||
],
|
||||
help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. If you don\'t know what a regex is, you probably don\'t want this option.',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="tag",
|
||||
name="colour",
|
||||
field=models.PositiveIntegerField(
|
||||
choices=[
|
||||
(1, "#a6cee3"),
|
||||
(2, "#1f78b4"),
|
||||
(3, "#b2df8a"),
|
||||
(4, "#33a02c"),
|
||||
(5, "#fb9a99"),
|
||||
(6, "#e31a1c"),
|
||||
(7, "#fdbf6f"),
|
||||
(8, "#ff7f00"),
|
||||
(9, "#cab2d6"),
|
||||
(10, "#6a3d9a"),
|
||||
(11, "#b15928"),
|
||||
(12, "#000000"),
|
||||
(13, "#cccccc"),
|
||||
],
|
||||
default=1,
|
||||
),
|
||||
),
|
||||
]
|
||||
25
src/documents/migrations/0007_document_content_length.py
Normal file
25
src/documents/migrations/0007_document_content_length.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.6 on 2026-01-24 07:33
|
||||
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0006_alter_document_checksum_unique"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="content_length",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.functions.text.Length("content"),
|
||||
null=False,
|
||||
help_text="Length of the content field in characters. Automatically maintained by the database for faster statistics computation.",
|
||||
output_field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
# Generated by Django 1.9 on 2016-01-29 22:58
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0007_auto_20160126_2114"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="file_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("pdf", "PDF"),
|
||||
("png", "PNG"),
|
||||
("jpg", "JPG"),
|
||||
("gif", "GIF"),
|
||||
("tiff", "TIFF"),
|
||||
],
|
||||
default="pdf",
|
||||
editable=False,
|
||||
max_length=4,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="tags",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="documents",
|
||||
to="documents.Tag",
|
||||
),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user