mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-13 20:51:24 +00:00
Compare commits
1 Commits
fix-drop-s
...
fix-email-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5ea4065a |
24
.github/dependabot.yml
vendored
24
.github/dependabot.yml
vendored
@@ -12,8 +12,6 @@ updates:
|
|||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "monthly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
labels:
|
labels:
|
||||||
- "frontend"
|
- "frontend"
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -38,9 +36,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
# Check for updates once a week
|
# Check for updates once a week
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "weekly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
labels:
|
labels:
|
||||||
- "backend"
|
- "backend"
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -101,8 +97,6 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
# Check for updates to GitHub Actions every month
|
# Check for updates to GitHub Actions every month
|
||||||
interval: "monthly"
|
interval: "monthly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
labels:
|
labels:
|
||||||
- "ci-cd"
|
- "ci-cd"
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -118,9 +112,7 @@ updates:
|
|||||||
- "/"
|
- "/"
|
||||||
- "/.devcontainer/"
|
- "/.devcontainer/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "weekly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -131,9 +123,7 @@ updates:
|
|||||||
- package-ecosystem: "docker-compose"
|
- package-ecosystem: "docker-compose"
|
||||||
directory: "/docker/compose/"
|
directory: "/docker/compose/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
interval: "weekly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
@@ -157,11 +147,3 @@ updates:
|
|||||||
postgres:
|
postgres:
|
||||||
patterns:
|
patterns:
|
||||||
- "docker.io/library/postgres*"
|
- "docker.io/library/postgres*"
|
||||||
- package-ecosystem: "pre-commit" # See documentation for possible values
|
|
||||||
directory: "/" # Location of package manifests
|
|
||||||
schedule:
|
|
||||||
interval: "monthly"
|
|
||||||
groups:
|
|
||||||
pre-commit-dependencies:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|||||||
12
.github/workflows/ci-docker.yml
vendored
12
.github/workflows/ci-docker.yml
vendored
@@ -104,9 +104,9 @@ jobs:
|
|||||||
echo "repository=${repo_name}"
|
echo "repository=${repo_name}"
|
||||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4.0.0
|
uses: docker/setup-buildx-action@v3.12.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4.0.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -179,22 +179,22 @@ jobs:
|
|||||||
echo "Downloaded digests:"
|
echo "Downloaded digests:"
|
||||||
ls -la /tmp/digests/
|
ls -la /tmp/digests/
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4.0.0
|
uses: docker/setup-buildx-action@v3.12.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4.0.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@v4.0.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@v4.0.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
|||||||
10
.github/workflows/ci-frontend.yml
vendored
10
.github/workflows/ci-frontend.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -95,7 +95,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -130,7 +130,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -214,7 +214,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
2
.github/workflows/ci-release.yml
vendored
2
.github/workflows/ci-release.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
2
.github/workflows/translate-strings.yml
vendored
2
.github/workflows/translate-strings.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@v6.3.0
|
uses: actions/setup-node@v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ repos:
|
|||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.4.2
|
rev: v2.4.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
@@ -46,8 +46,8 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.8.1
|
- prettier@3.3.3
|
||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.1.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.5
|
rev: v0.15.5
|
||||||
@@ -65,7 +65,7 @@ repos:
|
|||||||
- id: hadolint
|
- id: hadolint
|
||||||
# Shell script hooks
|
# Shell script hooks
|
||||||
- repo: https://github.com/lovesegfault/beautysh
|
- repo: https://github.com/lovesegfault/beautysh
|
||||||
rev: v6.4.3
|
rev: v6.4.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: beautysh
|
- id: beautysh
|
||||||
types: [file]
|
types: [file]
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ const config = {
|
|||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
// https://prettier.io/docs/en/options.html#trailing-commas
|
// https://prettier.io/docs/en/options.html#trailing-commas
|
||||||
trailingComma: 'es5',
|
trailingComma: 'es5',
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['docs/*.md'],
|
||||||
|
options: {
|
||||||
|
tabWidth: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
plugins: [require('prettier-plugin-organize-imports')],
|
plugins: [require('prettier-plugin-organize-imports')],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ cd "${PAPERLESS_SRC_DIR}"
|
|||||||
|
|
||||||
# The whole migrate, with flock, needs to run as the right user
|
# The whole migrate, with flock, needs to run as the right user
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py check --tag compatibility paperless || exit 1
|
exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py check --tag compatibility paperless
|
||||||
exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py migrate --skip-checks --no-input
|
exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py migrate --skip-checks --no-input
|
||||||
else
|
else
|
||||||
s6-setuidgid paperless python3 manage.py check --tag compatibility paperless || exit 1
|
exec s6-setuidgid paperless \
|
||||||
|
s6-setlock -n "${data_dir}/migration_lock" \
|
||||||
|
python3 manage.py check --tag compatibility paperless
|
||||||
exec s6-setuidgid paperless \
|
exec s6-setuidgid paperless \
|
||||||
s6-setlock -n "${data_dir}/migration_lock" \
|
s6-setlock -n "${data_dir}/migration_lock" \
|
||||||
python3 manage.py migrate --skip-checks --no-input
|
python3 manage.py migrate --skip-checks --no-input
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ consuming documents at that time.
|
|||||||
|
|
||||||
Options available to any installation of paperless:
|
Options available to any installation of paperless:
|
||||||
|
|
||||||
- Use the [document exporter](#exporter). The document exporter exports all your documents,
|
- Use the [document exporter](#exporter). The document exporter exports all your documents,
|
||||||
thumbnails, metadata, and database contents to a specific folder. You may import your
|
thumbnails, metadata, and database contents to a specific folder. You may import your
|
||||||
documents and settings into a fresh instance of paperless again or store your
|
documents and settings into a fresh instance of paperless again or store your
|
||||||
documents in another DMS with this export.
|
documents in another DMS with this export.
|
||||||
|
|
||||||
The document exporter is also able to update an already existing
|
The document exporter is also able to update an already existing
|
||||||
export. Therefore, incremental backups with `rsync` are entirely
|
export. Therefore, incremental backups with `rsync` are entirely
|
||||||
possible.
|
possible.
|
||||||
|
|
||||||
The exporter does not include API tokens and they will need to be re-generated after importing.
|
The exporter does not include API tokens and they will need to be re-generated after importing.
|
||||||
|
|
||||||
!!! caution
|
!!! caution
|
||||||
|
|
||||||
@@ -29,27 +29,28 @@ Options available to any installation of paperless:
|
|||||||
|
|
||||||
Options available to docker installations:
|
Options available to docker installations:
|
||||||
|
|
||||||
- Backup the docker volumes. These usually reside within
|
- Backup the docker volumes. These usually reside within
|
||||||
`/var/lib/docker/volumes` on the host and you need to be root in
|
`/var/lib/docker/volumes` on the host and you need to be root in
|
||||||
order to access them.
|
order to access them.
|
||||||
|
|
||||||
Paperless uses 4 volumes:
|
Paperless uses 4 volumes:
|
||||||
- `paperless_media`: This is where your documents are stored.
|
|
||||||
- `paperless_data`: This is where auxiliary data is stored. This
|
- `paperless_media`: This is where your documents are stored.
|
||||||
folder also contains the SQLite database, if you use it.
|
- `paperless_data`: This is where auxiliary data is stored. This
|
||||||
- `paperless_pgdata`: Exists only if you use PostgreSQL and
|
folder also contains the SQLite database, if you use it.
|
||||||
contains the database.
|
- `paperless_pgdata`: Exists only if you use PostgreSQL and
|
||||||
- `paperless_dbdata`: Exists only if you use MariaDB and contains
|
contains the database.
|
||||||
the database.
|
- `paperless_dbdata`: Exists only if you use MariaDB and contains
|
||||||
|
the database.
|
||||||
|
|
||||||
Options available to bare-metal and non-docker installations:
|
Options available to bare-metal and non-docker installations:
|
||||||
|
|
||||||
- Backup the entire paperless folder. This ensures that if your
|
- Backup the entire paperless folder. This ensures that if your
|
||||||
paperless instance crashes at some point or your disk fails, you can
|
paperless instance crashes at some point or your disk fails, you can
|
||||||
simply copy the folder back into place and it works.
|
simply copy the folder back into place and it works.
|
||||||
|
|
||||||
When using PostgreSQL or MariaDB, you'll also have to backup the
|
When using PostgreSQL or MariaDB, you'll also have to backup the
|
||||||
database.
|
database.
|
||||||
|
|
||||||
### Restoring {#migrating-restoring}
|
### Restoring {#migrating-restoring}
|
||||||
|
|
||||||
@@ -508,19 +509,19 @@ collection for issues.
|
|||||||
|
|
||||||
The issues detected by the sanity checker are as follows:
|
The issues detected by the sanity checker are as follows:
|
||||||
|
|
||||||
- Missing original files.
|
- Missing original files.
|
||||||
- Missing archive files.
|
- Missing archive files.
|
||||||
- Inaccessible original files due to improper permissions.
|
- Inaccessible original files due to improper permissions.
|
||||||
- Inaccessible archive files due to improper permissions.
|
- Inaccessible archive files due to improper permissions.
|
||||||
- Corrupted original documents by comparing their checksum against
|
- Corrupted original documents by comparing their checksum against
|
||||||
what is stored in the database.
|
what is stored in the database.
|
||||||
- Corrupted archive documents by comparing their checksum against what
|
- Corrupted archive documents by comparing their checksum against what
|
||||||
is stored in the database.
|
is stored in the database.
|
||||||
- Missing thumbnails.
|
- Missing thumbnails.
|
||||||
- Inaccessible thumbnails due to improper permissions.
|
- Inaccessible thumbnails due to improper permissions.
|
||||||
- Documents without any content (warning).
|
- Documents without any content (warning).
|
||||||
- Orphaned files in the media directory (warning). These are files
|
- Orphaned files in the media directory (warning). These are files
|
||||||
that are not referenced by any document in paperless.
|
that are not referenced by any document in paperless.
|
||||||
|
|
||||||
```
|
```
|
||||||
document_sanity_checker
|
document_sanity_checker
|
||||||
|
|||||||
@@ -25,20 +25,20 @@ documents.
|
|||||||
|
|
||||||
The following algorithms are available:
|
The following algorithms are available:
|
||||||
|
|
||||||
- **None:** No matching will be performed.
|
- **None:** No matching will be performed.
|
||||||
- **Any:** Looks for any occurrence of any word provided in match in
|
- **Any:** Looks for any occurrence of any word provided in match in
|
||||||
the PDF. If you define the match as `Bank1 Bank2`, it will match
|
the PDF. If you define the match as `Bank1 Bank2`, it will match
|
||||||
documents containing either of these terms.
|
documents containing either of these terms.
|
||||||
- **All:** Requires that every word provided appears in the PDF,
|
- **All:** Requires that every word provided appears in the PDF,
|
||||||
albeit not in the order provided.
|
albeit not in the order provided.
|
||||||
- **Exact:** Matches only if the match appears exactly as provided
|
- **Exact:** Matches only if the match appears exactly as provided
|
||||||
(i.e. preserve ordering) in the PDF.
|
(i.e. preserve ordering) in the PDF.
|
||||||
- **Regular expression:** Parses the match as a regular expression and
|
- **Regular expression:** Parses the match as a regular expression and
|
||||||
tries to find a match within the document.
|
tries to find a match within the document.
|
||||||
- **Fuzzy match:** Uses a partial matching based on locating the tag text
|
- **Fuzzy match:** Uses a partial matching based on locating the tag text
|
||||||
inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
|
inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
|
||||||
- **Auto:** Tries to automatically match new documents. This does not
|
- **Auto:** Tries to automatically match new documents. This does not
|
||||||
require you to set a match. See the [notes below](#automatic-matching).
|
require you to set a match. See the [notes below](#automatic-matching).
|
||||||
|
|
||||||
When using the _any_ or _all_ matching algorithms, you can search for
|
When using the _any_ or _all_ matching algorithms, you can search for
|
||||||
terms that consist of multiple words by enclosing them in double quotes.
|
terms that consist of multiple words by enclosing them in double quotes.
|
||||||
@@ -69,33 +69,33 @@ Paperless tries to hide much of the involved complexity with this
|
|||||||
approach. However, there are a couple caveats you need to keep in mind
|
approach. However, there are a couple caveats you need to keep in mind
|
||||||
when using this feature:
|
when using this feature:
|
||||||
|
|
||||||
- Changes to your documents are not immediately reflected by the
|
- Changes to your documents are not immediately reflected by the
|
||||||
matching algorithm. The neural network needs to be _trained_ on your
|
matching algorithm. The neural network needs to be _trained_ on your
|
||||||
documents after changes. Paperless periodically (default: once each
|
documents after changes. Paperless periodically (default: once each
|
||||||
hour) checks for changes and does this automatically for you.
|
hour) checks for changes and does this automatically for you.
|
||||||
- The Auto matching algorithm only takes documents into account which
|
- The Auto matching algorithm only takes documents into account which
|
||||||
are NOT placed in your inbox (i.e. have any inbox tags assigned to
|
are NOT placed in your inbox (i.e. have any inbox tags assigned to
|
||||||
them). This ensures that the neural network only learns from
|
them). This ensures that the neural network only learns from
|
||||||
documents which you have correctly tagged before.
|
documents which you have correctly tagged before.
|
||||||
- The matching algorithm can only work if there is a correlation
|
- The matching algorithm can only work if there is a correlation
|
||||||
between the tag, correspondent, document type, or storage path and
|
between the tag, correspondent, document type, or storage path and
|
||||||
the document itself. Your bank statements usually contain your bank
|
the document itself. Your bank statements usually contain your bank
|
||||||
account number and the name of the bank, so this works reasonably
|
account number and the name of the bank, so this works reasonably
|
||||||
well, However, tags such as "TODO" cannot be automatically
|
well, However, tags such as "TODO" cannot be automatically
|
||||||
assigned.
|
assigned.
|
||||||
- The matching algorithm needs a reasonable number of documents to
|
- The matching algorithm needs a reasonable number of documents to
|
||||||
identify when to assign tags, correspondents, storage paths, and
|
identify when to assign tags, correspondents, storage paths, and
|
||||||
types. If one out of a thousand documents has the correspondent
|
types. If one out of a thousand documents has the correspondent
|
||||||
"Very obscure web shop I bought something five years ago", it will
|
"Very obscure web shop I bought something five years ago", it will
|
||||||
probably not assign this correspondent automatically if you buy
|
probably not assign this correspondent automatically if you buy
|
||||||
something from them again. The more documents, the better.
|
something from them again. The more documents, the better.
|
||||||
- Paperless also needs a reasonable amount of negative examples to
|
- Paperless also needs a reasonable amount of negative examples to
|
||||||
decide when not to assign a certain tag, correspondent, document
|
decide when not to assign a certain tag, correspondent, document
|
||||||
type, or storage path. This will usually be the case as you start
|
type, or storage path. This will usually be the case as you start
|
||||||
filling up paperless with documents. Example: If all your documents
|
filling up paperless with documents. Example: If all your documents
|
||||||
are either from "Webshop" or "Bank", paperless will assign one
|
are either from "Webshop" or "Bank", paperless will assign one
|
||||||
of these correspondents to ANY new document, if both are set to
|
of these correspondents to ANY new document, if both are set to
|
||||||
automatic matching.
|
automatic matching.
|
||||||
|
|
||||||
## Hooking into the consumption process {#consume-hooks}
|
## Hooking into the consumption process {#consume-hooks}
|
||||||
|
|
||||||
@@ -243,12 +243,12 @@ webserver:
|
|||||||
|
|
||||||
Troubleshooting:
|
Troubleshooting:
|
||||||
|
|
||||||
- Monitor the Docker Compose log
|
- Monitor the Docker Compose log
|
||||||
`cd ~/paperless-ngx; docker compose logs -f`
|
`cd ~/paperless-ngx; docker compose logs -f`
|
||||||
- Check your script's permission e.g. in case of permission error
|
- Check your script's permission e.g. in case of permission error
|
||||||
`sudo chmod 755 post-consumption-example.sh`
|
`sudo chmod 755 post-consumption-example.sh`
|
||||||
- Pipe your scripts's output to a log file e.g.
|
- Pipe your scripts's output to a log file e.g.
|
||||||
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
|
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
|
||||||
|
|
||||||
## File name handling {#file-name-handling}
|
## File name handling {#file-name-handling}
|
||||||
|
|
||||||
@@ -307,35 +307,35 @@ will create a directory structure as follows:
|
|||||||
|
|
||||||
Paperless provides the following variables for use within filenames:
|
Paperless provides the following variables for use within filenames:
|
||||||
|
|
||||||
- `{{ asn }}`: The archive serial number of the document, or "none".
|
- `{{ asn }}`: The archive serial number of the document, or "none".
|
||||||
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
||||||
- `{{ document_type }}`: The name of the document type, or "none".
|
- `{{ document_type }}`: The name of the document type, or "none".
|
||||||
- `{{ tag_list }}`: A comma separated list of all tags assigned to the
|
- `{{ tag_list }}`: A comma separated list of all tags assigned to the
|
||||||
document.
|
document.
|
||||||
- `{{ title }}`: The title of the document.
|
- `{{ title }}`: The title of the document.
|
||||||
- `{{ created }}`: The full date (ISO 8601 format, e.g. `2024-03-14`) the document was created.
|
- `{{ created }}`: The full date (ISO 8601 format, e.g. `2024-03-14`) the document was created.
|
||||||
- `{{ created_year }}`: Year created only, formatted as the year with
|
- `{{ created_year }}`: Year created only, formatted as the year with
|
||||||
century.
|
century.
|
||||||
- `{{ created_year_short }}`: Year created only, formatted as the year
|
- `{{ created_year_short }}`: Year created only, formatted as the year
|
||||||
without century, zero padded.
|
without century, zero padded.
|
||||||
- `{{ created_month }}`: Month created only (number 01-12).
|
- `{{ created_month }}`: Month created only (number 01-12).
|
||||||
- `{{ created_month_name }}`: Month created name, as per locale
|
- `{{ created_month_name }}`: Month created name, as per locale
|
||||||
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
||||||
locale
|
locale
|
||||||
- `{{ created_day }}`: Day created only (number 01-31).
|
- `{{ created_day }}`: Day created only (number 01-31).
|
||||||
- `{{ added }}`: The full date (ISO format) the document was added to
|
- `{{ added }}`: The full date (ISO format) the document was added to
|
||||||
paperless.
|
paperless.
|
||||||
- `{{ added_year }}`: Year added only.
|
- `{{ added_year }}`: Year added only.
|
||||||
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
||||||
century, zero padded.
|
century, zero padded.
|
||||||
- `{{ added_month }}`: Month added only (number 01-12).
|
- `{{ added_month }}`: Month added only (number 01-12).
|
||||||
- `{{ added_month_name }}`: Month added name, as per locale
|
- `{{ added_month_name }}`: Month added name, as per locale
|
||||||
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
||||||
locale
|
locale
|
||||||
- `{{ added_day }}`: Day added only (number 01-31).
|
- `{{ added_day }}`: Day added only (number 01-31).
|
||||||
- `{{ owner_username }}`: Username of document owner, if any, or "none"
|
- `{{ owner_username }}`: Username of document owner, if any, or "none"
|
||||||
- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
|
- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
|
||||||
- `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
|
- `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
@@ -388,10 +388,10 @@ before empty placeholders are removed as well, empty directories are omitted.
|
|||||||
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
||||||
structure to set precisely where each document is stored in the file system.
|
structure to set precisely where each document is stored in the file system.
|
||||||
|
|
||||||
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
||||||
follows the rules described above
|
follows the rules described above
|
||||||
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
||||||
overwritten at any time
|
overwritten at any time
|
||||||
|
|
||||||
For example, you could define the following two storage paths:
|
For example, you could define the following two storage paths:
|
||||||
|
|
||||||
@@ -457,13 +457,13 @@ The `get_cf_value` filter retrieves a value from custom field data with optional
|
|||||||
|
|
||||||
###### Parameters
|
###### Parameters
|
||||||
|
|
||||||
- `custom_fields`: This _must_ be the provided custom field data
|
- `custom_fields`: This _must_ be the provided custom field data
|
||||||
- `name` (str): Name of the custom field to retrieve
|
- `name` (str): Name of the custom field to retrieve
|
||||||
- `default` (str, optional): Default value to return if field is not found or has no value
|
- `default` (str, optional): Default value to return if field is not found or has no value
|
||||||
|
|
||||||
###### Returns
|
###### Returns
|
||||||
|
|
||||||
- `str | None`: The field value, default value, or `None` if neither exists
|
- `str | None`: The field value, default value, or `None` if neither exists
|
||||||
|
|
||||||
###### Examples
|
###### Examples
|
||||||
|
|
||||||
@@ -487,12 +487,12 @@ The `datetime` filter formats a datetime string or datetime object using Python'
|
|||||||
|
|
||||||
###### Parameters
|
###### Parameters
|
||||||
|
|
||||||
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
|
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
|
||||||
- `format` (str): Python strftime format string
|
- `format` (str): Python strftime format string
|
||||||
|
|
||||||
###### Returns
|
###### Returns
|
||||||
|
|
||||||
- `str`: Formatted datetime string
|
- `str`: Formatted datetime string
|
||||||
|
|
||||||
###### Examples
|
###### Examples
|
||||||
|
|
||||||
@@ -525,13 +525,13 @@ An ISO string can also be provided to control the output format.
|
|||||||
|
|
||||||
###### Parameters
|
###### Parameters
|
||||||
|
|
||||||
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
|
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
|
||||||
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
||||||
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
||||||
|
|
||||||
###### Returns
|
###### Returns
|
||||||
|
|
||||||
- `str`: Localized, formatted date string
|
- `str`: Localized, formatted date string
|
||||||
|
|
||||||
###### Examples
|
###### Examples
|
||||||
|
|
||||||
@@ -565,15 +565,15 @@ See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.htm
|
|||||||
|
|
||||||
### Format Presets
|
### Format Presets
|
||||||
|
|
||||||
- **short**: Abbreviated format (e.g., "1/15/24")
|
- **short**: Abbreviated format (e.g., "1/15/24")
|
||||||
- **medium**: Medium-length format (e.g., "Jan 15, 2024")
|
- **medium**: Medium-length format (e.g., "Jan 15, 2024")
|
||||||
- **long**: Long format with full month name (e.g., "January 15, 2024")
|
- **long**: Long format with full month name (e.g., "January 15, 2024")
|
||||||
- **full**: Full format including day of week (e.g., "Monday, January 15, 2024")
|
- **full**: Full format including day of week (e.g., "Monday, January 15, 2024")
|
||||||
|
|
||||||
#### Additional Variables
|
#### Additional Variables
|
||||||
|
|
||||||
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
||||||
- `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable.
|
- `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
@@ -675,15 +675,15 @@ installation, you can use volumes to accomplish this:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
# ...
|
|
||||||
webserver:
|
|
||||||
environment:
|
|
||||||
- PAPERLESS_ENABLE_FLOWER
|
|
||||||
ports:
|
|
||||||
- 5555:5555 # (2)!
|
|
||||||
# ...
|
# ...
|
||||||
volumes:
|
webserver:
|
||||||
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)!
|
environment:
|
||||||
|
- PAPERLESS_ENABLE_FLOWER
|
||||||
|
ports:
|
||||||
|
- 5555:5555 # (2)!
|
||||||
|
# ...
|
||||||
|
volumes:
|
||||||
|
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)!
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Note the `:ro` tag means the file will be mounted as read only.
|
1. Note the `:ro` tag means the file will be mounted as read only.
|
||||||
@@ -714,11 +714,11 @@ For example, using Docker Compose:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
# ...
|
|
||||||
webserver:
|
|
||||||
# ...
|
# ...
|
||||||
volumes:
|
webserver:
|
||||||
- /path/to/my/scripts:/custom-cont-init.d:ro # (1)!
|
# ...
|
||||||
|
volumes:
|
||||||
|
- /path/to/my/scripts:/custom-cont-init.d:ro # (1)!
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
|
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
|
||||||
@@ -771,16 +771,16 @@ Paperless is able to utilize barcodes for automatically performing some tasks.
|
|||||||
|
|
||||||
At this time, the library utilized for detection of barcodes supports the following types:
|
At this time, the library utilized for detection of barcodes supports the following types:
|
||||||
|
|
||||||
- AN-13/UPC-A
|
- AN-13/UPC-A
|
||||||
- UPC-E
|
- UPC-E
|
||||||
- EAN-8
|
- EAN-8
|
||||||
- Code 128
|
- Code 128
|
||||||
- Code 93
|
- Code 93
|
||||||
- Code 39
|
- Code 39
|
||||||
- Codabar
|
- Codabar
|
||||||
- Interleaved 2 of 5
|
- Interleaved 2 of 5
|
||||||
- QR Code
|
- QR Code
|
||||||
- SQ Code
|
- SQ Code
|
||||||
|
|
||||||
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
||||||
|
|
||||||
@@ -793,8 +793,8 @@ below.
|
|||||||
If document splitting is enabled, Paperless splits _after_ a separator barcode by default.
|
If document splitting is enabled, Paperless splits _after_ a separator barcode by default.
|
||||||
This means:
|
This means:
|
||||||
|
|
||||||
- any page containing the configured separator barcode starts a new document, starting with the **next** page
|
- any page containing the configured separator barcode starts a new document, starting with the **next** page
|
||||||
- pages containing the separator barcode are discarded
|
- pages containing the separator barcode are discarded
|
||||||
|
|
||||||
This is intended for dedicated separator sheets such as PATCH-T pages.
|
This is intended for dedicated separator sheets such as PATCH-T pages.
|
||||||
|
|
||||||
@@ -831,10 +831,10 @@ to `true`.
|
|||||||
When enabled, documents will be split at pages containing tag barcodes, similar to how
|
When enabled, documents will be split at pages containing tag barcodes, similar to how
|
||||||
ASN barcodes work. Key features:
|
ASN barcodes work. Key features:
|
||||||
|
|
||||||
- The page with the tag barcode is **retained** in the resulting document
|
- The page with the tag barcode is **retained** in the resulting document
|
||||||
- **Each split document extracts its own tags** - only tags on pages within that document are assigned
|
- **Each split document extracts its own tags** - only tags on pages within that document are assigned
|
||||||
- Multiple tag barcodes can trigger multiple splits in the same document
|
- Multiple tag barcodes can trigger multiple splits in the same document
|
||||||
- Works seamlessly with ASN barcodes - each split document gets its own ASN and tags
|
- Works seamlessly with ASN barcodes - each split document gets its own ASN and tags
|
||||||
|
|
||||||
This is useful for batch scanning where you place tag barcode pages between different
|
This is useful for batch scanning where you place tag barcode pages between different
|
||||||
documents to both separate and categorize them in a single operation.
|
documents to both separate and categorize them in a single operation.
|
||||||
@@ -996,9 +996,9 @@ If using docker, you'll need to add the following volume mounts to your `docker-
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
webserver:
|
webserver:
|
||||||
volumes:
|
volumes:
|
||||||
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
|
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
|
||||||
- <path to gpg-agent socket>:/usr/src/paperless/.gnupg/S.gpg-agent
|
- <path to gpg-agent socket>:/usr/src/paperless/.gnupg/S.gpg-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
For a 'bare-metal' installation no further configuration is necessary. If you
|
For a 'bare-metal' installation no further configuration is necessary. If you
|
||||||
@@ -1006,9 +1006,9 @@ want to use a separate `GNUPG_HOME`, you can do so by configuring the [PAPERLESS
|
|||||||
|
|
||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
- Make sure, that `gpg-agent` is running on your host machine
|
- Make sure, that `gpg-agent` is running on your host machine
|
||||||
- Make sure, that encryption and decryption works from inside the container using the `gpg` commands from above.
|
- Make sure, that encryption and decryption works from inside the container using the `gpg` commands from above.
|
||||||
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
|
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
paperless@9da1865df327:~/.gnupg$ ls -al
|
paperless@9da1865df327:~/.gnupg$ ls -al
|
||||||
|
|||||||
212
docs/api.md
212
docs/api.md
@@ -66,10 +66,10 @@ Full text searching is available on the `/api/documents/` endpoint. Two
|
|||||||
specific query parameters cause the API to return full text search
|
specific query parameters cause the API to return full text search
|
||||||
results:
|
results:
|
||||||
|
|
||||||
- `/api/documents/?query=your%20search%20query`: Search for a document
|
- `/api/documents/?query=your%20search%20query`: Search for a document
|
||||||
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
||||||
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||||
the document with id 1234.
|
the document with id 1234.
|
||||||
|
|
||||||
Pagination works exactly the same as it does for normal requests on this
|
Pagination works exactly the same as it does for normal requests on this
|
||||||
endpoint.
|
endpoint.
|
||||||
@@ -106,12 +106,12 @@ attribute with various information about the search results:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `score` is an indication how well this document matches the query
|
- `score` is an indication how well this document matches the query
|
||||||
relative to the other search results.
|
relative to the other search results.
|
||||||
- `highlights` is an excerpt from the document content and highlights
|
- `highlights` is an excerpt from the document content and highlights
|
||||||
the search terms with `<span>` tags as shown above.
|
the search terms with `<span>` tags as shown above.
|
||||||
- `rank` is the index of the search results. The first result will
|
- `rank` is the index of the search results. The first result will
|
||||||
have rank 0.
|
have rank 0.
|
||||||
|
|
||||||
### Filtering by custom fields
|
### Filtering by custom fields
|
||||||
|
|
||||||
@@ -122,33 +122,33 @@ use cases:
|
|||||||
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
|
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
|
||||||
Sept 1, 2024 (inclusive):
|
Sept 1, 2024 (inclusive):
|
||||||
|
|
||||||
`?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
|
`?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
|
||||||
|
|
||||||
2. Documents with a custom field "customer" (text) that equals "bob"
|
2. Documents with a custom field "customer" (text) that equals "bob"
|
||||||
(case sensitive):
|
(case sensitive):
|
||||||
|
|
||||||
`?custom_field_query=["customer", "exact", "bob"]`
|
`?custom_field_query=["customer", "exact", "bob"]`
|
||||||
|
|
||||||
3. Documents with a custom field "answered" (boolean) set to `true`:
|
3. Documents with a custom field "answered" (boolean) set to `true`:
|
||||||
|
|
||||||
`?custom_field_query=["answered", "exact", true]`
|
`?custom_field_query=["answered", "exact", true]`
|
||||||
|
|
||||||
4. Documents with a custom field "favorite animal" (select) set to either
|
4. Documents with a custom field "favorite animal" (select) set to either
|
||||||
"cat" or "dog":
|
"cat" or "dog":
|
||||||
|
|
||||||
`?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
|
`?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
|
||||||
|
|
||||||
5. Documents with a custom field "address" (text) that is empty:
|
5. Documents with a custom field "address" (text) that is empty:
|
||||||
|
|
||||||
`?custom_field_query=["OR", [["address", "isnull", true], ["address", "exact", ""]]]`
|
`?custom_field_query=["OR", [["address", "isnull", true], ["address", "exact", ""]]]`
|
||||||
|
|
||||||
6. Documents that don't have a field called "foo":
|
6. Documents that don't have a field called "foo":
|
||||||
|
|
||||||
`?custom_field_query=["foo", "exists", false]`
|
`?custom_field_query=["foo", "exists", false]`
|
||||||
|
|
||||||
7. Documents that have document links "references" to both document 3 and 7:
|
7. Documents that have document links "references" to both document 3 and 7:
|
||||||
|
|
||||||
`?custom_field_query=["references", "contains", [3, 7]]`
|
`?custom_field_query=["references", "contains", [3, 7]]`
|
||||||
|
|
||||||
All field types support basic operations including `exact`, `in`, `isnull`,
|
All field types support basic operations including `exact`, `in`, `isnull`,
|
||||||
and `exists`. String, URL, and monetary fields support case-insensitive
|
and `exists`. String, URL, and monetary fields support case-insensitive
|
||||||
@@ -164,8 +164,8 @@ Get auto completions for a partial search term.
|
|||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
|
|
||||||
- `term`: The incomplete term.
|
- `term`: The incomplete term.
|
||||||
- `limit`: Amount of results. Defaults to 10.
|
- `limit`: Amount of results. Defaults to 10.
|
||||||
|
|
||||||
Results returned by the endpoint are ordered by importance of the term
|
Results returned by the endpoint are ordered by importance of the term
|
||||||
in the document index. The first result is the term that has the highest
|
in the document index. The first result is the term that has the highest
|
||||||
@@ -189,19 +189,19 @@ from there.
|
|||||||
|
|
||||||
The endpoint supports the following optional form fields:
|
The endpoint supports the following optional form fields:
|
||||||
|
|
||||||
- `title`: Specify a title that the consumer should use for the
|
- `title`: Specify a title that the consumer should use for the
|
||||||
document.
|
document.
|
||||||
- `created`: Specify a DateTime where the document was created (e.g.
|
- `created`: Specify a DateTime where the document was created (e.g.
|
||||||
"2016-04-19" or "2016-04-19 06:15:00+02:00").
|
"2016-04-19" or "2016-04-19 06:15:00+02:00").
|
||||||
- `correspondent`: Specify the ID of a correspondent that the consumer
|
- `correspondent`: Specify the ID of a correspondent that the consumer
|
||||||
should use for the document.
|
should use for the document.
|
||||||
- `document_type`: Similar to correspondent.
|
- `document_type`: Similar to correspondent.
|
||||||
- `storage_path`: Similar to correspondent.
|
- `storage_path`: Similar to correspondent.
|
||||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||||
have multiple tags added to the document.
|
have multiple tags added to the document.
|
||||||
- `archive_serial_number`: An optional archive serial number to set.
|
- `archive_serial_number`: An optional archive serial number to set.
|
||||||
- `custom_fields`: Either an array of custom field ids to assign (with an empty
|
- `custom_fields`: Either an array of custom field ids to assign (with an empty
|
||||||
value) to the document or an object mapping field id -> value.
|
value) to the document or an object mapping field id -> value.
|
||||||
|
|
||||||
The endpoint will immediately return HTTP 200 if the document consumption
|
The endpoint will immediately return HTTP 200 if the document consumption
|
||||||
process was started successfully, with the UUID of the consumption task
|
process was started successfully, with the UUID of the consumption task
|
||||||
@@ -215,16 +215,16 @@ consumption including the ID of a created document if consumption succeeded.
|
|||||||
|
|
||||||
Document versions are file-level versions linked to one root document.
|
Document versions are file-level versions linked to one root document.
|
||||||
|
|
||||||
- Root document metadata (title, tags, correspondent, document type, storage path, custom fields, permissions) remains shared.
|
- Root document metadata (title, tags, correspondent, document type, storage path, custom fields, permissions) remains shared.
|
||||||
- Version-specific file data (file, mime type, checksums, archive info, extracted text content) belongs to the selected/latest version.
|
- Version-specific file data (file, mime type, checksums, archive info, extracted text content) belongs to the selected/latest version.
|
||||||
|
|
||||||
Version-aware endpoints:
|
Version-aware endpoints:
|
||||||
|
|
||||||
- `GET /api/documents/{id}/`: returns root document data; `content` resolves to latest version content by default. Use `?version={version_id}` to resolve content for a specific version.
|
- `GET /api/documents/{id}/`: returns root document data; `content` resolves to latest version content by default. Use `?version={version_id}` to resolve content for a specific version.
|
||||||
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
||||||
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
||||||
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
||||||
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
@@ -282,34 +282,34 @@ a json payload of the format:
|
|||||||
|
|
||||||
The following methods are supported:
|
The following methods are supported:
|
||||||
|
|
||||||
- `set_correspondent`
|
- `set_correspondent`
|
||||||
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||||
- `set_document_type`
|
- `set_document_type`
|
||||||
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||||
- `set_storage_path`
|
- `set_storage_path`
|
||||||
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||||
- `add_tag`
|
- `add_tag`
|
||||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
- `remove_tag`
|
- `remove_tag`
|
||||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
- `modify_tags`
|
- `modify_tags`
|
||||||
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||||
- `delete`
|
- `delete`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `reprocess`
|
- `reprocess`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `set_permissions`
|
- `set_permissions`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
- `"owner": OWNER_ID or null`
|
- `"owner": OWNER_ID or null`
|
||||||
- `"merge": true or false` (defaults to false)
|
- `"merge": true or false` (defaults to false)
|
||||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
removing them) or be merged with existing permissions.
|
removing them) or be merged with existing permissions.
|
||||||
- `modify_custom_fields`
|
- `modify_custom_fields`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs
|
- `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs
|
||||||
to add with empty values.
|
to add with empty values.
|
||||||
- `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document.
|
- `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document.
|
||||||
|
|
||||||
#### Document-editing operations
|
#### Document-editing operations
|
||||||
|
|
||||||
@@ -335,16 +335,16 @@ operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json
|
|||||||
|
|
||||||
The REST API is versioned.
|
The REST API is versioned.
|
||||||
|
|
||||||
- Versioning ensures that changes to the API don't break older
|
- Versioning ensures that changes to the API don't break older
|
||||||
clients.
|
clients.
|
||||||
- Clients specify the specific version of the API they wish to use
|
- Clients specify the specific version of the API they wish to use
|
||||||
with every request and Paperless will handle the request using the
|
with every request and Paperless will handle the request using the
|
||||||
specified API version.
|
specified API version.
|
||||||
- Even if the underlying data model changes, supported older API
|
- Even if the underlying data model changes, supported older API
|
||||||
versions continue to serve compatible data.
|
versions continue to serve compatible data.
|
||||||
- If no version is specified, Paperless serves the configured default
|
- If no version is specified, Paperless serves the configured default
|
||||||
API version (currently `10`).
|
API version (currently `10`).
|
||||||
- Supported API versions are currently `9` and `10`.
|
- Supported API versions are currently `9` and `10`.
|
||||||
|
|
||||||
API versions are specified by submitting an additional HTTP `Accept`
|
API versions are specified by submitting an additional HTTP `Accept`
|
||||||
header with every request:
|
header with every request:
|
||||||
@@ -384,56 +384,56 @@ Initial API version.
|
|||||||
|
|
||||||
#### Version 2
|
#### Version 2
|
||||||
|
|
||||||
- Added field `Tag.color`. This read/write string field contains a hex
|
- Added field `Tag.color`. This read/write string field contains a hex
|
||||||
color such as `#a6cee3`.
|
color such as `#a6cee3`.
|
||||||
- Added read-only field `Tag.text_color`. This field contains the text
|
- Added read-only field `Tag.text_color`. This field contains the text
|
||||||
color to use for a specific tag, which is either black or white
|
color to use for a specific tag, which is either black or white
|
||||||
depending on the brightness of `Tag.color`.
|
depending on the brightness of `Tag.color`.
|
||||||
- Removed field `Tag.colour`.
|
- Removed field `Tag.colour`.
|
||||||
|
|
||||||
#### Version 3
|
#### Version 3
|
||||||
|
|
||||||
- Permissions endpoints have been added.
|
- Permissions endpoints have been added.
|
||||||
- The format of the `/api/ui_settings/` has changed.
|
- The format of the `/api/ui_settings/` has changed.
|
||||||
|
|
||||||
#### Version 4
|
#### Version 4
|
||||||
|
|
||||||
- Consumption templates were refactored to workflows and API endpoints
|
- Consumption templates were refactored to workflows and API endpoints
|
||||||
changed as such.
|
changed as such.
|
||||||
|
|
||||||
#### Version 5
|
#### Version 5
|
||||||
|
|
||||||
- Added bulk deletion methods for documents and objects.
|
- Added bulk deletion methods for documents and objects.
|
||||||
|
|
||||||
#### Version 6
|
#### Version 6
|
||||||
|
|
||||||
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
|
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
|
||||||
|
|
||||||
#### Version 7
|
#### Version 7
|
||||||
|
|
||||||
- The format of select type custom fields has changed to return the options
|
- The format of select type custom fields has changed to return the options
|
||||||
as an array of objects with `id` and `label` fields as opposed to a simple
|
as an array of objects with `id` and `label` fields as opposed to a simple
|
||||||
list of strings. When creating or updating a custom field value of a
|
list of strings. When creating or updating a custom field value of a
|
||||||
document for a select type custom field, the value should be the `id` of
|
document for a select type custom field, the value should be the `id` of
|
||||||
the option whereas previously was the index of the option.
|
the option whereas previously was the index of the option.
|
||||||
|
|
||||||
#### Version 8
|
#### Version 8
|
||||||
|
|
||||||
- The user field of document notes now returns a simplified user object
|
- The user field of document notes now returns a simplified user object
|
||||||
rather than just the user ID.
|
rather than just the user ID.
|
||||||
|
|
||||||
#### Version 9
|
#### Version 9
|
||||||
|
|
||||||
- The document `created` field is now a date, not a datetime. The
|
- The document `created` field is now a date, not a datetime. The
|
||||||
`created_date` field is considered deprecated and will be removed in a
|
`created_date` field is considered deprecated and will be removed in a
|
||||||
future version.
|
future version.
|
||||||
|
|
||||||
#### Version 10
|
#### Version 10
|
||||||
|
|
||||||
- The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been
|
- The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been
|
||||||
removed. Relevant settings are now stored in the UISettings model. Compatibility is maintained
|
removed. Relevant settings are now stored in the UISettings model. Compatibility is maintained
|
||||||
for versions < 10 until support for API v9 is dropped.
|
for versions < 10 until support for API v9 is dropped.
|
||||||
- Document-editing operations such as `merge`, `rotate`, and `edit_pdf` have been
|
- Document-editing operations such as `merge`, `rotate`, and `edit_pdf` have been
|
||||||
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
|
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
|
||||||
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
|
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
|
||||||
for API v9 is dropped.
|
for API v9 is dropped.
|
||||||
|
|||||||
9976
docs/changelog.md
9976
docs/changelog.md
File diff suppressed because it is too large
Load Diff
@@ -8,17 +8,17 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
|
|||||||
preference over the settings via environment variables. If not set, the environment setting
|
preference over the settings via environment variables. If not set, the environment setting
|
||||||
or applicable default will be utilized instead.
|
or applicable default will be utilized instead.
|
||||||
|
|
||||||
- If you run paperless on docker, `paperless.conf` is not used.
|
- If you run paperless on docker, `paperless.conf` is not used.
|
||||||
Rather, configure paperless by copying necessary options to
|
Rather, configure paperless by copying necessary options to
|
||||||
`docker-compose.env`.
|
`docker-compose.env`.
|
||||||
|
|
||||||
- If you are running paperless on anything else, paperless will search
|
- If you are running paperless on anything else, paperless will search
|
||||||
for the configuration file in these locations and use the first one
|
for the configuration file in these locations and use the first one
|
||||||
it finds:
|
it finds:
|
||||||
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
|
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
|
||||||
- `/path/to/paperless/paperless.conf`
|
- `/path/to/paperless/paperless.conf`
|
||||||
- `/etc/paperless.conf`
|
- `/etc/paperless.conf`
|
||||||
- `/usr/local/etc/paperless.conf`
|
- `/usr/local/etc/paperless.conf`
|
||||||
|
|
||||||
## Required services
|
## Required services
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,23 @@ on Paperless-ngx.
|
|||||||
Check out the source from GitHub. The repository is organized in the
|
Check out the source from GitHub. The repository is organized in the
|
||||||
following way:
|
following way:
|
||||||
|
|
||||||
- `main` always represents the latest release and will only see
|
- `main` always represents the latest release and will only see
|
||||||
changes when a new release is made.
|
changes when a new release is made.
|
||||||
- `dev` contains the code that will be in the next release.
|
- `dev` contains the code that will be in the next release.
|
||||||
- `feature-X` contains bigger changes that will be in some release, but
|
- `feature-X` contains bigger changes that will be in some release, but
|
||||||
not necessarily the next one.
|
not necessarily the next one.
|
||||||
|
|
||||||
When making functional changes to Paperless-ngx, _always_ make your changes
|
When making functional changes to Paperless-ngx, _always_ make your changes
|
||||||
on the `dev` branch.
|
on the `dev` branch.
|
||||||
|
|
||||||
Apart from that, the folder structure is as follows:
|
Apart from that, the folder structure is as follows:
|
||||||
|
|
||||||
- `docs/` - Documentation.
|
- `docs/` - Documentation.
|
||||||
- `src-ui/` - Code of the front end.
|
- `src-ui/` - Code of the front end.
|
||||||
- `src/` - Code of the back end.
|
- `src/` - Code of the back end.
|
||||||
- `scripts/` - Various scripts that help with different parts of
|
- `scripts/` - Various scripts that help with different parts of
|
||||||
development.
|
development.
|
||||||
- `docker/` - Files required to build the docker image.
|
- `docker/` - Files required to build the docker image.
|
||||||
|
|
||||||
## Contributing to Paperless-ngx
|
## Contributing to Paperless-ngx
|
||||||
|
|
||||||
@@ -94,17 +94,18 @@ first-time setup.
|
|||||||
```
|
```
|
||||||
|
|
||||||
7. You can now either ...
|
7. You can now either ...
|
||||||
- install Redis or
|
|
||||||
|
|
||||||
- use the included `scripts/start_services.sh` to use Docker to fire
|
- install Redis or
|
||||||
up a Redis instance (and some other services such as Tika,
|
|
||||||
Gotenberg and a database server) or
|
|
||||||
|
|
||||||
- spin up a bare Redis container
|
- use the included `scripts/start_services.sh` to use Docker to fire
|
||||||
|
up a Redis instance (and some other services such as Tika,
|
||||||
|
Gotenberg and a database server) or
|
||||||
|
|
||||||
```bash
|
- spin up a bare Redis container
|
||||||
docker run -d -p 6379:6379 --restart unless-stopped redis:latest
|
|
||||||
```
|
```bash
|
||||||
|
docker run -d -p 6379:6379 --restart unless-stopped redis:latest
|
||||||
|
```
|
||||||
|
|
||||||
8. Continue with either back-end or front-end development – or both :-).
|
8. Continue with either back-end or front-end development – or both :-).
|
||||||
|
|
||||||
@@ -117,9 +118,9 @@ work well for development, but you can use whatever you want.
|
|||||||
Configure the IDE to use the `src/`-folder as the base source folder.
|
Configure the IDE to use the `src/`-folder as the base source folder.
|
||||||
Configure the following launch configurations in your IDE:
|
Configure the following launch configurations in your IDE:
|
||||||
|
|
||||||
- `uv run manage.py runserver`
|
- `uv run manage.py runserver`
|
||||||
- `uv run manage.py document_consumer`
|
- `uv run manage.py document_consumer`
|
||||||
- `uv run celery --app paperless worker -l DEBUG` (or any other log level)
|
- `uv run celery --app paperless worker -l DEBUG` (or any other log level)
|
||||||
|
|
||||||
To start them all:
|
To start them all:
|
||||||
|
|
||||||
@@ -145,11 +146,11 @@ pnpm ng build --configuration production
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- Run `pytest` in the `src/` directory to execute all tests. This also
|
- Run `pytest` in the `src/` directory to execute all tests. This also
|
||||||
generates a HTML coverage report. When running tests, `paperless.conf`
|
generates a HTML coverage report. When running tests, `paperless.conf`
|
||||||
is loaded as well. However, the tests rely on the default
|
is loaded as well. However, the tests rely on the default
|
||||||
configuration. This is not ideal. But for now, make sure no settings
|
configuration. This is not ideal. But for now, make sure no settings
|
||||||
except for DEBUG are overridden when testing.
|
except for DEBUG are overridden when testing.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
@@ -253,14 +254,14 @@ these parts have to be translated separately.
|
|||||||
|
|
||||||
### Front end localization
|
### Front end localization
|
||||||
|
|
||||||
- The AngularJS front end does localization according to the [Angular
|
- The AngularJS front end does localization according to the [Angular
|
||||||
documentation](https://angular.io/guide/i18n).
|
documentation](https://angular.io/guide/i18n).
|
||||||
- The source language of the project is "en_US".
|
- The source language of the project is "en_US".
|
||||||
- The source strings end up in the file `src-ui/messages.xlf`.
|
- The source strings end up in the file `src-ui/messages.xlf`.
|
||||||
- The translated strings need to be placed in the
|
- The translated strings need to be placed in the
|
||||||
`src-ui/src/locale/` folder.
|
`src-ui/src/locale/` folder.
|
||||||
- In order to extract added or changed strings from the source files,
|
- In order to extract added or changed strings from the source files,
|
||||||
call `ng extract-i18n`.
|
call `ng extract-i18n`.
|
||||||
|
|
||||||
Adding new languages requires adding the translated files in the
|
Adding new languages requires adding the translated files in the
|
||||||
`src-ui/src/locale/` folder and adjusting a couple files.
|
`src-ui/src/locale/` folder and adjusting a couple files.
|
||||||
@@ -306,18 +307,18 @@ A majority of the strings that appear in the back end appear only when
|
|||||||
the admin is used. However, some of these are still shown on the front
|
the admin is used. However, some of these are still shown on the front
|
||||||
end (such as error messages).
|
end (such as error messages).
|
||||||
|
|
||||||
- The django application does localization according to the [Django
|
- The django application does localization according to the [Django
|
||||||
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
|
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
|
||||||
- The source language of the project is "en_US".
|
- The source language of the project is "en_US".
|
||||||
- Localization files end up in the folder `src/locale/`.
|
- Localization files end up in the folder `src/locale/`.
|
||||||
- In order to extract strings from the application, call
|
- In order to extract strings from the application, call
|
||||||
`uv run manage.py makemessages -l en_US`. This is important after
|
`uv run manage.py makemessages -l en_US`. This is important after
|
||||||
making changes to translatable strings.
|
making changes to translatable strings.
|
||||||
- The message files need to be compiled for them to show up in the
|
- The message files need to be compiled for them to show up in the
|
||||||
application. Call `uv run manage.py compilemessages` to do this.
|
application. Call `uv run manage.py compilemessages` to do this.
|
||||||
The generated files don't get committed into git, since these are
|
The generated files don't get committed into git, since these are
|
||||||
derived artifacts. The build pipeline takes care of executing this
|
derived artifacts. The build pipeline takes care of executing this
|
||||||
command.
|
command.
|
||||||
|
|
||||||
Adding new languages requires adding the translated files in the
|
Adding new languages requires adding the translated files in the
|
||||||
`src/locale/`-folder and adjusting the file
|
`src/locale/`-folder and adjusting the file
|
||||||
@@ -380,10 +381,10 @@ base code.
|
|||||||
Paperless-ngx uses parsers to add documents. A parser is
|
Paperless-ngx uses parsers to add documents. A parser is
|
||||||
responsible for:
|
responsible for:
|
||||||
|
|
||||||
- Retrieving the content from the original
|
- Retrieving the content from the original
|
||||||
- Creating a thumbnail
|
- Creating a thumbnail
|
||||||
- _optional:_ Retrieving a created date from the original
|
- _optional:_ Retrieving a created date from the original
|
||||||
- _optional:_ Creating an archived document from the original
|
- _optional:_ Creating an archived document from the original
|
||||||
|
|
||||||
Custom parsers can be added to Paperless-ngx to support more file types. In
|
Custom parsers can be added to Paperless-ngx to support more file types. In
|
||||||
order to do that, you need to write the parser itself and announce its
|
order to do that, you need to write the parser itself and announce its
|
||||||
@@ -441,17 +442,17 @@ def myparser_consumer_declaration(sender, **kwargs):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `parser` is a reference to a class that extends `DocumentParser`.
|
- `parser` is a reference to a class that extends `DocumentParser`.
|
||||||
- `weight` is used whenever two or more parsers are able to parse a
|
- `weight` is used whenever two or more parsers are able to parse a
|
||||||
file: The parser with the higher weight wins. This can be used to
|
file: The parser with the higher weight wins. This can be used to
|
||||||
override the parsers provided by Paperless-ngx.
|
override the parsers provided by Paperless-ngx.
|
||||||
- `mime_types` is a dictionary. The keys are the mime types your
|
- `mime_types` is a dictionary. The keys are the mime types your
|
||||||
parser supports and the value is the default file extension that
|
parser supports and the value is the default file extension that
|
||||||
Paperless-ngx should use when storing files and serving them for
|
Paperless-ngx should use when storing files and serving them for
|
||||||
download. We could guess that from the file extensions, but some
|
download. We could guess that from the file extensions, but some
|
||||||
mime types have many extensions associated with them and the Python
|
mime types have many extensions associated with them and the Python
|
||||||
methods responsible for guessing the extension do not always return
|
methods responsible for guessing the extension do not always return
|
||||||
the same value.
|
the same value.
|
||||||
|
|
||||||
## Using Visual Studio Code devcontainer
|
## Using Visual Studio Code devcontainer
|
||||||
|
|
||||||
@@ -470,8 +471,9 @@ To get started:
|
|||||||
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
||||||
|
|
||||||
3. In case your host operating system is Windows:
|
3. In case your host operating system is Windows:
|
||||||
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
|
|
||||||
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
|
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
|
||||||
|
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
|
||||||
|
|
||||||
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
||||||
will initialize the database tables and create a superuser. Then you can compile the front end
|
will initialize the database tables and create a superuser. Then you can compile the front end
|
||||||
@@ -536,12 +538,12 @@ class MyDateParserPlugin(DateParserPluginBase):
|
|||||||
|
|
||||||
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
|
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
|
||||||
|
|
||||||
- `languages: list[str]` - List of language codes for date parsing
|
- `languages: list[str]` - List of language codes for date parsing
|
||||||
- `timezone_str: str` - Timezone string for date localization
|
- `timezone_str: str` - Timezone string for date localization
|
||||||
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
|
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
|
||||||
- `reference_time: datetime.datetime` - Current time for filtering future dates
|
- `reference_time: datetime.datetime` - Current time for filtering future dates
|
||||||
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
|
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
|
||||||
- `content_date_order: str` - Date order preference for content
|
- `content_date_order: str` - Date order preference for content
|
||||||
|
|
||||||
The base class provides two helper methods you can use:
|
The base class provides two helper methods you can use:
|
||||||
|
|
||||||
|
|||||||
34
docs/faq.md
34
docs/faq.md
@@ -44,28 +44,28 @@ system. On Linux, chances are high that this location is
|
|||||||
You can always drag those files out of that folder to use them
|
You can always drag those files out of that folder to use them
|
||||||
elsewhere. Here are a couple notes about that.
|
elsewhere. Here are a couple notes about that.
|
||||||
|
|
||||||
- Paperless-ngx never modifies your original documents. It keeps
|
- Paperless-ngx never modifies your original documents. It keeps
|
||||||
checksums of all documents and uses a scheduled sanity checker to
|
checksums of all documents and uses a scheduled sanity checker to
|
||||||
check that they remain the same.
|
check that they remain the same.
|
||||||
- By default, paperless uses the internal ID of each document as its
|
- By default, paperless uses the internal ID of each document as its
|
||||||
filename. This might not be very convenient for export. However, you
|
filename. This might not be very convenient for export. However, you
|
||||||
can adjust the way files are stored in paperless by
|
can adjust the way files are stored in paperless by
|
||||||
[configuring the filename format](advanced_usage.md#file-name-handling).
|
[configuring the filename format](advanced_usage.md#file-name-handling).
|
||||||
- [The exporter](administration.md#exporter) is
|
- [The exporter](administration.md#exporter) is
|
||||||
another easy way to get your files out of paperless with reasonable
|
another easy way to get your files out of paperless with reasonable
|
||||||
file names.
|
file names.
|
||||||
|
|
||||||
## _What file types does paperless-ngx support?_
|
## _What file types does paperless-ngx support?_
|
||||||
|
|
||||||
**A:** Currently, the following files are supported:
|
**A:** Currently, the following files are supported:
|
||||||
|
|
||||||
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and
|
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and
|
||||||
WebP images are processed with OCR and converted into PDF documents.
|
WebP images are processed with OCR and converted into PDF documents.
|
||||||
- Plain text documents are supported as well and are added verbatim to
|
- Plain text documents are supported as well and are added verbatim to
|
||||||
paperless.
|
paperless.
|
||||||
- With the optional Tika integration enabled (see [Tika configuration](https://docs.paperless-ngx.com/configuration#tika)),
|
- With the optional Tika integration enabled (see [Tika configuration](https://docs.paperless-ngx.com/configuration#tika)),
|
||||||
Paperless also supports various Office documents (.docx, .doc, odt,
|
Paperless also supports various Office documents (.docx, .doc, odt,
|
||||||
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
||||||
|
|
||||||
Paperless-ngx determines the type of a file by inspecting its content
|
Paperless-ngx determines the type of a file by inspecting its content
|
||||||
rather than its file extensions. However, files processed via the
|
rather than its file extensions. However, files processed via the
|
||||||
|
|||||||
@@ -28,36 +28,36 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
||||||
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
|
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
|
||||||
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
||||||
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
||||||
- _New!_ Supports remote OCR with Azure AI (opt-in).
|
- _New!_ Supports remote OCR with Azure AI (opt-in).
|
||||||
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
||||||
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
||||||
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
|
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
|
||||||
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
||||||
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
||||||
- **Beautiful, modern web application** that features:
|
- **Beautiful, modern web application** that features:
|
||||||
- Customizable dashboard with statistics.
|
- Customizable dashboard with statistics.
|
||||||
- Filtering by tags, correspondents, types, and more.
|
- Filtering by tags, correspondents, types, and more.
|
||||||
- Bulk editing of tags, correspondents, types and more.
|
- Bulk editing of tags, correspondents, types and more.
|
||||||
- Drag-and-drop uploading of documents throughout the app.
|
- Drag-and-drop uploading of documents throughout the app.
|
||||||
- Customizable views can be saved and displayed on the dashboard and / or sidebar.
|
- Customizable views can be saved and displayed on the dashboard and / or sidebar.
|
||||||
- Support for custom fields of various data types.
|
- Support for custom fields of various data types.
|
||||||
- Shareable public links with optional expiration.
|
- Shareable public links with optional expiration.
|
||||||
- **Full text search** helps you find what you need:
|
- **Full text search** helps you find what you need:
|
||||||
- Auto completion suggests relevant words from your documents.
|
- Auto completion suggests relevant words from your documents.
|
||||||
- Results are sorted by relevance to your search query.
|
- Results are sorted by relevance to your search query.
|
||||||
- Highlighting shows you which parts of the document matched the query.
|
- Highlighting shows you which parts of the document matched the query.
|
||||||
- Searching for similar documents ("More like this")
|
- Searching for similar documents ("More like this")
|
||||||
- **Email processing**[^1]: import documents from your email accounts:
|
- **Email processing**[^1]: import documents from your email accounts:
|
||||||
- Configure multiple accounts and rules for each account.
|
- Configure multiple accounts and rules for each account.
|
||||||
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
||||||
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
||||||
- A powerful workflow system that gives you even more control.
|
- A powerful workflow system that gives you even more control.
|
||||||
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
||||||
- The integrated sanity checker makes sure that your document archive is in good health.
|
- The integrated sanity checker makes sure that your document archive is in good health.
|
||||||
|
|
||||||
[^1]: Office document and email consumption support is optional and provided by Apache Tika (see [configuration](https://docs.paperless-ngx.com/configuration/#tika))
|
[^1]: Office document and email consumption support is optional and provided by Apache Tika (see [configuration](https://docs.paperless-ngx.com/configuration/#tika))
|
||||||
|
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ The `CONSUMER_BARCODE_SCANNER` setting has been removed. zxing-cpp is now the on
|
|||||||
|
|
||||||
### Action Required
|
### Action Required
|
||||||
|
|
||||||
- If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting.
|
- If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting.
|
||||||
- If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond
|
- If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond
|
||||||
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
|
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
|
||||||
reliability.
|
reliability.
|
||||||
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
|
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
|
||||||
images or host installations.
|
images or host installations.
|
||||||
|
|
||||||
## Database Engine
|
## Database Engine
|
||||||
|
|
||||||
|
|||||||
235
docs/setup.md
235
docs/setup.md
@@ -44,8 +44,8 @@ account. In short, it automates the [Docker Compose setup](#docker) described be
|
|||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
||||||
- macOS users will need [GNU sed](https://formulae.brew.sh/formula/gnu-sed) with support for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
|
- macOS users will need [GNU sed](https://formulae.brew.sh/formula/gnu-sed) with support for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
|
||||||
|
|
||||||
#### Run the installation script
|
#### Run the installation script
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ credentials you provided during the installation script.
|
|||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
|
||||||
|
|
||||||
#### Installation
|
#### Installation
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ credentials you provided during the installation script.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ports:
|
ports:
|
||||||
- 8010:8000
|
- 8010:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Modify `docker-compose.env` with any configuration options you need.
|
3. Modify `docker-compose.env` with any configuration options you need.
|
||||||
@@ -145,11 +145,11 @@ a [superuser](usage.md#superusers) account.
|
|||||||
If you want to run Paperless as a rootless container, make this
|
If you want to run Paperless as a rootless container, make this
|
||||||
change in `docker-compose.yml`:
|
change in `docker-compose.yml`:
|
||||||
|
|
||||||
- Set the `user` running the container to map to the `paperless`
|
- Set the `user` running the container to map to the `paperless`
|
||||||
user in the container. This value (`user_id` below) should be
|
user in the container. This value (`user_id` below) should be
|
||||||
the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in
|
the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in
|
||||||
`docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID`
|
`docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID`
|
||||||
[here](configuration.md#docker).
|
[here](configuration.md#docker).
|
||||||
|
|
||||||
Your entry for Paperless should contain something like:
|
Your entry for Paperless should contain something like:
|
||||||
|
|
||||||
@@ -171,25 +171,26 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- Paperless runs on Linux only, Windows is not supported.
|
- Paperless runs on Linux only, Windows is not supported.
|
||||||
- Python 3.11, 3.12, 3.13, or 3.14 is required. As a policy, Paperless-ngx aims to support at least the three most recent Python versions and drops support for versions as they reach end-of-life. Newer versions may work, but some dependencies may not be fully compatible.
|
- Python 3.11, 3.12, 3.13, or 3.14 is required. As a policy, Paperless-ngx aims to support at least the three most recent Python versions and drops support for versions as they reach end-of-life. Newer versions may work, but some dependencies may not be fully compatible.
|
||||||
|
|
||||||
#### Installation
|
#### Installation
|
||||||
|
|
||||||
1. Install dependencies. Paperless requires the following packages:
|
1. Install dependencies. Paperless requires the following packages:
|
||||||
- `python3`
|
|
||||||
- `python3-pip`
|
- `python3`
|
||||||
- `python3-dev`
|
- `python3-pip`
|
||||||
- `default-libmysqlclient-dev` for MariaDB
|
- `python3-dev`
|
||||||
- `pkg-config` for mysqlclient (python dependency)
|
- `default-libmysqlclient-dev` for MariaDB
|
||||||
- `fonts-liberation` for generating thumbnails for plain text
|
- `pkg-config` for mysqlclient (python dependency)
|
||||||
files
|
- `fonts-liberation` for generating thumbnails for plain text
|
||||||
- `imagemagick` >= 6 for PDF conversion
|
files
|
||||||
- `gnupg` for handling encrypted documents
|
- `imagemagick` >= 6 for PDF conversion
|
||||||
- `libpq-dev` for PostgreSQL
|
- `gnupg` for handling encrypted documents
|
||||||
- `libmagic-dev` for mime type detection
|
- `libpq-dev` for PostgreSQL
|
||||||
- `mariadb-client` for MariaDB compile time
|
- `libmagic-dev` for mime type detection
|
||||||
- `poppler-utils` for barcode detection
|
- `mariadb-client` for MariaDB compile time
|
||||||
|
- `poppler-utils` for barcode detection
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
@@ -199,17 +200,18 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
|
|
||||||
These dependencies are required for OCRmyPDF, which is used for text
|
These dependencies are required for OCRmyPDF, which is used for text
|
||||||
recognition.
|
recognition.
|
||||||
- `unpaper`
|
|
||||||
- `ghostscript`
|
- `unpaper`
|
||||||
- `icc-profiles-free`
|
- `ghostscript`
|
||||||
- `qpdf`
|
- `icc-profiles-free`
|
||||||
- `liblept5`
|
- `qpdf`
|
||||||
- `libxml2`
|
- `liblept5`
|
||||||
- `pngquant` (suggested for certain PDF image optimizations)
|
- `libxml2`
|
||||||
- `zlib1g`
|
- `pngquant` (suggested for certain PDF image optimizations)
|
||||||
- `tesseract-ocr` >= 4.0.0 for OCR
|
- `zlib1g`
|
||||||
- `tesseract-ocr` language packs (`tesseract-ocr-eng`,
|
- `tesseract-ocr` >= 4.0.0 for OCR
|
||||||
`tesseract-ocr-deu`, etc)
|
- `tesseract-ocr` language packs (`tesseract-ocr-eng`,
|
||||||
|
`tesseract-ocr-deu`, etc)
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
@@ -218,14 +220,16 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
```
|
```
|
||||||
|
|
||||||
On Raspberry Pi, these libraries are required as well:
|
On Raspberry Pi, these libraries are required as well:
|
||||||
- `libatlas-base-dev`
|
|
||||||
- `libxslt1-dev`
|
- `libatlas-base-dev`
|
||||||
- `mime-support`
|
- `libxslt1-dev`
|
||||||
|
- `mime-support`
|
||||||
|
|
||||||
You will also need these for installing some of the python dependencies:
|
You will also need these for installing some of the python dependencies:
|
||||||
- `build-essential`
|
|
||||||
- `python3-setuptools`
|
- `build-essential`
|
||||||
- `python3-wheel`
|
- `python3-setuptools`
|
||||||
|
- `python3-wheel`
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
@@ -275,41 +279,44 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
|
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
|
||||||
Edit the included `paperless.conf` and adjust the settings to your
|
Edit the included `paperless.conf` and adjust the settings to your
|
||||||
needs. Required settings for getting Paperless-ngx running are:
|
needs. Required settings for getting Paperless-ngx running are:
|
||||||
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
|
|
||||||
`redis://localhost:6379`.
|
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
|
||||||
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
|
`redis://localhost:6379`.
|
||||||
`mariadb`, or `sqlite`
|
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
|
||||||
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
`mariadb`, or `sqlite`
|
||||||
PostgreSQL server is running. Do not configure this to use
|
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
||||||
SQLite instead. Also configure port, database name, user and
|
PostgreSQL server is running. Do not configure this to use
|
||||||
password as necessary.
|
SQLite instead. Also configure port, database name, user and
|
||||||
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
|
password as necessary.
|
||||||
that Paperless-ngx should watch for incoming documents.
|
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
|
||||||
Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
that Paperless-ngx should watch for incoming documents.
|
||||||
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
|
Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
||||||
If needed, these can point to the same directory.
|
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
|
||||||
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
If needed, these can point to the same directory.
|
||||||
characters. It's used for authentication. Failure to do so
|
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
||||||
allows third parties to forge authentication credentials.
|
characters. It's used for authentication. Failure to do so
|
||||||
- Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
allows third parties to forge authentication credentials.
|
||||||
point to your domain. Please see
|
- Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
||||||
[configuration](configuration.md) for more
|
point to your domain. Please see
|
||||||
information.
|
[configuration](configuration.md) for more
|
||||||
|
information.
|
||||||
|
|
||||||
You can make many more adjustments, especially for OCR.
|
You can make many more adjustments, especially for OCR.
|
||||||
The following options are recommended for most users:
|
The following options are recommended for most users:
|
||||||
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
|
||||||
documents are written in.
|
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
||||||
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
|
documents are written in.
|
||||||
|
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
|
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
|
||||||
|
|
||||||
7. Create the following directories if they do not already exist:
|
7. Create the following directories if they do not already exist:
|
||||||
- `/opt/paperless/media`
|
|
||||||
- `/opt/paperless/data`
|
- `/opt/paperless/media`
|
||||||
- `/opt/paperless/consume`
|
- `/opt/paperless/data`
|
||||||
|
- `/opt/paperless/consume`
|
||||||
|
|
||||||
Adjust these paths if you configured different folders.
|
Adjust these paths if you configured different folders.
|
||||||
Then verify that the `paperless` user has write permissions:
|
Then verify that the `paperless` user has write permissions:
|
||||||
@@ -384,10 +391,11 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
starting point.
|
starting point.
|
||||||
|
|
||||||
Paperless needs:
|
Paperless needs:
|
||||||
- The `webserver` script to run the webserver.
|
|
||||||
- The `consumer` script to watch the input folder.
|
- The `webserver` script to run the webserver.
|
||||||
- The `taskqueue` script for background workers (document consumption, etc.).
|
- The `consumer` script to watch the input folder.
|
||||||
- The `scheduler` script for periodic tasks such as email checking.
|
- The `taskqueue` script for background workers (document consumption, etc.).
|
||||||
|
- The `scheduler` script for periodic tasks such as email checking.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
@@ -493,19 +501,19 @@ your setup depending on how you installed Paperless.
|
|||||||
This section describes how to update an existing Paperless Docker
|
This section describes how to update an existing Paperless Docker
|
||||||
installation. Keep these points in mind:
|
installation. Keep these points in mind:
|
||||||
|
|
||||||
- Read the [changelog](changelog.md) and
|
- Read the [changelog](changelog.md) and
|
||||||
take note of breaking changes.
|
take note of breaking changes.
|
||||||
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
||||||
Both work fine with Paperless-ngx.
|
Both work fine with Paperless-ngx.
|
||||||
However, if you already have a database server running
|
However, if you already have a database server running
|
||||||
for other services, you might as well use it for Paperless as well.
|
for other services, you might as well use it for Paperless as well.
|
||||||
- The task scheduler of Paperless, which is used to execute periodic
|
- The task scheduler of Paperless, which is used to execute periodic
|
||||||
tasks such as email checking and maintenance, requires a
|
tasks such as email checking and maintenance, requires a
|
||||||
[Redis](https://redis.io/) message broker instance. The
|
[Redis](https://redis.io/) message broker instance. The
|
||||||
Docker Compose route takes care of that.
|
Docker Compose route takes care of that.
|
||||||
- The layout of the folder structure for your documents and data
|
- The layout of the folder structure for your documents and data
|
||||||
remains the same, so you can plug your old Docker volumes into
|
remains the same, so you can plug your old Docker volumes into
|
||||||
paperless-ngx and expect it to find everything where it should be.
|
paperless-ngx and expect it to find everything where it should be.
|
||||||
|
|
||||||
Migration to Paperless-ngx is then performed in a few simple steps:
|
Migration to Paperless-ngx is then performed in a few simple steps:
|
||||||
|
|
||||||
@@ -590,6 +598,7 @@ commands as well.
|
|||||||
1. Stop and remove the Paperless container.
|
1. Stop and remove the Paperless container.
|
||||||
2. If using an external database, stop that container.
|
2. If using an external database, stop that container.
|
||||||
3. Update Redis configuration.
|
3. Update Redis configuration.
|
||||||
|
|
||||||
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
||||||
and continue to step 4.
|
and continue to step 4.
|
||||||
|
|
||||||
@@ -601,18 +610,22 @@ commands as well.
|
|||||||
the new Redis container.
|
the new Redis container.
|
||||||
|
|
||||||
4. Update user mapping.
|
4. Update user mapping.
|
||||||
|
|
||||||
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
|
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
|
||||||
|
|
||||||
1. If set, change the environment variable `PGID` to `USERMAP_GID`.
|
1. If set, change the environment variable `PGID` to `USERMAP_GID`.
|
||||||
|
|
||||||
5. Update configuration paths.
|
5. Update configuration paths.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
|
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
|
||||||
|
|
||||||
6. Update media paths.
|
6. Update media paths.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
||||||
`/data/media`.
|
`/data/media`.
|
||||||
|
|
||||||
7. Update timezone.
|
7. Update timezone.
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
||||||
value as `TZ`.
|
value as `TZ`.
|
||||||
|
|
||||||
@@ -626,33 +639,33 @@ commands as well.
|
|||||||
Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered
|
Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered
|
||||||
hardware, but a few settings can improve performance:
|
hardware, but a few settings can improve performance:
|
||||||
|
|
||||||
- Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
|
- Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
|
||||||
if you encounter issues with SQLite locking.
|
if you encounter issues with SQLite locking.
|
||||||
- If you do not need the filesystem-based consumer, consider disabling it
|
- If you do not need the filesystem-based consumer, consider disabling it
|
||||||
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
|
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
|
||||||
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that Paperless
|
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that Paperless
|
||||||
OCRs only the first page of your documents. In most cases, this page
|
OCRs only the first page of your documents. In most cases, this page
|
||||||
contains enough information to be able to find it.
|
contains enough information to be able to find it.
|
||||||
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
|
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
|
||||||
configured to use all cores. The Raspberry Pi models 3 and up have 4
|
configured to use all cores. The Raspberry Pi models 3 and up have 4
|
||||||
cores, meaning that Paperless will use 2 workers and 2 threads per
|
cores, meaning that Paperless will use 2 workers and 2 threads per
|
||||||
worker. This may result in sluggish response times during
|
worker. This may result in sluggish response times during
|
||||||
consumption, so you might want to lower these settings (example: 2
|
consumption, so you might want to lower these settings (example: 2
|
||||||
workers and 1 thread to always have some computing power left for
|
workers and 1 thread to always have some computing power left for
|
||||||
other tasks).
|
other tasks).
|
||||||
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
|
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
|
||||||
OCRing your documents before feeding them into Paperless. Some
|
OCRing your documents before feeding them into Paperless. Some
|
||||||
scanners are able to do this!
|
scanners are able to do this!
|
||||||
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
|
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
|
||||||
file generation for already OCRed documents, or `always` to skip it
|
file generation for already OCRed documents, or `always` to skip it
|
||||||
for all documents.
|
for all documents.
|
||||||
- If you want to perform OCR on the device, consider using
|
- If you want to perform OCR on the device, consider using
|
||||||
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
||||||
less memory at the expense of slightly worse OCR results.
|
less memory at the expense of slightly worse OCR results.
|
||||||
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
||||||
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
||||||
more advanced language processing, which can take more memory and
|
more advanced language processing, which can take more memory and
|
||||||
processing time.
|
processing time.
|
||||||
|
|
||||||
For details, refer to [configuration](configuration.md).
|
For details, refer to [configuration](configuration.md).
|
||||||
|
|
||||||
|
|||||||
@@ -4,27 +4,27 @@
|
|||||||
|
|
||||||
Check for the following issues:
|
Check for the following issues:
|
||||||
|
|
||||||
- Ensure that the directory you're putting your documents in is the
|
- Ensure that the directory you're putting your documents in is the
|
||||||
folder paperless is watching. With docker, this setting is performed
|
folder paperless is watching. With docker, this setting is performed
|
||||||
in the `docker-compose.yml` file. Without Docker, look at the
|
in the `docker-compose.yml` file. Without Docker, look at the
|
||||||
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
||||||
using docker.
|
using docker.
|
||||||
|
|
||||||
- Ensure that redis is up and running. Paperless does its task
|
- Ensure that redis is up and running. Paperless does its task
|
||||||
processing asynchronously, and for documents to arrive at the task
|
processing asynchronously, and for documents to arrive at the task
|
||||||
processor, it needs redis to run.
|
processor, it needs redis to run.
|
||||||
|
|
||||||
- Ensure that the task processor is running. Docker does this
|
- Ensure that the task processor is running. Docker does this
|
||||||
automatically. Manually invoke the task processor by executing
|
automatically. Manually invoke the task processor by executing
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
celery --app paperless worker
|
celery --app paperless worker
|
||||||
```
|
```
|
||||||
|
|
||||||
- Look at the output of paperless and inspect it for any errors.
|
- Look at the output of paperless and inspect it for any errors.
|
||||||
|
|
||||||
- Go to the admin interface, and check if there are failed tasks. If
|
- Go to the admin interface, and check if there are failed tasks. If
|
||||||
so, the tasks will contain an error message.
|
so, the tasks will contain an error message.
|
||||||
|
|
||||||
## Consumer warns `OCR for XX failed`
|
## Consumer warns `OCR for XX failed`
|
||||||
|
|
||||||
@@ -78,12 +78,12 @@ Ensure that `chown` is possible on these directories.
|
|||||||
This indicates that the Auto matching algorithm found no documents to
|
This indicates that the Auto matching algorithm found no documents to
|
||||||
learn from. This may have two reasons:
|
learn from. This may have two reasons:
|
||||||
|
|
||||||
- You don't use the Auto matching algorithm: The error can be safely
|
- You don't use the Auto matching algorithm: The error can be safely
|
||||||
ignored in this case.
|
ignored in this case.
|
||||||
- You are using the Auto matching algorithm: The classifier explicitly
|
- You are using the Auto matching algorithm: The classifier explicitly
|
||||||
excludes documents with Inbox tags. Verify that there are documents
|
excludes documents with Inbox tags. Verify that there are documents
|
||||||
in your archive without inbox tags. The algorithm will only learn
|
in your archive without inbox tags. The algorithm will only learn
|
||||||
from documents not in your inbox.
|
from documents not in your inbox.
|
||||||
|
|
||||||
## UserWarning in sklearn on every single document
|
## UserWarning in sklearn on every single document
|
||||||
|
|
||||||
@@ -127,10 +127,10 @@ change in the `docker-compose.yml` file:
|
|||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
command:
|
command:
|
||||||
- 'gotenberg'
|
- 'gotenberg'
|
||||||
- '--chromium-disable-javascript=true'
|
- '--chromium-disable-javascript=true'
|
||||||
- '--chromium-allow-list=file:///tmp/.*'
|
- '--chromium-allow-list=file:///tmp/.*'
|
||||||
- '--api-timeout=60s'
|
- '--api-timeout=60s'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Permission denied errors in the consumption directory
|
## Permission denied errors in the consumption directory
|
||||||
|
|||||||
404
docs/usage.md
404
docs/usage.md
@@ -14,42 +14,42 @@ for finding and managing your documents.
|
|||||||
Paperless essentially consists of two different parts for managing your
|
Paperless essentially consists of two different parts for managing your
|
||||||
documents:
|
documents:
|
||||||
|
|
||||||
- The _consumer_ watches a specified folder and adds all documents in
|
- The _consumer_ watches a specified folder and adds all documents in
|
||||||
that folder to paperless.
|
that folder to paperless.
|
||||||
- The _web server_ (web UI) provides a UI that you use to manage and
|
- The _web server_ (web UI) provides a UI that you use to manage and
|
||||||
search documents.
|
search documents.
|
||||||
|
|
||||||
Each document has data fields that you can assign to them:
|
Each document has data fields that you can assign to them:
|
||||||
|
|
||||||
- A _Document_ is a piece of paper that sometimes contains valuable
|
- A _Document_ is a piece of paper that sometimes contains valuable
|
||||||
information.
|
information.
|
||||||
- The _correspondent_ of a document is the person, institution or
|
- The _correspondent_ of a document is the person, institution or
|
||||||
company that a document either originates from, or is sent to.
|
company that a document either originates from, or is sent to.
|
||||||
- A _tag_ is a label that you can assign to documents. Think of labels
|
- A _tag_ is a label that you can assign to documents. Think of labels
|
||||||
as more powerful folders: Multiple documents can be grouped together
|
as more powerful folders: Multiple documents can be grouped together
|
||||||
with a single tag, however, a single document can also have multiple
|
with a single tag, however, a single document can also have multiple
|
||||||
tags. This is not possible with folders. The reason folders are not
|
tags. This is not possible with folders. The reason folders are not
|
||||||
implemented in paperless is simply that tags are much more versatile
|
implemented in paperless is simply that tags are much more versatile
|
||||||
than folders.
|
than folders.
|
||||||
- A _document type_ is used to demarcate the type of a document such
|
- A _document type_ is used to demarcate the type of a document such
|
||||||
as letter, bank statement, invoice, contract, etc. It is used to
|
as letter, bank statement, invoice, contract, etc. It is used to
|
||||||
identify what a document is about.
|
identify what a document is about.
|
||||||
- The document _storage path_ is the location where the document files
|
- The document _storage path_ is the location where the document files
|
||||||
are stored. See [Storage Paths](advanced_usage.md#storage-paths) for
|
are stored. See [Storage Paths](advanced_usage.md#storage-paths) for
|
||||||
more information.
|
more information.
|
||||||
- The _date added_ of a document is the date the document was scanned
|
- The _date added_ of a document is the date the document was scanned
|
||||||
into paperless. You cannot and should not change this date.
|
into paperless. You cannot and should not change this date.
|
||||||
- The _date created_ of a document is the date the document was
|
- The _date created_ of a document is the date the document was
|
||||||
initially issued. This can be the date you bought a product, the
|
initially issued. This can be the date you bought a product, the
|
||||||
date you signed a contract, or the date a letter was sent to you.
|
date you signed a contract, or the date a letter was sent to you.
|
||||||
- The _archive serial number_ (short: ASN) of a document is the
|
- The _archive serial number_ (short: ASN) of a document is the
|
||||||
identifier of the document in your physical document binders. See
|
identifier of the document in your physical document binders. See
|
||||||
[recommended workflow](#usage-recommended-workflow) below.
|
[recommended workflow](#usage-recommended-workflow) below.
|
||||||
- The _content_ of a document is the text that was OCR'ed from the
|
- The _content_ of a document is the text that was OCR'ed from the
|
||||||
document. This text is fed into the search engine and is used for
|
document. This text is fed into the search engine and is used for
|
||||||
matching tags, correspondents and document types.
|
matching tags, correspondents and document types.
|
||||||
- Paperless-ngx also supports _custom fields_ which can be used to
|
- Paperless-ngx also supports _custom fields_ which can be used to
|
||||||
store additional metadata about a document.
|
store additional metadata about a document.
|
||||||
|
|
||||||
## The Web UI
|
## The Web UI
|
||||||
|
|
||||||
@@ -93,12 +93,12 @@ download the document or share it via a share link.
|
|||||||
|
|
||||||
Think of versions as **file history** for a document.
|
Think of versions as **file history** for a document.
|
||||||
|
|
||||||
- Versions track the underlying file and extracted text content (OCR/text).
|
- Versions track the underlying file and extracted text content (OCR/text).
|
||||||
- Metadata such as tags, correspondent, document type, storage path and custom fields stay on the "root" document.
|
- Metadata such as tags, correspondent, document type, storage path and custom fields stay on the "root" document.
|
||||||
- Version files follow normal filename formatting (including storage paths) and add a `_vN` suffix (for example `_v1`, `_v2`).
|
- Version files follow normal filename formatting (including storage paths) and add a `_vN` suffix (for example `_v1`, `_v2`).
|
||||||
- By default, search and document content use the latest version.
|
- By default, search and document content use the latest version.
|
||||||
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
||||||
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
||||||
|
|
||||||
### Management Lists
|
### Management Lists
|
||||||
|
|
||||||
@@ -218,20 +218,21 @@ patterns can include wildcards and multiple patterns separated by a comma.
|
|||||||
The actions all ensure that the same mail is not consumed twice by
|
The actions all ensure that the same mail is not consumed twice by
|
||||||
different means. These are as follows:
|
different means. These are as follows:
|
||||||
|
|
||||||
- **Delete:** Immediately deletes mail that paperless has consumed
|
- **Delete:** Immediately deletes mail that paperless has consumed
|
||||||
documents from. Use with caution.
|
documents from. Use with caution.
|
||||||
- **Mark as read:** Mark consumed mail as read. Paperless will not
|
- **Mark as read:** Mark consumed mail as read. Paperless will not
|
||||||
consume documents from already read mails. If you read a mail before
|
consume documents from already read mails. If you read a mail before
|
||||||
paperless sees it, it will be ignored.
|
paperless sees it, it will be ignored.
|
||||||
- **Flag:** Sets the 'important' flag on mails with consumed
|
- **Flag:** Sets the 'important' flag on mails with consumed
|
||||||
documents. Paperless will not consume flagged mails.
|
documents. Paperless will not consume flagged mails.
|
||||||
- **Move to folder:** Moves consumed mails out of the way so that
|
- **Move to folder:** Moves consumed mails out of the way so that
|
||||||
paperless won't consume them again.
|
paperless won't consume them again.
|
||||||
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
||||||
documents (the IMAP standard calls these "keywords"). Paperless
|
documents (the IMAP standard calls these "keywords"). Paperless
|
||||||
will not consume mails already tagged. Not all mail servers support
|
will not consume mails already tagged. Not all mail servers support
|
||||||
this feature!
|
this feature!
|
||||||
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
|
|
||||||
|
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
@@ -324,12 +325,12 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook)
|
|||||||
|
|
||||||
"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" 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 or bundled download.
|
- 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 are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
||||||
- Links can optionally have an expiration time set.
|
- Links can optionally have an expiration time set.
|
||||||
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
||||||
- From the document detail screen you can create a share link for that single document.
|
- 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.
|
- 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
|
!!! tip
|
||||||
|
|
||||||
@@ -513,25 +514,25 @@ flowchart TD
|
|||||||
|
|
||||||
Workflows allow you to filter by:
|
Workflows allow you to filter by:
|
||||||
|
|
||||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs.
|
- File name, including wildcards e.g. \*.pdf will apply to all pdfs.
|
||||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||||
example, automatically assigning documents to different owners based on the upload directory.
|
example, automatically assigning documents to different owners based on the upload directory.
|
||||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
||||||
|
|
||||||
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
||||||
|
|
||||||
- Any Tags: Filter for documents with any of the specified tags.
|
- Any Tags: Filter for documents with any of the specified tags.
|
||||||
- All Tags: Filter for documents with all of the specified tags.
|
- All Tags: Filter for documents with all of the specified tags.
|
||||||
- No Tags: Filter for documents with none of the specified tags.
|
- No Tags: Filter for documents with none of the specified tags.
|
||||||
- Document type: Filter documents with this document type.
|
- Document type: Filter documents with this document type.
|
||||||
- Not Document types: Filter documents without any of these document types.
|
- Not Document types: Filter documents without any of these document types.
|
||||||
- Correspondent: Filter documents with this correspondent.
|
- Correspondent: Filter documents with this correspondent.
|
||||||
- Not Correspondents: Filter documents without any of these correspondents.
|
- Not Correspondents: Filter documents without any of these correspondents.
|
||||||
- Storage path: Filter documents with this storage path.
|
- Storage path: Filter documents with this storage path.
|
||||||
- Not Storage paths: Filter documents without any of these storage paths.
|
- Not Storage paths: Filter documents without any of these storage paths.
|
||||||
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
@@ -543,37 +544,37 @@ The following workflow action types are available:
|
|||||||
|
|
||||||
"Assignment" actions can assign:
|
"Assignment" actions can assign:
|
||||||
|
|
||||||
- Title, see [workflow placeholders](usage.md#workflow-placeholders) below
|
- Title, see [workflow placeholders](usage.md#workflow-placeholders) below
|
||||||
- Tags, correspondent, document type and storage path
|
- Tags, correspondent, document type and storage path
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
- Custom fields. Note that no value for the field will be set
|
- Custom fields. Note that no value for the field will be set
|
||||||
|
|
||||||
##### Removal {#workflow-action-removal}
|
##### Removal {#workflow-action-removal}
|
||||||
|
|
||||||
"Removal" actions can remove either all of or specific sets of the following:
|
"Removal" actions can remove either all of or specific sets of the following:
|
||||||
|
|
||||||
- Tags, correspondents, document types or storage paths
|
- Tags, correspondents, document types or storage paths
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions
|
- View and / or edit permissions
|
||||||
- Custom fields
|
- Custom fields
|
||||||
|
|
||||||
##### Email {#workflow-action-email}
|
##### Email {#workflow-action-email}
|
||||||
|
|
||||||
"Email" actions can send documents via email. This action requires a mail server to be [configured](configuration.md#email-sending). You can specify:
|
"Email" actions can send documents via email. This action requires a mail server to be [configured](configuration.md#email-sending). You can specify:
|
||||||
|
|
||||||
- The recipient email address(es) separated by commas
|
- The recipient email address(es) separated by commas
|
||||||
- The subject and body of the email, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below
|
- The subject and body of the email, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below
|
||||||
- Whether to include the document as an attachment
|
- Whether to include the document as an attachment
|
||||||
|
|
||||||
##### Webhook {#workflow-action-webhook}
|
##### Webhook {#workflow-action-webhook}
|
||||||
|
|
||||||
"Webhook" actions send a POST request to a specified URL. You can specify:
|
"Webhook" actions send a POST request to a specified URL. You can specify:
|
||||||
|
|
||||||
- The URL to send the request to
|
- The URL to send the request to
|
||||||
- The request body as text or as key-value pairs, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below.
|
- The request body as text or as key-value pairs, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below.
|
||||||
- Encoding for the request body, either JSON or form data
|
- Encoding for the request body, either JSON or form data
|
||||||
- The request headers as key-value pairs
|
- The request headers as key-value pairs
|
||||||
|
|
||||||
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
|
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
|
||||||
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
||||||
@@ -604,33 +605,33 @@ 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
|
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||||
applied. You can use the following placeholders in the template with any trigger type:
|
applied. You can use the following placeholders in the template with any trigger type:
|
||||||
|
|
||||||
- `{{correspondent}}`: assigned correspondent name
|
- `{{correspondent}}`: assigned correspondent name
|
||||||
- `{{document_type}}`: assigned document type name
|
- `{{document_type}}`: assigned document type name
|
||||||
- `{{owner_username}}`: assigned owner username
|
- `{{owner_username}}`: assigned owner username
|
||||||
- `{{added}}`: added datetime
|
- `{{added}}`: added datetime
|
||||||
- `{{added_year}}`: added year
|
- `{{added_year}}`: added year
|
||||||
- `{{added_year_short}}`: added year
|
- `{{added_year_short}}`: added year
|
||||||
- `{{added_month}}`: added month
|
- `{{added_month}}`: added month
|
||||||
- `{{added_month_name}}`: added month name
|
- `{{added_month_name}}`: added month name
|
||||||
- `{{added_month_name_short}}`: added month short name
|
- `{{added_month_name_short}}`: added month short name
|
||||||
- `{{added_day}}`: added day
|
- `{{added_day}}`: added day
|
||||||
- `{{added_time}}`: added time in HH:MM format
|
- `{{added_time}}`: added time in HH:MM format
|
||||||
- `{{original_filename}}`: original file name without extension
|
- `{{original_filename}}`: original file name without extension
|
||||||
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
||||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
- `{{created}}`: created datetime
|
- `{{created}}`: created datetime
|
||||||
- `{{created_year}}`: created year
|
- `{{created_year}}`: created year
|
||||||
- `{{created_year_short}}`: created year
|
- `{{created_year_short}}`: created year
|
||||||
- `{{created_month}}`: created month
|
- `{{created_month}}`: created month
|
||||||
- `{{created_month_name}}`: created month name
|
- `{{created_month_name}}`: created month name
|
||||||
- `{{created_month_name_short}}`: created month short name
|
- `{{created_month_name_short}}`: created month short name
|
||||||
- `{{created_day}}`: created day
|
- `{{created_day}}`: created day
|
||||||
- `{{created_time}}`: created time in HH:MM format
|
- `{{created_time}}`: created time in HH:MM format
|
||||||
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
||||||
- `{{doc_id}}`: Document ID
|
- `{{doc_id}}`: Document ID
|
||||||
|
|
||||||
##### Examples
|
##### Examples
|
||||||
|
|
||||||
@@ -675,26 +676,26 @@ Multiple fields may be attached to a document but the same field name cannot be
|
|||||||
|
|
||||||
The following custom field types are supported:
|
The following custom field types are supported:
|
||||||
|
|
||||||
- `Text`: any text
|
- `Text`: any text
|
||||||
- `Boolean`: true / false (check / unchecked) field
|
- `Boolean`: true / false (check / unchecked) field
|
||||||
- `Date`: date
|
- `Date`: date
|
||||||
- `URL`: a valid url
|
- `URL`: a valid url
|
||||||
- `Integer`: integer number e.g. 12
|
- `Integer`: integer number e.g. 12
|
||||||
- `Number`: float number e.g. 12.3456
|
- `Number`: float number e.g. 12.3456
|
||||||
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
||||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
- `Select`: a pre-defined list of strings from which the user can choose
|
- `Select`: a pre-defined list of strings from which the user can choose
|
||||||
|
|
||||||
## PDF Actions
|
## PDF Actions
|
||||||
|
|
||||||
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
||||||
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
|
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
|
||||||
|
|
||||||
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
||||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
|
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
|
||||||
- Splitting documents: via the pdf editor on an individual document's details page.
|
- Splitting documents: via the pdf editor on an individual document's details page.
|
||||||
- Deleting pages: via the pdf editor on an individual document's details page.
|
- Deleting pages: via the pdf editor on an individual document's details page.
|
||||||
- Re-arranging pages: via the pdf editor on an individual document's details page.
|
- Re-arranging pages: via the pdf editor on an individual document's details page.
|
||||||
|
|
||||||
!!! important
|
!!! important
|
||||||
|
|
||||||
@@ -772,18 +773,18 @@ the system.
|
|||||||
Here are a couple examples of tags and types that you could use in your
|
Here are a couple examples of tags and types that you could use in your
|
||||||
collection.
|
collection.
|
||||||
|
|
||||||
- An `inbox` tag for newly added documents that you haven't manually
|
- An `inbox` tag for newly added documents that you haven't manually
|
||||||
edited yet.
|
edited yet.
|
||||||
- A tag `car` for everything car related (repairs, registration,
|
- A tag `car` for everything car related (repairs, registration,
|
||||||
insurance, etc)
|
insurance, etc)
|
||||||
- A tag `todo` for documents that you still need to do something with,
|
- A tag `todo` for documents that you still need to do something with,
|
||||||
such as reply, or perform some task online.
|
such as reply, or perform some task online.
|
||||||
- A tag `bank account x` for all bank statement related to that
|
- A tag `bank account x` for all bank statement related to that
|
||||||
account.
|
account.
|
||||||
- A tag `mail` for anything that you added to paperless via its mail
|
- A tag `mail` for anything that you added to paperless via its mail
|
||||||
processing capabilities.
|
processing capabilities.
|
||||||
- A tag `missing_metadata` when you still need to add some metadata to
|
- A tag `missing_metadata` when you still need to add some metadata to
|
||||||
a document, but can't or don't want to do this right now.
|
a document, but can't or don't want to do this right now.
|
||||||
|
|
||||||
## Searching {#basic-usage_searching}
|
## Searching {#basic-usage_searching}
|
||||||
|
|
||||||
@@ -872,8 +873,8 @@ The following diagram shows how easy it is to manage your documents.
|
|||||||
|
|
||||||
### Preparations in paperless
|
### Preparations in paperless
|
||||||
|
|
||||||
- Create an inbox tag that gets assigned to all new documents.
|
- Create an inbox tag that gets assigned to all new documents.
|
||||||
- Create a TODO tag.
|
- Create a TODO tag.
|
||||||
|
|
||||||
### Processing of the physical documents
|
### Processing of the physical documents
|
||||||
|
|
||||||
@@ -947,15 +948,15 @@ Some documents require attention and require you to act on the document.
|
|||||||
You may take two different approaches to handle these documents based on
|
You may take two different approaches to handle these documents based on
|
||||||
how regularly you intend to scan documents and use paperless.
|
how regularly you intend to scan documents and use paperless.
|
||||||
|
|
||||||
- If you scan and process your documents in paperless regularly,
|
- If you scan and process your documents in paperless regularly,
|
||||||
assign a TODO tag to all scanned documents that you need to process.
|
assign a TODO tag to all scanned documents that you need to process.
|
||||||
Create a saved view on the dashboard that shows all documents with
|
Create a saved view on the dashboard that shows all documents with
|
||||||
this tag.
|
this tag.
|
||||||
- If you do not scan documents regularly and use paperless solely for
|
- If you do not scan documents regularly and use paperless solely for
|
||||||
archiving, create a physical todo box next to your physical inbox
|
archiving, create a physical todo box next to your physical inbox
|
||||||
and put documents you need to process in the TODO box. When you
|
and put documents you need to process in the TODO box. When you
|
||||||
performed the task associated with the document, move it to the
|
performed the task associated with the document, move it to the
|
||||||
inbox.
|
inbox.
|
||||||
|
|
||||||
## Remote OCR
|
## Remote OCR
|
||||||
|
|
||||||
@@ -976,63 +977,64 @@ or page limitations (e.g. with a free tier).
|
|||||||
|
|
||||||
Paperless-ngx consists of the following components:
|
Paperless-ngx consists of the following components:
|
||||||
|
|
||||||
- **The webserver:** This serves the administration pages, the API,
|
- **The webserver:** This serves the administration pages, the API,
|
||||||
and the new frontend. This is the main tool you'll be using to interact
|
and the new frontend. This is the main tool you'll be using to interact
|
||||||
with paperless. You may start the webserver directly with
|
with paperless. You may start the webserver directly with
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
cd /path/to/paperless/src/
|
cd /path/to/paperless/src/
|
||||||
granian --interface asginl --ws "paperless.asgi:application"
|
granian --interface asginl --ws "paperless.asgi:application"
|
||||||
```
|
```
|
||||||
|
|
||||||
or by any other means such as Apache `mod_wsgi`.
|
or by any other means such as Apache `mod_wsgi`.
|
||||||
|
|
||||||
- **The consumer:** This is what watches your consumption folder for
|
- **The consumer:** This is what watches your consumption folder for
|
||||||
documents. However, the consumer itself does not really consume your
|
documents. However, the consumer itself does not really consume your
|
||||||
documents. Now it notifies a task processor that a new file is ready
|
documents. Now it notifies a task processor that a new file is ready
|
||||||
for consumption. I suppose it should be named differently. This was
|
for consumption. I suppose it should be named differently. This was
|
||||||
also used to check your emails, but that's now done elsewhere as
|
also used to check your emails, but that's now done elsewhere as
|
||||||
well.
|
well.
|
||||||
|
|
||||||
Start the consumer with the management command `document_consumer`:
|
Start the consumer with the management command `document_consumer`:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
cd /path/to/paperless/src/
|
cd /path/to/paperless/src/
|
||||||
python3 manage.py document_consumer
|
python3 manage.py document_consumer
|
||||||
```
|
```
|
||||||
|
|
||||||
- **The task processor:** Paperless relies on [Celery - Distributed
|
- **The task processor:** Paperless relies on [Celery - Distributed
|
||||||
Task Queue](https://docs.celeryq.dev/en/stable/index.html) for doing
|
Task Queue](https://docs.celeryq.dev/en/stable/index.html) for doing
|
||||||
most of the heavy lifting. This is a task queue that accepts tasks
|
most of the heavy lifting. This is a task queue that accepts tasks
|
||||||
from multiple sources and processes these in parallel. It also comes
|
from multiple sources and processes these in parallel. It also comes
|
||||||
with a scheduler that executes certain commands periodically.
|
with a scheduler that executes certain commands periodically.
|
||||||
|
|
||||||
This task processor is responsible for:
|
This task processor is responsible for:
|
||||||
- Consuming documents. When the consumer finds new documents, it
|
|
||||||
notifies the task processor to start a consumption task.
|
|
||||||
- The task processor also performs the consumption of any
|
|
||||||
documents you upload through the web interface.
|
|
||||||
- Consuming emails. It periodically checks your configured
|
|
||||||
accounts for new emails and notifies the task processor to
|
|
||||||
consume the attachment of an email.
|
|
||||||
- Maintaining the search index and the automatic matching
|
|
||||||
algorithm. These are things that paperless needs to do from time
|
|
||||||
to time in order to operate properly.
|
|
||||||
|
|
||||||
This allows paperless to process multiple documents from your
|
- Consuming documents. When the consumer finds new documents, it
|
||||||
consumption folder in parallel! On a modern multi core system, this
|
notifies the task processor to start a consumption task.
|
||||||
makes the consumption process with full OCR blazingly fast.
|
- The task processor also performs the consumption of any
|
||||||
|
documents you upload through the web interface.
|
||||||
|
- Consuming emails. It periodically checks your configured
|
||||||
|
accounts for new emails and notifies the task processor to
|
||||||
|
consume the attachment of an email.
|
||||||
|
- Maintaining the search index and the automatic matching
|
||||||
|
algorithm. These are things that paperless needs to do from time
|
||||||
|
to time in order to operate properly.
|
||||||
|
|
||||||
The task processor comes with a built-in admin interface that you
|
This allows paperless to process multiple documents from your
|
||||||
can use to check whenever any of the tasks fail and inspect the
|
consumption folder in parallel! On a modern multi core system, this
|
||||||
errors (i.e., wrong email credentials, errors during consuming a
|
makes the consumption process with full OCR blazingly fast.
|
||||||
specific file, etc).
|
|
||||||
|
|
||||||
- A [redis](https://redis.io/) message broker: This is a really
|
The task processor comes with a built-in admin interface that you
|
||||||
lightweight service that is responsible for getting the tasks from
|
can use to check whenever any of the tasks fail and inspect the
|
||||||
the webserver and the consumer to the task scheduler. These run in a
|
errors (i.e., wrong email credentials, errors during consuming a
|
||||||
different process (maybe even on different machines!), and
|
specific file, etc).
|
||||||
therefore, this is necessary.
|
|
||||||
|
|
||||||
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
|
- A [redis](https://redis.io/) message broker: This is a really
|
||||||
and SQLite for storing its data.
|
lightweight service that is responsible for getting the tasks from
|
||||||
|
the webserver and the consumer to the task scheduler. These run in a
|
||||||
|
different process (maybe even on different machines!), and
|
||||||
|
therefore, this is necessary.
|
||||||
|
|
||||||
|
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
|
||||||
|
and SQLite for storing its data.
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ dependencies = [
|
|||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"djangorestframework-guardian~=0.4.0",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular~=0.28",
|
||||||
"drf-spectacular-sidecar~=2026.3.1",
|
"drf-spectacular-sidecar~=2026.1.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"faiss-cpu>=1.10",
|
"faiss-cpu>=1.10",
|
||||||
"filelock~=3.25.2",
|
"filelock~=3.20.3",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.13.1",
|
"gotenberg-client~=0.13.1",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.16",
|
||||||
@@ -72,7 +72,7 @@ dependencies = [
|
|||||||
"rapidfuzz~=3.14.0",
|
"rapidfuzz~=3.14.0",
|
||||||
"redis[hiredis]~=5.2.1",
|
"redis[hiredis]~=5.2.1",
|
||||||
"regex>=2025.9.18",
|
"regex>=2025.9.18",
|
||||||
"scikit-learn~=1.8.0",
|
"scikit-learn~=1.7.0",
|
||||||
"sentence-transformers>=4.1",
|
"sentence-transformers>=4.1",
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"tika-client~=0.10.0",
|
"tika-client~=0.10.0",
|
||||||
@@ -111,7 +111,7 @@ docs = [
|
|||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker~=40.8.0",
|
"faker~=40.5.1",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.0.0",
|
"pytest~=9.0.0",
|
||||||
"pytest-cov~=7.0.0",
|
"pytest-cov~=7.0.0",
|
||||||
|
|||||||
@@ -468,7 +468,7 @@
|
|||||||
"time": 0.951,
|
"time": 0.951,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -534,7 +534,7 @@
|
|||||||
"time": 0.653,
|
"time": 0.653,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
@@ -883,7 +883,7 @@
|
|||||||
"time": 0.93,
|
"time": 0.93,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
@@ -961,7 +961,7 @@
|
|||||||
"time": -1,
|
"time": -1,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ test('basic filtering', async ({ page }) => {
|
|||||||
await expect(page).toHaveURL(/tags__id__all=9/)
|
await expect(page).toHaveURL(/tags__id__all=9/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
await page.getByRole('button', { name: 'Document type' }).click()
|
await page.getByRole('button', { name: 'Document type' }).click()
|
||||||
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
|
await page.getByRole('menuitem', { name: 'Invoice Test 3' }).click()
|
||||||
await expect(page).toHaveURL(/document_type__id__in=1/)
|
await expect(page).toHaveURL(/document_type__id__in=1/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
||||||
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -31,8 +31,8 @@ export enum EditDialogMode {
|
|||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
export abstract class EditDialogComponent<
|
export abstract class EditDialogComponent<
|
||||||
T extends ObjectWithPermissions | ObjectWithId,
|
T extends ObjectWithPermissions | ObjectWithId,
|
||||||
>
|
>
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import { Subject, filter, takeUntil } from 'rxjs'
|
|||||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
import { SelectionDataItem } from 'src/app/data/results'
|
|
||||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||||
|
import { SelectionDataItem } from 'src/app/services/rest/document.service'
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
|
|||||||
@@ -1644,9 +1644,9 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(
|
expect(
|
||||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
;((component.document.mime_type =
|
;(component.document.mime_type =
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||||
fixture.detectChanges())
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Other
|
component.ContentRenderType.Other
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_tags: [101], remove_tags: [] },
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -332,7 +332,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -423,7 +423,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { correspondent: 101 },
|
parameters: { correspondent: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -455,7 +455,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -521,7 +521,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { document_type: 101 },
|
parameters: { document_type: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -553,7 +553,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -619,7 +619,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { storage_path: 101 },
|
parameters: { storage_path: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -651,7 +651,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -717,7 +717,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -749,7 +749,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -858,7 +858,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -951,7 +951,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -986,7 +986,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
source_mode: 'latest_version',
|
source_mode: 'latest_version',
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1027,7 +1027,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
metadata_document_id: 3,
|
metadata_document_id: 3,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1046,7 +1046,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
delete_originals: true,
|
delete_originals: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1067,7 +1067,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
archive_fallback: true,
|
archive_fallback: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1153,7 +1153,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1460,7 +1460,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
||||||
expect(listReloadSpy).toHaveBeenCalled()
|
expect(listReloadSpy).toHaveBeenCalled()
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
|||||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
import { CustomField } from 'src/app/data/custom-field'
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { SelectionDataItem } from 'src/app/data/results'
|
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
DocumentBulkEditMethod,
|
DocumentBulkEditMethod,
|
||||||
DocumentService,
|
DocumentService,
|
||||||
MergeDocumentsRequest,
|
MergeDocumentsRequest,
|
||||||
|
SelectionDataItem,
|
||||||
} from 'src/app/services/rest/document.service'
|
} from 'src/app/services/rest/document.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ import {
|
|||||||
FILTER_TITLE_CONTENT,
|
FILTER_TITLE_CONTENT,
|
||||||
NEGATIVE_NULL_FILTER_VALUE,
|
NEGATIVE_NULL_FILTER_VALUE,
|
||||||
} from 'src/app/data/filter-rule-type'
|
} from 'src/app/data/filter-rule-type'
|
||||||
import { SelectionData, SelectionDataItem } from 'src/app/data/results'
|
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
@@ -85,7 +84,11 @@ import {
|
|||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import {
|
||||||
|
DocumentService,
|
||||||
|
SelectionData,
|
||||||
|
SelectionDataItem,
|
||||||
|
} from 'src/app/services/rest/document.service'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Document } from './document'
|
|
||||||
|
|
||||||
export interface Results<T> {
|
export interface Results<T> {
|
||||||
count: number
|
count: number
|
||||||
|
|
||||||
@@ -7,20 +5,3 @@ export interface Results<T> {
|
|||||||
|
|
||||||
all: number[]
|
all: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectionDataItem {
|
|
||||||
id: number
|
|
||||||
document_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectionData {
|
|
||||||
selected_storage_paths: SelectionDataItem[]
|
|
||||||
selected_correspondents: SelectionDataItem[]
|
|
||||||
selected_tags: SelectionDataItem[]
|
|
||||||
selected_document_types: SelectionDataItem[]
|
|
||||||
selected_custom_fields: SelectionDataItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocumentResults extends Results<Document> {
|
|
||||||
selection_data?: SelectionData
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -126,10 +126,13 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.isReloading).toBeFalsy()
|
expect(documentListViewService.isReloading).toBeFalsy()
|
||||||
expect(documentListViewService.activeSavedViewId).toBeNull()
|
expect(documentListViewService.activeSavedViewId).toBeNull()
|
||||||
@@ -141,12 +144,12 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should handle error on page request out of range', () => {
|
it('should handle error on page request out of range', () => {
|
||||||
documentListViewService.currentPage = 50
|
documentListViewService.currentPage = 50
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush([], { status: 404, statusText: 'Unexpected error' })
|
req.flush([], { status: 404, statusText: 'Unexpected error' })
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
@@ -163,7 +166,7 @@ describe('DocumentListViewService', () => {
|
|||||||
]
|
]
|
||||||
documentListViewService.setFilterRules(filterRulesAny)
|
documentListViewService.setFilterRules(filterRulesAny)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(
|
req.flush(
|
||||||
@@ -171,13 +174,13 @@ describe('DocumentListViewService', () => {
|
|||||||
{ status: 404, statusText: 'Unexpected error' }
|
{ status: 404, statusText: 'Unexpected error' }
|
||||||
)
|
)
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,7 +188,7 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.currentPage = 1
|
documentListViewService.currentPage = 1
|
||||||
documentListViewService.sortField = 'custom_field_999'
|
documentListViewService.sortField = 'custom_field_999'
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(
|
req.flush(
|
||||||
@@ -194,7 +197,7 @@ describe('DocumentListViewService', () => {
|
|||||||
)
|
)
|
||||||
// resets itself
|
// resets itself
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,7 +212,7 @@ describe('DocumentListViewService', () => {
|
|||||||
]
|
]
|
||||||
documentListViewService.setFilterRules(filterRulesAny)
|
documentListViewService.setFilterRules(filterRulesAny)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
|
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
|
||||||
@@ -217,7 +220,7 @@ describe('DocumentListViewService', () => {
|
|||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,7 +229,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
documentListViewService.setSort('added', false)
|
documentListViewService.setSort('added', false)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.sortField).toEqual('added')
|
expect(documentListViewService.sortField).toEqual('added')
|
||||||
@@ -234,12 +237,12 @@ describe('DocumentListViewService', () => {
|
|||||||
|
|
||||||
documentListViewService.sortField = 'created'
|
documentListViewService.sortField = 'created'
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.sortField).toEqual('created')
|
expect(documentListViewService.sortField).toEqual('created')
|
||||||
documentListViewService.sortReverse = true
|
documentListViewService.sortReverse = true
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
@@ -259,7 +262,7 @@ describe('DocumentListViewService', () => {
|
|||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
|
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
|
||||||
documentListViewService.pageSize
|
documentListViewService.pageSize
|
||||||
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=true`
|
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.currentPage).toEqual(page)
|
expect(documentListViewService.currentPage).toEqual(page)
|
||||||
@@ -276,7 +279,7 @@ describe('DocumentListViewService', () => {
|
|||||||
}
|
}
|
||||||
documentListViewService.loadFromQueryParams(convertToParamMap(params))
|
documentListViewService.loadFromQueryParams(convertToParamMap(params))
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.filterRules).toEqual([
|
expect(documentListViewService.filterRules).toEqual([
|
||||||
@@ -286,12 +289,15 @@ describe('DocumentListViewService', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use filter rules to update query params', () => {
|
it('should use filter rules to update query params', () => {
|
||||||
documentListViewService.setFilterRules(filterRules)
|
documentListViewService.setFilterRules(filterRules)
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
@@ -300,26 +306,34 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.currentPage = 2
|
documentListViewService.currentPage = 2
|
||||||
let req = httpTestingController.expectOne((request) =>
|
let req = httpTestingController.expectOne((request) =>
|
||||||
request.urlWithParams.startsWith(
|
request.urlWithParams.startsWith(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
|
req.flush([])
|
||||||
|
|
||||||
documentListViewService.setFilterRules(filterRules, true)
|
documentListViewService.setFilterRules(filterRules, true)
|
||||||
|
|
||||||
const filteredReqs = httpTestingController.match(
|
const filteredReqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(filteredReqs).toHaveLength(1)
|
expect(filteredReqs).toHaveLength(1)
|
||||||
filteredReqs[0].flush(full_results)
|
filteredReqs[0].flush(full_results)
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
|
req.flush([])
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support quick filter', () => {
|
it('should support quick filter', () => {
|
||||||
documentListViewService.quickFilter(filterRules)
|
documentListViewService.quickFilter(filterRules)
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
@@ -342,21 +356,21 @@ describe('DocumentListViewService', () => {
|
|||||||
convertToParamMap(params)
|
convertToParamMap(params)
|
||||||
)
|
)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.currentPage = 1
|
documentListViewService.currentPage = 1
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true`
|
||||||
)
|
)
|
||||||
documentListViewService.sortField = 'created'
|
documentListViewService.sortField = 'created'
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
documentListViewService.activateSavedView(null)
|
documentListViewService.activateSavedView(null)
|
||||||
})
|
})
|
||||||
@@ -364,18 +378,21 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support navigating next / previous', () => {
|
it('should support navigating next / previous', () => {
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(`${environment.apiBaseUrl}documents/selection_data/`)
|
||||||
|
.flush([])
|
||||||
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
||||||
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
|
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
|
||||||
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
|
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
|
||||||
@@ -422,7 +439,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
jest
|
jest
|
||||||
.spyOn(documentListViewService, 'getLastPage')
|
.spyOn(documentListViewService, 'getLastPage')
|
||||||
@@ -437,7 +454,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect(documentListViewService.currentPage).toEqual(2)
|
expect(documentListViewService.currentPage).toEqual(2)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(reqs.length).toBeGreaterThan(0)
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -472,11 +489,11 @@ describe('DocumentListViewService', () => {
|
|||||||
.mockReturnValue(documents)
|
.mockReturnValue(documents)
|
||||||
documentListViewService.currentPage = 2
|
documentListViewService.currentPage = 2
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
|
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
|
||||||
documentListViewService.getPrevious(1).subscribe({
|
documentListViewService.getPrevious(1).subscribe({
|
||||||
@@ -486,7 +503,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(reqs.length).toBeGreaterThan(0)
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -499,10 +516,13 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select a document', () => {
|
it('should support select a document', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
@@ -524,13 +544,16 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select page', () => {
|
it('should support select page', () => {
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
documentListViewService.selectPage()
|
documentListViewService.selectPage()
|
||||||
expect(documentListViewService.selected.size).toEqual(3)
|
expect(documentListViewService.selected.size).toEqual(3)
|
||||||
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
|
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
|
||||||
@@ -539,10 +562,13 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select range', () => {
|
it('should support select range', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.selectRangeTo(documents[2])
|
documentListViewService.selectRangeTo(documents[2])
|
||||||
@@ -562,7 +588,7 @@ describe('DocumentListViewService', () => {
|
|||||||
|
|
||||||
documentListViewService.setFilterRules(filterRules)
|
documentListViewService.setFilterRules(filterRules)
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
|
||||||
@@ -578,7 +604,7 @@ describe('DocumentListViewService', () => {
|
|||||||
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
|
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
expect(cancelSpy).toHaveBeenCalled()
|
expect(cancelSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -597,7 +623,7 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
expect(documentListViewService.sortField).toEqual('created')
|
expect(documentListViewService.sortField).toEqual('created')
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -624,11 +650,11 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(localStorageSpy).toHaveBeenCalled()
|
expect(localStorageSpy).toHaveBeenCalled()
|
||||||
// reload triggered
|
// reload triggered
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
documentListViewService.displayFields = null
|
documentListViewService.displayFields = null
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.displayFields).toEqual(
|
expect(documentListViewService.displayFields).toEqual(
|
||||||
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
|
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
|
||||||
@@ -668,7 +694,7 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should generate quick filter URL preserving default state', () => {
|
it('should generate quick filter URL preserving default state', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
||||||
expect(urlTree).toBeDefined()
|
expect(urlTree).toBeDefined()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core'
|
import { Injectable, inject } from '@angular/core'
|
||||||
import { ParamMap, Router, UrlTree } from '@angular/router'
|
import { ParamMap, Router, UrlTree } from '@angular/router'
|
||||||
import { Observable, Subject, takeUntil } from 'rxjs'
|
import { Observable, Subject, first, takeUntil } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
DEFAULT_DISPLAY_FIELDS,
|
DEFAULT_DISPLAY_FIELDS,
|
||||||
DisplayField,
|
DisplayField,
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Document,
|
Document,
|
||||||
} from '../data/document'
|
} from '../data/document'
|
||||||
import { FilterRule } from '../data/filter-rule'
|
import { FilterRule } from '../data/filter-rule'
|
||||||
import { DocumentResults, SelectionData } from '../data/results'
|
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
|
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
|
||||||
import { SETTINGS_KEYS } from '../data/ui-settings'
|
import { SETTINGS_KEYS } from '../data/ui-settings'
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
isFullTextFilterRule,
|
isFullTextFilterRule,
|
||||||
} from '../utils/filter-rules'
|
} from '../utils/filter-rules'
|
||||||
import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
|
import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
|
||||||
import { DocumentService } from './rest/document.service'
|
import { DocumentService, SelectionData } from './rest/document.service'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
|
|
||||||
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
|
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
|
||||||
@@ -261,17 +260,27 @@ export class DocumentListViewService {
|
|||||||
activeListViewState.sortField,
|
activeListViewState.sortField,
|
||||||
activeListViewState.sortReverse,
|
activeListViewState.sortReverse,
|
||||||
activeListViewState.filterRules,
|
activeListViewState.filterRules,
|
||||||
{ truncate_content: true, include_selection_data: true }
|
{ truncate_content: true }
|
||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
const resultWithSelectionData = result as DocumentResults
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
this.isReloading = false
|
this.isReloading = false
|
||||||
activeListViewState.collectionSize = result.count
|
activeListViewState.collectionSize = result.count
|
||||||
activeListViewState.documents = result.results
|
activeListViewState.documents = result.results
|
||||||
this.selectionData = resultWithSelectionData.selection_data ?? null
|
|
||||||
|
this.documentService
|
||||||
|
.getSelectionData(result.all)
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe({
|
||||||
|
next: (selectionData) => {
|
||||||
|
this.selectionData = selectionData
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.selectionData = null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (updateQueryParams && !this._activeSavedViewId) {
|
if (updateQueryParams && !this._activeSavedViewId) {
|
||||||
let base = ['/documents']
|
let base = ['/documents']
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
import { FilterRule } from 'src/app/data/filter-rule'
|
import { FilterRule } from 'src/app/data/filter-rule'
|
||||||
import { Results, SelectionData } from 'src/app/data/results'
|
import { Results } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,19 @@ import { SettingsService } from '../settings.service'
|
|||||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||||
import { CustomFieldsService } from './custom-fields.service'
|
import { CustomFieldsService } from './custom-fields.service'
|
||||||
|
|
||||||
|
export interface SelectionDataItem {
|
||||||
|
id: number
|
||||||
|
document_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionData {
|
||||||
|
selected_storage_paths: SelectionDataItem[]
|
||||||
|
selected_correspondents: SelectionDataItem[]
|
||||||
|
selected_tags: SelectionDataItem[]
|
||||||
|
selected_document_types: SelectionDataItem[]
|
||||||
|
selected_custom_fields: SelectionDataItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export enum BulkEditSourceMode {
|
export enum BulkEditSourceMode {
|
||||||
LATEST_VERSION = 'latest_version',
|
LATEST_VERSION = 'latest_version',
|
||||||
EXPLICIT_SELECTION = 'explicit_selection',
|
EXPLICIT_SELECTION = 'explicit_selection',
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function hslToRgb(h, s, l) {
|
|||||||
* @return Array The HSL representation
|
* @return Array The HSL representation
|
||||||
*/
|
*/
|
||||||
export function rgbToHsl(r, g, b) {
|
export function rgbToHsl(r, g, b) {
|
||||||
;((r /= 255), (g /= 255), (b /= 255))
|
;(r /= 255), (g /= 255), (b /= 255)
|
||||||
var max = Math.max(r, g, b),
|
var max = Math.max(r, g, b),
|
||||||
min = Math.min(r, g, b)
|
min = Math.min(r, g, b)
|
||||||
var h,
|
var h,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from documents.plugins.base import AlwaysRunPluginMixin
|
|||||||
from documents.plugins.base import ConsumeTaskPlugin
|
from documents.plugins.base import ConsumeTaskPlugin
|
||||||
from documents.plugins.base import NoCleanupPluginMixin
|
from documents.plugins.base import NoCleanupPluginMixin
|
||||||
from documents.plugins.base import NoSetupPluginMixin
|
from documents.plugins.base import NoSetupPluginMixin
|
||||||
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
from documents.plugins.helpers import ProgressManager
|
from documents.plugins.helpers import ProgressManager
|
||||||
from documents.plugins.helpers import ProgressStatusOptions
|
from documents.plugins.helpers import ProgressStatusOptions
|
||||||
@@ -51,28 +52,11 @@ from documents.templating.workflows import parse_w_workflow_placeholders
|
|||||||
from documents.utils import copy_basic_file_stats
|
from documents.utils import copy_basic_file_stats
|
||||||
from documents.utils import copy_file_with_basic_stats
|
from documents.utils import copy_file_with_basic_stats
|
||||||
from documents.utils import run_subprocess
|
from documents.utils import run_subprocess
|
||||||
from paperless.parsers.text import TextDocumentParser
|
|
||||||
from paperless_mail.parsers import MailDocumentParser
|
from paperless_mail.parsers import MailDocumentParser
|
||||||
|
|
||||||
LOGGING_NAME: Final[str] = "paperless.consumer"
|
LOGGING_NAME: Final[str] = "paperless.consumer"
|
||||||
|
|
||||||
|
|
||||||
def _parser_cleanup(parser: DocumentParser) -> None:
|
|
||||||
"""
|
|
||||||
Call cleanup on a parser, handling the new-style context-manager parsers.
|
|
||||||
|
|
||||||
New-style parsers (e.g. TextDocumentParser) use __exit__ for teardown
|
|
||||||
instead of a cleanup() method. This shim will be removed once all existing parsers
|
|
||||||
have switched to the new style and this consumer is updated to use it
|
|
||||||
|
|
||||||
TODO(stumpylog): Remove me in the future
|
|
||||||
"""
|
|
||||||
if isinstance(parser, TextDocumentParser):
|
|
||||||
parser.__exit__(None, None, None)
|
|
||||||
else:
|
|
||||||
parser.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowTriggerPlugin(
|
class WorkflowTriggerPlugin(
|
||||||
NoCleanupPluginMixin,
|
NoCleanupPluginMixin,
|
||||||
NoSetupPluginMixin,
|
NoSetupPluginMixin,
|
||||||
@@ -476,9 +460,6 @@ class ConsumerPlugin(
|
|||||||
self.filename,
|
self.filename,
|
||||||
self.input_doc.mailrule_id,
|
self.input_doc.mailrule_id,
|
||||||
)
|
)
|
||||||
elif isinstance(document_parser, TextDocumentParser):
|
|
||||||
# TODO(stumpylog): Remove me in the future
|
|
||||||
document_parser.parse(self.working_copy, mime_type)
|
|
||||||
else:
|
else:
|
||||||
document_parser.parse(self.working_copy, mime_type, self.filename)
|
document_parser.parse(self.working_copy, mime_type, self.filename)
|
||||||
|
|
||||||
@@ -489,15 +470,11 @@ class ConsumerPlugin(
|
|||||||
ProgressStatusOptions.WORKING,
|
ProgressStatusOptions.WORKING,
|
||||||
ConsumerStatusShortMessage.GENERATING_THUMBNAIL,
|
ConsumerStatusShortMessage.GENERATING_THUMBNAIL,
|
||||||
)
|
)
|
||||||
if isinstance(document_parser, TextDocumentParser):
|
thumbnail = document_parser.get_thumbnail(
|
||||||
# TODO(stumpylog): Remove me in the future
|
self.working_copy,
|
||||||
thumbnail = document_parser.get_thumbnail(self.working_copy, mime_type)
|
mime_type,
|
||||||
else:
|
self.filename,
|
||||||
thumbnail = document_parser.get_thumbnail(
|
)
|
||||||
self.working_copy,
|
|
||||||
mime_type,
|
|
||||||
self.filename,
|
|
||||||
)
|
|
||||||
|
|
||||||
text = document_parser.get_text()
|
text = document_parser.get_text()
|
||||||
date = document_parser.get_date()
|
date = document_parser.get_date()
|
||||||
@@ -514,7 +491,7 @@ class ConsumerPlugin(
|
|||||||
page_count = document_parser.get_page_count(self.working_copy, mime_type)
|
page_count = document_parser.get_page_count(self.working_copy, mime_type)
|
||||||
|
|
||||||
except ParseError as e:
|
except ParseError as e:
|
||||||
_parser_cleanup(document_parser)
|
document_parser.cleanup()
|
||||||
if tempdir:
|
if tempdir:
|
||||||
tempdir.cleanup()
|
tempdir.cleanup()
|
||||||
self._fail(
|
self._fail(
|
||||||
@@ -524,7 +501,7 @@ class ConsumerPlugin(
|
|||||||
exception=e,
|
exception=e,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_parser_cleanup(document_parser)
|
document_parser.cleanup()
|
||||||
if tempdir:
|
if tempdir:
|
||||||
tempdir.cleanup()
|
tempdir.cleanup()
|
||||||
self._fail(
|
self._fail(
|
||||||
@@ -726,7 +703,7 @@ class ConsumerPlugin(
|
|||||||
exception=e,
|
exception=e,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_parser_cleanup(document_parser)
|
document_parser.cleanup()
|
||||||
tempdir.cleanup()
|
tempdir.cleanup()
|
||||||
|
|
||||||
self.run_post_consume_script(document)
|
self.run_post_consume_script(document)
|
||||||
@@ -984,10 +961,14 @@ class ConsumerPreflightPlugin(
|
|||||||
)
|
)
|
||||||
failure_msg += " Note: existing document is in the trash."
|
failure_msg += " Note: existing document is in the trash."
|
||||||
|
|
||||||
self._fail(
|
self._send_progress(
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
ProgressStatusOptions.FAILED,
|
||||||
status_msg,
|
status_msg,
|
||||||
failure_msg,
|
|
||||||
)
|
)
|
||||||
|
self.log.error(failure_msg)
|
||||||
|
raise StopConsumeTaskError(failure_msg)
|
||||||
|
|
||||||
def pre_check_directories(self) -> None:
|
def pre_check_directories(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ def _process_document(doc_id: int) -> None:
|
|||||||
)
|
)
|
||||||
shutil.move(thumb, document.thumbnail_path)
|
shutil.move(thumb, document.thumbnail_path)
|
||||||
finally:
|
finally:
|
||||||
# TODO(stumpylog): Cleanup once all parsers are handled
|
|
||||||
parser.cleanup()
|
parser.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -399,7 +399,6 @@ def update_document_content_maybe_archive_file(document_id) -> None:
|
|||||||
f"Error while parsing document {document} (ID: {document_id})",
|
f"Error while parsing document {document} (ID: {document_id})",
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# TODO(stumpylog): Cleanup once all parsers are handled
|
|
||||||
parser.cleanup()
|
parser.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1144,56 +1144,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(len(response.data["all"]), 50)
|
self.assertEqual(len(response.data["all"]), 50)
|
||||||
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
||||||
|
|
||||||
def test_list_with_include_selection_data(self) -> None:
|
|
||||||
correspondent = Correspondent.objects.create(name="c1")
|
|
||||||
doc_type = DocumentType.objects.create(name="dt1")
|
|
||||||
storage_path = StoragePath.objects.create(name="sp1")
|
|
||||||
tag = Tag.objects.create(name="tag")
|
|
||||||
|
|
||||||
matching_doc = Document.objects.create(
|
|
||||||
checksum="A",
|
|
||||||
correspondent=correspondent,
|
|
||||||
document_type=doc_type,
|
|
||||||
storage_path=storage_path,
|
|
||||||
)
|
|
||||||
matching_doc.tags.add(tag)
|
|
||||||
|
|
||||||
non_matching_doc = Document.objects.create(checksum="B")
|
|
||||||
non_matching_doc.tags.add(Tag.objects.create(name="other"))
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/?tags__id__in={tag.id}&include_selection_data=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertIn("selection_data", response.data)
|
|
||||||
|
|
||||||
selected_correspondent = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_correspondents"]
|
|
||||||
if item["id"] == correspondent.id
|
|
||||||
)
|
|
||||||
selected_tag = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_tags"]
|
|
||||||
if item["id"] == tag.id
|
|
||||||
)
|
|
||||||
selected_type = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_document_types"]
|
|
||||||
if item["id"] == doc_type.id
|
|
||||||
)
|
|
||||||
selected_storage_path = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_storage_paths"]
|
|
||||||
if item["id"] == storage_path.id
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(selected_correspondent["document_count"], 1)
|
|
||||||
self.assertEqual(selected_tag["document_count"], 1)
|
|
||||||
self.assertEqual(selected_type["document_count"], 1)
|
|
||||||
self.assertEqual(selected_storage_path["document_count"], 1)
|
|
||||||
|
|
||||||
def test_statistics(self) -> None:
|
def test_statistics(self) -> None:
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
title="none1",
|
title="none1",
|
||||||
|
|||||||
@@ -89,46 +89,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(len(results), 0)
|
self.assertEqual(len(results), 0)
|
||||||
self.assertCountEqual(response.data["all"], [])
|
self.assertCountEqual(response.data["all"], [])
|
||||||
|
|
||||||
def test_search_with_include_selection_data(self) -> None:
|
|
||||||
correspondent = Correspondent.objects.create(name="c1")
|
|
||||||
doc_type = DocumentType.objects.create(name="dt1")
|
|
||||||
storage_path = StoragePath.objects.create(name="sp1")
|
|
||||||
tag = Tag.objects.create(name="tag")
|
|
||||||
|
|
||||||
matching_doc = Document.objects.create(
|
|
||||||
title="bank statement",
|
|
||||||
content="bank content",
|
|
||||||
checksum="A",
|
|
||||||
correspondent=correspondent,
|
|
||||||
document_type=doc_type,
|
|
||||||
storage_path=storage_path,
|
|
||||||
)
|
|
||||||
matching_doc.tags.add(tag)
|
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
|
||||||
index.update_document(writer, matching_doc)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
"/api/documents/?query=bank&include_selection_data=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertIn("selection_data", response.data)
|
|
||||||
|
|
||||||
selected_correspondent = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_correspondents"]
|
|
||||||
if item["id"] == correspondent.id
|
|
||||||
)
|
|
||||||
selected_tag = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_tags"]
|
|
||||||
if item["id"] == tag.id
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(selected_correspondent["document_count"], 1)
|
|
||||||
self.assertEqual(selected_tag["document_count"], 1)
|
|
||||||
|
|
||||||
def test_search_custom_field_ordering(self) -> None:
|
def test_search_custom_field_ordering(self) -> None:
|
||||||
custom_field = CustomField.objects.create(
|
custom_field = CustomField.objects.create(
|
||||||
name="Sortable field",
|
name="Sortable field",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from documents.models import StoragePath
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.parsers import DocumentParser
|
from documents.parsers import DocumentParser
|
||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
from documents.plugins.helpers import ProgressStatusOptions
|
from documents.plugins.helpers import ProgressStatusOptions
|
||||||
from documents.tasks import sanity_check
|
from documents.tasks import sanity_check
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
@@ -952,11 +953,11 @@ class TestConsumer(
|
|||||||
self.assertIsFile(dst)
|
self.assertIsFile(dst)
|
||||||
|
|
||||||
expected_message = (
|
expected_message = (
|
||||||
f"{dst.name}: Not consuming {dst.name}: "
|
f"Not consuming {dst.name}: "
|
||||||
f"It is a duplicate of {document.title} (#{document.pk})"
|
f"It is a duplicate of {document.title} (#{document.pk})"
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaisesMessage(ConsumerError, expected_message):
|
with self.assertRaisesMessage(StopConsumeTaskError, expected_message):
|
||||||
with self.get_consumer(dst) as consumer:
|
with self.get_consumer(dst) as consumer:
|
||||||
consumer.run()
|
consumer.run()
|
||||||
|
|
||||||
@@ -978,12 +979,12 @@ class TestConsumer(
|
|||||||
self.assertIsFile(dst)
|
self.assertIsFile(dst)
|
||||||
|
|
||||||
expected_message = (
|
expected_message = (
|
||||||
f"{dst.name}: Not consuming {dst.name}: "
|
f"Not consuming {dst.name}: "
|
||||||
f"It is a duplicate of {document.title} (#{document.pk})"
|
f"It is a duplicate of {document.title} (#{document.pk})"
|
||||||
f" Note: existing document is in the trash."
|
f" Note: existing document is in the trash."
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaisesMessage(ConsumerError, expected_message):
|
with self.assertRaisesMessage(StopConsumeTaskError, expected_message):
|
||||||
with self.get_consumer(dst) as consumer:
|
with self.get_consumer(dst) as consumer:
|
||||||
consumer.run()
|
consumer.run()
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from documents.parsers import get_default_file_extension
|
|||||||
from documents.parsers import get_parser_class_for_mime_type
|
from documents.parsers import get_parser_class_for_mime_type
|
||||||
from documents.parsers import get_supported_file_extensions
|
from documents.parsers import get_supported_file_extensions
|
||||||
from documents.parsers import is_file_ext_supported
|
from documents.parsers import is_file_ext_supported
|
||||||
from paperless.parsers.text import TextDocumentParser
|
|
||||||
from paperless_tesseract.parsers import RasterisedDocumentParser
|
from paperless_tesseract.parsers import RasterisedDocumentParser
|
||||||
|
from paperless_text.parsers import TextDocumentParser
|
||||||
from paperless_tika.parsers import TikaDocumentParser
|
from paperless_tika.parsers import TikaDocumentParser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -835,61 +835,6 @@ class DocumentViewSet(
|
|||||||
"custom_field_",
|
"custom_field_",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_selection_data_for_queryset(self, queryset):
|
|
||||||
correspondents = Correspondent.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
tags = Tag.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
document_types = DocumentType.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
storage_paths = StoragePath.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
custom_fields = CustomField.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"fields__document",
|
|
||||||
filter=Q(fields__document__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"selected_correspondents": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in correspondents
|
|
||||||
],
|
|
||||||
"selected_tags": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in tags
|
|
||||||
],
|
|
||||||
"selected_document_types": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in document_types
|
|
||||||
],
|
|
||||||
"selected_storage_paths": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in storage_paths
|
|
||||||
],
|
|
||||||
"selected_custom_fields": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in custom_fields
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
latest_version_content = Subquery(
|
latest_version_content = Subquery(
|
||||||
Document.objects.filter(root_document=OuterRef("pk"))
|
Document.objects.filter(root_document=OuterRef("pk"))
|
||||||
@@ -1037,25 +982,6 @@ class DocumentViewSet(
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
|
||||||
if not get_boolean(
|
|
||||||
str(request.query_params.get("include_selection_data", "false")),
|
|
||||||
):
|
|
||||||
return super().list(request, *args, **kwargs)
|
|
||||||
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
|
||||||
selection_data = self._get_selection_data_for_queryset(queryset)
|
|
||||||
|
|
||||||
page = self.paginate_queryset(queryset)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True)
|
|
||||||
response = self.get_paginated_response(serializer.data)
|
|
||||||
response.data["selection_data"] = selection_data
|
|
||||||
return response
|
|
||||||
|
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
|
||||||
return Response({"results": serializer.data, "selection_data": selection_data})
|
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
from documents import index
|
from documents import index
|
||||||
|
|
||||||
@@ -1602,17 +1528,13 @@ class DocumentViewSet(
|
|||||||
|
|
||||||
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
|
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
operation_id="documents_email_document",
|
|
||||||
deprecated=True,
|
|
||||||
)
|
|
||||||
@action(
|
@action(
|
||||||
methods=["post"],
|
methods=["post"],
|
||||||
detail=True,
|
detail=True,
|
||||||
url_path="email",
|
url_path="email",
|
||||||
permission_classes=[IsAuthenticated, ViewDocumentsPermissions],
|
permission_classes=[IsAuthenticated, ViewDocumentsPermissions],
|
||||||
)
|
)
|
||||||
# TODO: deprecated, remove with drop of support for API v9
|
# TODO: deprecated as of 2.19, remove in future release
|
||||||
def email_document(self, request, pk=None):
|
def email_document(self, request, pk=None):
|
||||||
request_data = request.data.copy()
|
request_data = request.data.copy()
|
||||||
request_data.setlist("documents", [pk])
|
request_data.setlist("documents", [pk])
|
||||||
@@ -2076,21 +1998,6 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
if get_boolean(
|
|
||||||
str(
|
|
||||||
request.query_params.get(
|
|
||||||
"include_selection_data",
|
|
||||||
"false",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
result_ids = response.data.get("all", [])
|
|
||||||
response.data["selection_data"] = (
|
|
||||||
self._get_selection_data_for_queryset(
|
|
||||||
Document.objects.filter(pk__in=result_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
except NotFound:
|
except NotFound:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-03-12 15:43+0000\n"
|
"POT-Creation-Date: 2026-03-10 23:46+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1339,7 +1339,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2556 documents/views.py:3565
|
#: documents/serialisers.py:2556 documents/views.py:3561
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1603,20 +1603,20 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:3577
|
#: documents/views.py:3573
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:3620
|
#: documents/views.py:3616
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:3677
|
#: documents/views.py:3673
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:3687
|
#: documents/views.py:3683
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
from celery.signals import worker_process_init
|
|
||||||
|
|
||||||
# Set the default Django settings module for the 'celery' program.
|
# Set the default Django settings module for the 'celery' program.
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
|
||||||
@@ -16,19 +15,3 @@ app.config_from_object("django.conf:settings", namespace="CELERY")
|
|||||||
|
|
||||||
# Load task modules from all registered Django apps.
|
# Load task modules from all registered Django apps.
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
|
|
||||||
|
|
||||||
@worker_process_init.connect
|
|
||||||
def on_worker_process_init(**kwargs) -> None: # pragma: no cover
|
|
||||||
"""
|
|
||||||
Register built-in parsers eagerly in each Celery worker process.
|
|
||||||
|
|
||||||
This registers only the built-in parsers (no entrypoint discovery) so
|
|
||||||
that workers can begin consuming documents immediately. Entrypoint
|
|
||||||
discovery for third-party parsers is deferred to the first call of
|
|
||||||
get_parser_registry() inside a task, keeping worker_process_init
|
|
||||||
well within its 4-second timeout budget.
|
|
||||||
"""
|
|
||||||
from paperless.parsers.registry import init_builtin_parsers
|
|
||||||
|
|
||||||
init_builtin_parsers()
|
|
||||||
|
|||||||
@@ -1,51 +1,62 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.exceptions import AcceptConnection
|
||||||
|
from channels.exceptions import DenyConnection
|
||||||
|
from channels.generic.websocket import WebsocketConsumer
|
||||||
|
|
||||||
|
|
||||||
class StatusConsumer(AsyncWebsocketConsumer):
|
class StatusConsumer(WebsocketConsumer):
|
||||||
def _authenticated(self) -> bool:
|
def _authenticated(self):
|
||||||
user: Any = self.scope.get("user")
|
return "user" in self.scope and self.scope["user"].is_authenticated
|
||||||
return user is not None and user.is_authenticated
|
|
||||||
|
|
||||||
async def _can_view(self, data: dict[str, Any]) -> bool:
|
def _can_view(self, data):
|
||||||
user: Any = self.scope.get("user")
|
user = self.scope.get("user") if self.scope.get("user") else None
|
||||||
if user is None:
|
|
||||||
return False
|
|
||||||
owner_id = data.get("owner_id")
|
owner_id = data.get("owner_id")
|
||||||
users_can_view = data.get("users_can_view", [])
|
users_can_view = data.get("users_can_view", [])
|
||||||
groups_can_view = data.get("groups_can_view", [])
|
groups_can_view = data.get("groups_can_view", [])
|
||||||
|
return (
|
||||||
|
user.is_superuser
|
||||||
|
or user.id == owner_id
|
||||||
|
or user.id in users_can_view
|
||||||
|
or any(
|
||||||
|
user.groups.filter(pk=group_id).exists() for group_id in groups_can_view
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if user.is_superuser or user.id == owner_id or user.id in users_can_view:
|
def connect(self):
|
||||||
return True
|
|
||||||
|
|
||||||
return await user.groups.filter(pk__in=groups_can_view).aexists()
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
if not self._authenticated():
|
if not self._authenticated():
|
||||||
await self.close()
|
raise DenyConnection
|
||||||
return
|
|
||||||
await self.channel_layer.group_add("status_updates", self.channel_name)
|
|
||||||
await self.accept()
|
|
||||||
|
|
||||||
async def disconnect(self, code: int) -> None:
|
|
||||||
await self.channel_layer.group_discard("status_updates", self.channel_name)
|
|
||||||
|
|
||||||
async def status_update(self, event: dict[str, Any]) -> None:
|
|
||||||
if not self._authenticated():
|
|
||||||
await self.close()
|
|
||||||
elif await self._can_view(event["data"]):
|
|
||||||
await self.send(json.dumps(event))
|
|
||||||
|
|
||||||
async def documents_deleted(self, event: dict[str, Any]) -> None:
|
|
||||||
if not self._authenticated():
|
|
||||||
await self.close()
|
|
||||||
else:
|
else:
|
||||||
await self.send(json.dumps(event))
|
async_to_sync(self.channel_layer.group_add)(
|
||||||
|
"status_updates",
|
||||||
|
self.channel_name,
|
||||||
|
)
|
||||||
|
raise AcceptConnection
|
||||||
|
|
||||||
async def document_updated(self, event: dict[str, Any]) -> None:
|
def disconnect(self, close_code) -> None:
|
||||||
|
async_to_sync(self.channel_layer.group_discard)(
|
||||||
|
"status_updates",
|
||||||
|
self.channel_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def status_update(self, event) -> None:
|
||||||
if not self._authenticated():
|
if not self._authenticated():
|
||||||
await self.close()
|
self.close()
|
||||||
elif await self._can_view(event["data"]):
|
else:
|
||||||
await self.send(json.dumps(event))
|
if self._can_view(event["data"]):
|
||||||
|
self.send(json.dumps(event))
|
||||||
|
|
||||||
|
def documents_deleted(self, event) -> None:
|
||||||
|
if not self._authenticated():
|
||||||
|
self.close()
|
||||||
|
else:
|
||||||
|
self.send(json.dumps(event))
|
||||||
|
|
||||||
|
def document_updated(self, event: Any) -> None:
|
||||||
|
if not self._authenticated():
|
||||||
|
self.close()
|
||||||
|
else:
|
||||||
|
if self._can_view(event["data"]):
|
||||||
|
self.send(json.dumps(event))
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
"""
|
|
||||||
Public interface for the Paperless-ngx parser plugin system.
|
|
||||||
|
|
||||||
This module defines ParserProtocol — the structural contract that every
|
|
||||||
document parser must satisfy, whether it is a built-in parser shipped with
|
|
||||||
Paperless-ngx or a third-party parser installed via a Python entrypoint.
|
|
||||||
|
|
||||||
Phase 1/2 scope: only the Protocol is defined here. The transitional
|
|
||||||
DocumentParser ABC (Phase 3) and concrete built-in parsers (Phase 3+) will
|
|
||||||
be added in later phases, so there are intentionally no imports of parser
|
|
||||||
implementations here.
|
|
||||||
|
|
||||||
Usage example (third-party parser)::
|
|
||||||
|
|
||||||
from paperless.parsers import ParserProtocol
|
|
||||||
|
|
||||||
class MyParser:
|
|
||||||
name = "my-parser"
|
|
||||||
version = "1.0.0"
|
|
||||||
author = "Acme Corp"
|
|
||||||
url = "https://example.com/my-parser"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls) -> dict[str, str]:
|
|
||||||
return {"application/x-my-format": ".myf"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 10
|
|
||||||
|
|
||||||
# … implement remaining protocol methods …
|
|
||||||
|
|
||||||
assert isinstance(MyParser(), ParserProtocol)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Protocol
|
|
||||||
from typing import Self
|
|
||||||
from typing import TypedDict
|
|
||||||
from typing import runtime_checkable
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from types import TracebackType
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"MetadataEntry",
|
|
||||||
"ParserProtocol",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class MetadataEntry(TypedDict):
|
|
||||||
"""A single metadata field extracted from a document.
|
|
||||||
|
|
||||||
All four keys are required. Values are always serialised to strings —
|
|
||||||
type-specific conversion (dates, integers, lists) is the responsibility
|
|
||||||
of the parser before returning.
|
|
||||||
"""
|
|
||||||
|
|
||||||
namespace: str
|
|
||||||
"""URI of the metadata namespace (e.g. 'http://ns.adobe.com/pdf/1.3/')."""
|
|
||||||
|
|
||||||
prefix: str
|
|
||||||
"""Conventional namespace prefix (e.g. 'pdf', 'xmp', 'dc')."""
|
|
||||||
|
|
||||||
key: str
|
|
||||||
"""Field name within the namespace (e.g. 'Author', 'CreateDate')."""
|
|
||||||
|
|
||||||
value: str
|
|
||||||
"""String representation of the field value."""
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class ParserProtocol(Protocol):
|
|
||||||
"""Structural contract for all Paperless-ngx document parsers.
|
|
||||||
|
|
||||||
Both built-in parsers and third-party plugins (discovered via the
|
|
||||||
"paperless_ngx.parsers" entrypoint group) must satisfy this Protocol.
|
|
||||||
Because it is decorated with runtime_checkable, isinstance(obj,
|
|
||||||
ParserProtocol) works at runtime based on method presence, which is
|
|
||||||
useful for validation in ParserRegistry.discover.
|
|
||||||
|
|
||||||
Parsers must expose four string attributes at the class level so the
|
|
||||||
registry can log attribution information without instantiating the parser:
|
|
||||||
|
|
||||||
name : str
|
|
||||||
Human-readable parser name (e.g. "Tesseract OCR").
|
|
||||||
version : str
|
|
||||||
Semantic version string (e.g. "1.2.3").
|
|
||||||
author : str
|
|
||||||
Author or organisation name.
|
|
||||||
url : str
|
|
||||||
URL for documentation, source code, or issue tracker.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Class-level identity (checked by the registry, not Protocol methods)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
name: str
|
|
||||||
version: str
|
|
||||||
author: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Class methods
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls) -> dict[str, str]:
|
|
||||||
"""Return a mapping of supported MIME types to preferred file extensions.
|
|
||||||
|
|
||||||
The keys are MIME type strings (e.g. "application/pdf"), and the
|
|
||||||
values are the preferred file extension including the leading dot
|
|
||||||
(e.g. ".pdf"). The registry uses this mapping both to decide whether
|
|
||||||
a parser is a candidate for a given file and to determine the default
|
|
||||||
extension when creating archive copies.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
dict[str, str]
|
|
||||||
{mime_type: extension} mapping — may be empty if the parser
|
|
||||||
has been temporarily disabled.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(
|
|
||||||
cls,
|
|
||||||
mime_type: str,
|
|
||||||
filename: str,
|
|
||||||
path: Path | None = None,
|
|
||||||
) -> int | None:
|
|
||||||
"""Return a priority score for handling this file, or None to decline.
|
|
||||||
|
|
||||||
The registry calls this after confirming that the MIME type is in
|
|
||||||
supported_mime_types. Parsers may inspect filename and optionally
|
|
||||||
the file at path to refine their confidence level.
|
|
||||||
|
|
||||||
A higher score wins. Return None to explicitly decline handling a file
|
|
||||||
even though the MIME type is listed as supported (e.g. when a feature
|
|
||||||
flag is disabled, or a required service is not configured).
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
mime_type:
|
|
||||||
The detected MIME type of the file to be parsed.
|
|
||||||
filename:
|
|
||||||
The original filename, including extension.
|
|
||||||
path:
|
|
||||||
Optional filesystem path to the file. Parsers that need to
|
|
||||||
inspect file content (e.g. magic-byte sniffing) may use this.
|
|
||||||
May be None when scoring happens before the file is available locally.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
int | None
|
|
||||||
Priority score (higher wins), or None to decline.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Properties
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def can_produce_archive(self) -> bool:
|
|
||||||
"""Whether this parser can produce a searchable PDF archive copy.
|
|
||||||
|
|
||||||
If True, the consumption pipeline may request an archive version when
|
|
||||||
processing the document, subject to the ARCHIVE_FILE_GENERATION
|
|
||||||
setting. If False, only thumbnail and text extraction are performed.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def requires_pdf_rendition(self) -> bool:
|
|
||||||
"""Whether the parser must produce a PDF for the frontend to display.
|
|
||||||
|
|
||||||
True for formats the browser cannot display natively (e.g. DOCX, ODT).
|
|
||||||
When True, the pipeline always stores the PDF output regardless of the
|
|
||||||
ARCHIVE_FILE_GENERATION setting, since the original format cannot be
|
|
||||||
shown to the user.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Core parsing interface
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def parse(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
*,
|
|
||||||
produce_archive: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""Parse document_path and populate internal state.
|
|
||||||
|
|
||||||
After a successful call, callers retrieve results via get_text,
|
|
||||||
get_date, and get_archive_path.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
document_path:
|
|
||||||
Absolute path to the document file to parse.
|
|
||||||
mime_type:
|
|
||||||
Detected MIME type of the document.
|
|
||||||
produce_archive:
|
|
||||||
When True (the default) and can_produce_archive is also True,
|
|
||||||
the parser should produce a searchable PDF at the path returned
|
|
||||||
by get_archive_path. Pass False when only text extraction and
|
|
||||||
thumbnail generation are required and disk I/O should be minimised.
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
documents.parsers.ParseError
|
|
||||||
If parsing fails for any reason.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Result accessors
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_text(self) -> str | None:
|
|
||||||
"""Return the plain-text content extracted during parse.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
str | None
|
|
||||||
Extracted text, or None if no text could be found.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_date(self) -> datetime.datetime | None:
|
|
||||||
"""Return the document date detected during parse.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
datetime.datetime | None
|
|
||||||
Detected document date, or None if no date was found.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_archive_path(self) -> Path | None:
|
|
||||||
"""Return the path to the generated archive PDF, or None.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path | None
|
|
||||||
Path to the searchable PDF archive, or None if no archive was
|
|
||||||
produced (e.g. because produce_archive=False or the parser does
|
|
||||||
not support archive generation).
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Thumbnail and metadata
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
|
|
||||||
"""Generate and return the path to a thumbnail image for the document.
|
|
||||||
|
|
||||||
May be called independently of parse. The returned path must point to
|
|
||||||
an existing WebP image file inside the parser's temporary working
|
|
||||||
directory.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
document_path:
|
|
||||||
Absolute path to the source document.
|
|
||||||
mime_type:
|
|
||||||
Detected MIME type of the document.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Path to the generated thumbnail image (WebP format preferred).
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_page_count(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
) -> int | None:
|
|
||||||
"""Return the number of pages in the document, if determinable.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
document_path:
|
|
||||||
Absolute path to the source document.
|
|
||||||
mime_type:
|
|
||||||
Detected MIME type of the document.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
int | None
|
|
||||||
Page count, or None if the parser cannot determine it.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def extract_metadata(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
) -> list[MetadataEntry]:
|
|
||||||
"""Extract format-specific metadata from the document.
|
|
||||||
|
|
||||||
Called by the API view layer on demand — not during the consumption
|
|
||||||
pipeline. Results are returned to the frontend for per-file display.
|
|
||||||
|
|
||||||
For documents with an archive version, this method is called twice:
|
|
||||||
once for the original file (with its native MIME type) and once for
|
|
||||||
the archive file (with ``"application/pdf"``). Parsers that produce
|
|
||||||
archives should handle both cases.
|
|
||||||
|
|
||||||
Implementations must not raise. A failure to read metadata is not
|
|
||||||
fatal — log a warning and return whatever partial results were
|
|
||||||
collected, or ``[]`` if none.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
document_path:
|
|
||||||
Absolute path to the file to extract metadata from.
|
|
||||||
mime_type:
|
|
||||||
MIME type of the file at ``document_path``. May be
|
|
||||||
``"application/pdf"`` when called for the archive version.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
list[MetadataEntry]
|
|
||||||
Zero or more metadata entries. Returns ``[]`` if no metadata
|
|
||||||
could be extracted or the format does not support it.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Context manager
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
|
||||||
"""Enter the parser context, returning the parser instance.
|
|
||||||
|
|
||||||
Implementations should perform any resource allocation here if not
|
|
||||||
done in __init__ (e.g. creating API clients or temp directories).
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Self
|
|
||||||
The parser instance itself.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def __exit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc_val: BaseException | None,
|
|
||||||
exc_tb: TracebackType | None,
|
|
||||||
) -> None:
|
|
||||||
"""Exit the parser context and release all resources.
|
|
||||||
|
|
||||||
Implementations must clean up all temporary files and other resources
|
|
||||||
regardless of whether an exception occurred.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
exc_type:
|
|
||||||
The exception class, or None if no exception was raised.
|
|
||||||
exc_val:
|
|
||||||
The exception instance, or None.
|
|
||||||
exc_tb:
|
|
||||||
The traceback, or None.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
"""
|
|
||||||
Singleton registry that tracks all document parsers available to
|
|
||||||
Paperless-ngx — both built-ins shipped with the application and third-party
|
|
||||||
plugins installed via Python entrypoints.
|
|
||||||
|
|
||||||
Public surface
|
|
||||||
--------------
|
|
||||||
get_parser_registry
|
|
||||||
Lazy-initialise and return the shared ParserRegistry. This is the primary
|
|
||||||
entry point for production code.
|
|
||||||
|
|
||||||
init_builtin_parsers
|
|
||||||
Register built-in parsers only, without entrypoint discovery. Safe to
|
|
||||||
call from Celery worker_process_init where importing all entrypoints
|
|
||||||
would be wasteful or cause side effects.
|
|
||||||
|
|
||||||
reset_parser_registry
|
|
||||||
Reset module-level state. For tests only.
|
|
||||||
|
|
||||||
Entrypoint group
|
|
||||||
----------------
|
|
||||||
Third-party parsers must advertise themselves under the
|
|
||||||
"paperless_ngx.parsers" entrypoint group in their pyproject.toml::
|
|
||||||
|
|
||||||
[project.entry-points."paperless_ngx.parsers"]
|
|
||||||
my_parser = "my_package.parsers:MyParser"
|
|
||||||
|
|
||||||
The loaded class must expose the following attributes at the class level
|
|
||||||
(not just on instances) for the registry to accept it:
|
|
||||||
name, version, author, url, supported_mime_types (callable), score (callable).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from importlib.metadata import entry_points
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from paperless.parsers import ParserProtocol
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.parsers.registry")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module-level singleton state
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_registry: ParserRegistry | None = None
|
|
||||||
_discovery_complete: bool = False
|
|
||||||
|
|
||||||
# Attribute names that every registered external parser class must expose.
|
|
||||||
_REQUIRED_ATTRS: tuple[str, ...] = (
|
|
||||||
"name",
|
|
||||||
"version",
|
|
||||||
"author",
|
|
||||||
"url",
|
|
||||||
"supported_mime_types",
|
|
||||||
"score",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module-level accessor functions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def get_parser_registry() -> ParserRegistry:
|
|
||||||
"""Return the shared ParserRegistry instance.
|
|
||||||
|
|
||||||
On the first call this function:
|
|
||||||
|
|
||||||
1. Creates a new ParserRegistry.
|
|
||||||
2. Calls register_defaults to install built-in parsers.
|
|
||||||
3. Calls discover to load third-party plugins via importlib.metadata entrypoints.
|
|
||||||
4. Calls log_summary to emit a startup summary.
|
|
||||||
|
|
||||||
Subsequent calls return the same instance immediately.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
ParserRegistry
|
|
||||||
The shared registry singleton.
|
|
||||||
"""
|
|
||||||
global _registry, _discovery_complete
|
|
||||||
|
|
||||||
if _registry is None:
|
|
||||||
_registry = ParserRegistry()
|
|
||||||
_registry.register_defaults()
|
|
||||||
|
|
||||||
if not _discovery_complete:
|
|
||||||
_registry.discover()
|
|
||||||
_registry.log_summary()
|
|
||||||
_discovery_complete = True
|
|
||||||
|
|
||||||
return _registry
|
|
||||||
|
|
||||||
|
|
||||||
def init_builtin_parsers() -> None:
|
|
||||||
"""Register built-in parsers without performing entrypoint discovery.
|
|
||||||
|
|
||||||
Intended for use in Celery worker_process_init handlers where importing
|
|
||||||
all installed entrypoints would be wasteful, slow, or could produce
|
|
||||||
undesirable side effects. Entrypoint discovery (third-party plugins) is
|
|
||||||
deliberately not performed.
|
|
||||||
|
|
||||||
Safe to call multiple times — subsequent calls are no-ops.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
global _registry
|
|
||||||
|
|
||||||
if _registry is None:
|
|
||||||
_registry = ParserRegistry()
|
|
||||||
_registry.register_defaults()
|
|
||||||
|
|
||||||
|
|
||||||
def reset_parser_registry() -> None:
|
|
||||||
"""Reset the module-level registry state to its initial values.
|
|
||||||
|
|
||||||
Resets _registry and _discovery_complete so the next call to
|
|
||||||
get_parser_registry will re-initialise everything from scratch.
|
|
||||||
|
|
||||||
FOR TESTS ONLY. Do not call this in production code — resetting the
|
|
||||||
registry mid-request causes all subsequent parser lookups to go through
|
|
||||||
discovery again, which is expensive and may have unexpected side effects
|
|
||||||
in multi-threaded environments.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
global _registry, _discovery_complete
|
|
||||||
|
|
||||||
_registry = None
|
|
||||||
_discovery_complete = False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Registry class
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class ParserRegistry:
|
|
||||||
"""Registry that maps MIME types to the best available parser class.
|
|
||||||
|
|
||||||
Parsers are partitioned into two lists:
|
|
||||||
|
|
||||||
_builtins
|
|
||||||
Parser classes registered via register_builtin (populated by
|
|
||||||
register_defaults in Phase 3+).
|
|
||||||
|
|
||||||
_external
|
|
||||||
Parser classes loaded from installed Python entrypoints via discover.
|
|
||||||
|
|
||||||
When resolving a parser for a file, external parsers are evaluated
|
|
||||||
alongside built-in parsers using a uniform scoring mechanism. Both lists
|
|
||||||
are iterated together; the class with the highest score wins. If an
|
|
||||||
external parser wins, its attribution details are logged so users can
|
|
||||||
identify which third-party package handled their document.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._external: list[type[ParserProtocol]] = []
|
|
||||||
self._builtins: list[type[ParserProtocol]] = []
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Registration
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def register_builtin(self, parser_class: type[ParserProtocol]) -> None:
|
|
||||||
"""Register a built-in parser class.
|
|
||||||
|
|
||||||
Built-in parsers are shipped with Paperless-ngx and are appended to
|
|
||||||
the _builtins list. They are never overridden by external parsers;
|
|
||||||
instead, scoring determines which parser wins for any given file.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
parser_class:
|
|
||||||
The parser class to register. Must satisfy ParserProtocol.
|
|
||||||
"""
|
|
||||||
self._builtins.append(parser_class)
|
|
||||||
|
|
||||||
def register_defaults(self) -> None:
|
|
||||||
"""Register the built-in parsers that ship with Paperless-ngx.
|
|
||||||
|
|
||||||
Each parser that has been migrated to the new ParserProtocol interface
|
|
||||||
is registered here. Parsers are added in ascending weight order so
|
|
||||||
that log output is predictable; scoring determines which parser wins
|
|
||||||
at runtime regardless of registration order.
|
|
||||||
"""
|
|
||||||
from paperless.parsers.text import TextDocumentParser
|
|
||||||
|
|
||||||
self.register_builtin(TextDocumentParser)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Discovery
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def discover(self) -> None:
|
|
||||||
"""Load third-party parsers from the "paperless_ngx.parsers" entrypoint group.
|
|
||||||
|
|
||||||
For each advertised entrypoint the method:
|
|
||||||
|
|
||||||
1. Calls ep.load() to import the class.
|
|
||||||
2. Validates that the class exposes all required attributes.
|
|
||||||
3. On success, appends the class to _external and logs an info message.
|
|
||||||
4. On failure (import error or missing attributes), logs an appropriate
|
|
||||||
warning/error and continues to the next entrypoint.
|
|
||||||
|
|
||||||
Errors during discovery of a single parser do not prevent other parsers
|
|
||||||
from being loaded.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
eps = entry_points(group="paperless_ngx.parsers")
|
|
||||||
|
|
||||||
for ep in eps:
|
|
||||||
try:
|
|
||||||
parser_class = ep.load()
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to load parser entrypoint '%s' — skipping.",
|
|
||||||
ep.name,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
missing = [
|
|
||||||
attr for attr in _REQUIRED_ATTRS if not hasattr(parser_class, attr)
|
|
||||||
]
|
|
||||||
if missing:
|
|
||||||
logger.warning(
|
|
||||||
"Parser loaded from entrypoint '%s' is missing required "
|
|
||||||
"attributes %r — skipping.",
|
|
||||||
ep.name,
|
|
||||||
missing,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
self._external.append(parser_class)
|
|
||||||
logger.info(
|
|
||||||
"Loaded third-party parser '%s' v%s by %s (entrypoint: '%s').",
|
|
||||||
parser_class.name,
|
|
||||||
parser_class.version,
|
|
||||||
parser_class.author,
|
|
||||||
ep.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Summary logging
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def log_summary(self) -> None:
|
|
||||||
"""Log a startup summary of all registered parsers.
|
|
||||||
|
|
||||||
Built-in parsers are listed first, followed by any external parsers
|
|
||||||
discovered from entrypoints. If no external parsers were found a
|
|
||||||
short informational message is logged instead of an empty list.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
"Built-in parsers (%d):",
|
|
||||||
len(self._builtins),
|
|
||||||
)
|
|
||||||
for cls in self._builtins:
|
|
||||||
logger.info(
|
|
||||||
" [built-in] %s v%s — %s",
|
|
||||||
getattr(cls, "name", repr(cls)),
|
|
||||||
getattr(cls, "version", "unknown"),
|
|
||||||
getattr(cls, "url", "built-in"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self._external:
|
|
||||||
logger.info("No third-party parsers discovered.")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Third-party parsers (%d):",
|
|
||||||
len(self._external),
|
|
||||||
)
|
|
||||||
for cls in self._external:
|
|
||||||
logger.info(
|
|
||||||
" [external] %s v%s by %s — report issues at %s",
|
|
||||||
getattr(cls, "name", repr(cls)),
|
|
||||||
getattr(cls, "version", "unknown"),
|
|
||||||
getattr(cls, "author", "unknown"),
|
|
||||||
getattr(cls, "url", "unknown"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Parser resolution
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_parser_for_file(
|
|
||||||
self,
|
|
||||||
mime_type: str,
|
|
||||||
filename: str,
|
|
||||||
path: Path | None = None,
|
|
||||||
) -> type[ParserProtocol] | None:
|
|
||||||
"""Return the best parser class for the given file, or None.
|
|
||||||
|
|
||||||
All registered parsers (external first, then built-ins) are evaluated
|
|
||||||
against the file. A parser is eligible if mime_type appears in the dict
|
|
||||||
returned by its supported_mime_types classmethod, and its score
|
|
||||||
classmethod returns a non-None integer.
|
|
||||||
|
|
||||||
The parser with the highest score wins. When two parsers return the
|
|
||||||
same score, the one that appears earlier in the evaluation order wins
|
|
||||||
(external parsers are evaluated before built-ins, giving third-party
|
|
||||||
packages a chance to override defaults at equal priority).
|
|
||||||
|
|
||||||
When an external parser is selected, its identity is logged at INFO
|
|
||||||
level so operators can trace which package handled a document.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
mime_type:
|
|
||||||
The detected MIME type of the file.
|
|
||||||
filename:
|
|
||||||
The original filename, including extension.
|
|
||||||
path:
|
|
||||||
Optional filesystem path to the file. Forwarded to each
|
|
||||||
parser's score method.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
type[ParserProtocol] | None
|
|
||||||
The winning parser class, or None if no parser can handle the file.
|
|
||||||
"""
|
|
||||||
best_score: int | None = None
|
|
||||||
best_parser: type[ParserProtocol] | None = None
|
|
||||||
|
|
||||||
# External parsers are placed first so that, at equal scores, an
|
|
||||||
# external parser wins over a built-in (first-seen policy).
|
|
||||||
for parser_class in (*self._external, *self._builtins):
|
|
||||||
if mime_type not in parser_class.supported_mime_types():
|
|
||||||
continue
|
|
||||||
|
|
||||||
score = parser_class.score(mime_type, filename, path)
|
|
||||||
if score is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if best_score is None or score > best_score:
|
|
||||||
best_score = score
|
|
||||||
best_parser = parser_class
|
|
||||||
|
|
||||||
if best_parser is not None and best_parser in self._external:
|
|
||||||
logger.info(
|
|
||||||
"Document handled by third-party parser '%s' v%s — %s",
|
|
||||||
getattr(best_parser, "name", repr(best_parser)),
|
|
||||||
getattr(best_parser, "version", "unknown"),
|
|
||||||
getattr(best_parser, "url", "unknown"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return best_parser
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
"""
|
|
||||||
Built-in plain-text document parser.
|
|
||||||
|
|
||||||
Handles text/plain, text/csv, and application/csv MIME types by reading the
|
|
||||||
file content directly. Thumbnails are generated by rendering a page-sized
|
|
||||||
WebP image from the first 100,000 characters using Pillow.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Self
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from PIL import Image
|
|
||||||
from PIL import ImageDraw
|
|
||||||
from PIL import ImageFont
|
|
||||||
|
|
||||||
from paperless.version import __full_version_str__
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import datetime
|
|
||||||
from types import TracebackType
|
|
||||||
|
|
||||||
from paperless.parsers import MetadataEntry
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.parsing.text")
|
|
||||||
|
|
||||||
_SUPPORTED_MIME_TYPES: dict[str, str] = {
|
|
||||||
"text/plain": ".txt",
|
|
||||||
"text/csv": ".csv",
|
|
||||||
"application/csv": ".csv",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TextDocumentParser:
|
|
||||||
"""Parse plain-text documents (txt, csv) for Paperless-ngx.
|
|
||||||
|
|
||||||
This parser reads the file content directly as UTF-8 text and renders a
|
|
||||||
simple thumbnail using Pillow. It does not perform OCR and does not
|
|
||||||
produce a searchable PDF archive copy.
|
|
||||||
|
|
||||||
Class attributes
|
|
||||||
----------------
|
|
||||||
name : str
|
|
||||||
Human-readable parser name.
|
|
||||||
version : str
|
|
||||||
Semantic version string, kept in sync with Paperless-ngx releases.
|
|
||||||
author : str
|
|
||||||
Maintainer name.
|
|
||||||
url : str
|
|
||||||
Issue tracker / source URL.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str = "Paperless-ngx Text Parser"
|
|
||||||
version: str = __full_version_str__
|
|
||||||
author: str = "Paperless-ngx Contributors"
|
|
||||||
url: str = "https://github.com/paperless-ngx/paperless-ngx"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Class methods
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls) -> dict[str, str]:
|
|
||||||
"""Return the MIME types this parser handles.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
dict[str, str]
|
|
||||||
Mapping of MIME type to preferred file extension.
|
|
||||||
"""
|
|
||||||
return _SUPPORTED_MIME_TYPES
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(
|
|
||||||
cls,
|
|
||||||
mime_type: str,
|
|
||||||
filename: str,
|
|
||||||
path: Path | None = None,
|
|
||||||
) -> int | None:
|
|
||||||
"""Return the priority score for handling this file.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
mime_type:
|
|
||||||
Detected MIME type of the file.
|
|
||||||
filename:
|
|
||||||
Original filename including extension.
|
|
||||||
path:
|
|
||||||
Optional filesystem path. Not inspected by this parser.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
int | None
|
|
||||||
10 if the MIME type is supported, otherwise None.
|
|
||||||
"""
|
|
||||||
if mime_type in _SUPPORTED_MIME_TYPES:
|
|
||||||
return 10
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Properties
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def can_produce_archive(self) -> bool:
|
|
||||||
"""Whether this parser can produce a searchable PDF archive copy.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
Always False — the text parser does not produce a PDF archive.
|
|
||||||
"""
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def requires_pdf_rendition(self) -> bool:
|
|
||||||
"""Whether the parser must produce a PDF for the frontend to display.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
Always False — plain text files are displayable as-is.
|
|
||||||
"""
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def __init__(self, logging_group: object = None) -> None:
|
|
||||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._tempdir = Path(
|
|
||||||
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR),
|
|
||||||
)
|
|
||||||
self._text: str | None = None
|
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc_val: BaseException | None,
|
|
||||||
exc_tb: TracebackType | None,
|
|
||||||
) -> None:
|
|
||||||
logger.debug("Cleaning up temporary directory %s", self._tempdir)
|
|
||||||
shutil.rmtree(self._tempdir, ignore_errors=True)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Core parsing interface
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def parse(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
*,
|
|
||||||
produce_archive: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""Read the document and store its text content.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
document_path:
|
|
||||||
Absolute path to the text file.
|
|
||||||
mime_type:
|
|
||||||
Detected MIME type of the document.
|
|
||||||
produce_archive:
|
|
||||||
Ignored — this parser never produces a PDF archive.
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
documents.parsers.ParseError
|
|
||||||
If the file cannot be read.
|
|
||||||
"""
|
|
||||||
self._text = self._read_text(document_path)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Result accessors
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_text(self) -> str | None:
|
|
||||||
"""Return the plain-text content extracted during parse.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
str | None
|
|
||||||
Extracted text, or None if parse has not been called yet.
|
|
||||||
"""
|
|
||||||
return self._text
|
|
||||||
|
|
||||||
def get_date(self) -> datetime.datetime | None:
|
|
||||||
"""Return the document date detected during parse.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
datetime.datetime | None
|
|
||||||
Always None — the text parser does not detect dates.
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_archive_path(self) -> Path | None:
|
|
||||||
"""Return the path to a generated archive PDF, or None.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path | None
|
|
||||||
Always None — the text parser does not produce a PDF archive.
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Thumbnail and metadata
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
|
|
||||||
"""Render the first portion of the document as a WebP thumbnail.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
document_path:
|
|
||||||
Absolute path to the source document.
|
|
||||||
mime_type:
|
|
||||||
Detected MIME type of the document.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Path to the generated WebP thumbnail inside the temporary directory.
|
|
||||||
"""
|
|
||||||
max_chars = 100_000
|
|
||||||
file_size_limit = 50 * 1024 * 1024
|
|
||||||
|
|
||||||
if document_path.stat().st_size > file_size_limit:
|
|
||||||
text = "[File too large to preview]"
|
|
||||||
else:
|
|
||||||
with Path(document_path).open("r", encoding="utf-8", errors="replace") as f:
|
|
||||||
text = f.read(max_chars)
|
|
||||||
|
|
||||||
img = Image.new("RGB", (500, 700), color="white")
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
font = ImageFont.truetype(
|
|
||||||
font=settings.THUMBNAIL_FONT_NAME,
|
|
||||||
size=20,
|
|
||||||
layout_engine=ImageFont.Layout.BASIC,
|
|
||||||
)
|
|
||||||
draw.multiline_text((5, 5), text, font=font, fill="black", spacing=4)
|
|
||||||
|
|
||||||
out_path = self._tempdir / "thumb.webp"
|
|
||||||
img.save(out_path, format="WEBP")
|
|
||||||
|
|
||||||
return out_path
|
|
||||||
|
|
||||||
def get_page_count(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
) -> int | None:
|
|
||||||
"""Return the number of pages in the document.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
document_path:
|
|
||||||
Absolute path to the source document.
|
|
||||||
mime_type:
|
|
||||||
Detected MIME type of the document.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
int | None
|
|
||||||
Always None — page count is not meaningful for plain text.
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def extract_metadata(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
) -> list[MetadataEntry]:
|
|
||||||
"""Extract format-specific metadata from the document.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
list[MetadataEntry]
|
|
||||||
Always ``[]`` — plain text files carry no structured metadata.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Private helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _read_text(self, filepath: Path) -> str:
|
|
||||||
"""Read file content, replacing invalid UTF-8 bytes rather than failing.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
filepath:
|
|
||||||
Path to the file to read.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
str
|
|
||||||
File content as a string.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return filepath.read_text(encoding="utf-8")
|
|
||||||
except UnicodeDecodeError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Unicode error reading %s, replacing bad bytes: %s",
|
|
||||||
filepath,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return filepath.read_bytes().decode("utf-8", errors="replace")
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""
|
|
||||||
Fixtures defined here are available to every test module under
|
|
||||||
src/paperless/tests/ (including sub-packages such as parsers/).
|
|
||||||
|
|
||||||
Session-scoped fixtures for the shared samples directory live here so
|
|
||||||
sub-package conftest files can reference them without duplicating path logic.
|
|
||||||
Parser-specific fixtures (concrete parser instances, format-specific sample
|
|
||||||
files) live in paperless/tests/parsers/conftest.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from paperless.parsers.registry import reset_parser_registry
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Generator
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def samples_dir() -> Path:
|
|
||||||
"""Absolute path to the shared parser sample files directory.
|
|
||||||
|
|
||||||
Sub-package conftest files derive format-specific paths from this root,
|
|
||||||
e.g. ``samples_dir / "text" / "test.txt"``.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Directory containing all sample documents used by parser tests.
|
|
||||||
"""
|
|
||||||
return (Path(__file__).parent / "samples").resolve()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def clean_registry() -> Generator[None, None, None]:
|
|
||||||
"""Reset the parser registry before and after every test.
|
|
||||||
|
|
||||||
This prevents registry state from leaking between tests that call
|
|
||||||
get_parser_registry() or init_builtin_parsers().
|
|
||||||
"""
|
|
||||||
reset_parser_registry()
|
|
||||||
yield
|
|
||||||
reset_parser_registry()
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""
|
|
||||||
Parser fixtures that are used across multiple test modules in this package
|
|
||||||
are defined here. Format-specific sample-file fixtures are grouped by parser
|
|
||||||
so it is easy to see which files belong to which test module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from paperless.parsers.text import TextDocumentParser
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Generator
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Text parser sample files
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def text_samples_dir(samples_dir: Path) -> Path:
|
|
||||||
"""Absolute path to the text parser sample files directory.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
``<samples_dir>/text/``
|
|
||||||
"""
|
|
||||||
return samples_dir / "text"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sample_txt_file(text_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a valid UTF-8 plain-text sample file.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``text/test.txt``.
|
|
||||||
"""
|
|
||||||
return text_samples_dir / "test.txt"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def malformed_txt_file(text_samples_dir: Path) -> Path:
|
|
||||||
"""Path to a text file containing invalid UTF-8 bytes.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Absolute path to ``text/decode_error.txt``.
|
|
||||||
"""
|
|
||||||
return text_samples_dir / "decode_error.txt"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Text parser instance
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def text_parser() -> Generator[TextDocumentParser, None, None]:
|
|
||||||
"""Yield a TextDocumentParser and clean up its temporary directory afterwards.
|
|
||||||
|
|
||||||
Yields
|
|
||||||
------
|
|
||||||
TextDocumentParser
|
|
||||||
A ready-to-use parser instance.
|
|
||||||
"""
|
|
||||||
with TextDocumentParser() as parser:
|
|
||||||
yield parser
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for paperless.parsers.text.TextDocumentParser.
|
|
||||||
|
|
||||||
All tests use the context-manager protocol for parser lifecycle. Sample
|
|
||||||
files are provided by session-scoped fixtures defined in conftest.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from paperless.parsers import ParserProtocol
|
|
||||||
from paperless.parsers.text import TextDocumentParser
|
|
||||||
|
|
||||||
|
|
||||||
class TestTextParserProtocol:
|
|
||||||
"""Verify that TextDocumentParser satisfies the ParserProtocol contract."""
|
|
||||||
|
|
||||||
def test_isinstance_satisfies_protocol(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
) -> None:
|
|
||||||
assert isinstance(text_parser, ParserProtocol)
|
|
||||||
|
|
||||||
def test_class_attributes_present(self) -> None:
|
|
||||||
assert isinstance(TextDocumentParser.name, str) and TextDocumentParser.name
|
|
||||||
assert (
|
|
||||||
isinstance(TextDocumentParser.version, str) and TextDocumentParser.version
|
|
||||||
)
|
|
||||||
assert isinstance(TextDocumentParser.author, str) and TextDocumentParser.author
|
|
||||||
assert isinstance(TextDocumentParser.url, str) and TextDocumentParser.url
|
|
||||||
|
|
||||||
def test_supported_mime_types_returns_dict(self) -> None:
|
|
||||||
mime_types = TextDocumentParser.supported_mime_types()
|
|
||||||
assert isinstance(mime_types, dict)
|
|
||||||
assert "text/plain" in mime_types
|
|
||||||
assert "text/csv" in mime_types
|
|
||||||
assert "application/csv" in mime_types
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("mime_type", "expected"),
|
|
||||||
[
|
|
||||||
("text/plain", 10),
|
|
||||||
("text/csv", 10),
|
|
||||||
("application/csv", 10),
|
|
||||||
("application/pdf", None),
|
|
||||||
("image/png", None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_score(self, mime_type: str, expected: int | None) -> None:
|
|
||||||
assert TextDocumentParser.score(mime_type, "file.txt") == expected
|
|
||||||
|
|
||||||
def test_can_produce_archive_is_false(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
) -> None:
|
|
||||||
assert text_parser.can_produce_archive is False
|
|
||||||
|
|
||||||
def test_requires_pdf_rendition_is_false(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
) -> None:
|
|
||||||
assert text_parser.requires_pdf_rendition is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestTextParserLifecycle:
|
|
||||||
"""Verify context-manager behaviour and temporary directory cleanup."""
|
|
||||||
|
|
||||||
def test_context_manager_cleans_up_tempdir(self) -> None:
|
|
||||||
with TextDocumentParser() as parser:
|
|
||||||
tempdir = parser._tempdir
|
|
||||||
assert tempdir.exists()
|
|
||||||
assert not tempdir.exists()
|
|
||||||
|
|
||||||
def test_context_manager_cleans_up_after_exception(self) -> None:
|
|
||||||
tempdir: Path | None = None
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
with TextDocumentParser() as parser:
|
|
||||||
tempdir = parser._tempdir
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
assert tempdir is not None
|
|
||||||
assert not tempdir.exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTextParserParse:
|
|
||||||
"""Verify parse() and the result accessors."""
|
|
||||||
|
|
||||||
def test_parse_valid_utf8(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
sample_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
text_parser.parse(sample_txt_file, "text/plain")
|
|
||||||
|
|
||||||
assert text_parser.get_text() == "This is a test file.\n"
|
|
||||||
|
|
||||||
def test_parse_returns_none_for_archive_path(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
sample_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
text_parser.parse(sample_txt_file, "text/plain")
|
|
||||||
|
|
||||||
assert text_parser.get_archive_path() is None
|
|
||||||
|
|
||||||
def test_parse_returns_none_for_date(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
sample_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
text_parser.parse(sample_txt_file, "text/plain")
|
|
||||||
|
|
||||||
assert text_parser.get_date() is None
|
|
||||||
|
|
||||||
def test_parse_invalid_utf8_bytes_replaced(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
malformed_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A text file containing invalid UTF-8 byte sequences
|
|
||||||
WHEN:
|
|
||||||
- The file is parsed
|
|
||||||
THEN:
|
|
||||||
- Parsing succeeds
|
|
||||||
- Invalid bytes are replaced with the Unicode replacement character
|
|
||||||
"""
|
|
||||||
text_parser.parse(malformed_txt_file, "text/plain")
|
|
||||||
|
|
||||||
assert text_parser.get_text() == "Pantothens\ufffdure\n"
|
|
||||||
|
|
||||||
def test_get_text_none_before_parse(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
) -> None:
|
|
||||||
assert text_parser.get_text() is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestTextParserThumbnail:
|
|
||||||
"""Verify thumbnail generation."""
|
|
||||||
|
|
||||||
def test_thumbnail_exists_and_is_file(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
sample_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
thumb = text_parser.get_thumbnail(sample_txt_file, "text/plain")
|
|
||||||
|
|
||||||
assert thumb.exists()
|
|
||||||
assert thumb.is_file()
|
|
||||||
|
|
||||||
def test_thumbnail_large_file_does_not_read_all(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A text file larger than 50 MB
|
|
||||||
WHEN:
|
|
||||||
- A thumbnail is requested
|
|
||||||
THEN:
|
|
||||||
- The thumbnail is generated without loading the full file
|
|
||||||
"""
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
delete=False,
|
|
||||||
mode="w",
|
|
||||||
encoding="utf-8",
|
|
||||||
suffix=".txt",
|
|
||||||
) as tmp:
|
|
||||||
tmp.write("A" * (51 * 1024 * 1024))
|
|
||||||
large_file = Path(tmp.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
thumb = text_parser.get_thumbnail(large_file, "text/plain")
|
|
||||||
assert thumb.exists()
|
|
||||||
assert thumb.is_file()
|
|
||||||
finally:
|
|
||||||
large_file.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def test_get_page_count_returns_none(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
sample_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
assert text_parser.get_page_count(sample_txt_file, "text/plain") is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestTextParserMetadata:
|
|
||||||
"""Verify extract_metadata behaviour."""
|
|
||||||
|
|
||||||
def test_extract_metadata_returns_empty_list(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
sample_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
result = text_parser.extract_metadata(sample_txt_file, "text/plain")
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_extract_metadata_returns_list_type(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
sample_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
result = text_parser.extract_metadata(sample_txt_file, "text/plain")
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
def test_extract_metadata_ignores_mime_type(
|
|
||||||
self,
|
|
||||||
text_parser: TextDocumentParser,
|
|
||||||
sample_txt_file: Path,
|
|
||||||
) -> None:
|
|
||||||
"""extract_metadata returns [] regardless of the mime_type argument."""
|
|
||||||
assert text_parser.extract_metadata(sample_txt_file, "application/pdf") == []
|
|
||||||
assert text_parser.extract_metadata(sample_txt_file, "text/csv") == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestTextParserRegistry:
|
|
||||||
"""Verify that TextDocumentParser is registered by default."""
|
|
||||||
|
|
||||||
def test_registered_in_defaults(self) -> None:
|
|
||||||
from paperless.parsers.registry import ParserRegistry
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_defaults()
|
|
||||||
|
|
||||||
assert TextDocumentParser in registry._builtins
|
|
||||||
|
|
||||||
def test_get_parser_for_text_plain(self) -> None:
|
|
||||||
from paperless.parsers.registry import get_parser_registry
|
|
||||||
|
|
||||||
registry = get_parser_registry()
|
|
||||||
parser_cls = registry.get_parser_for_file("text/plain", "doc.txt")
|
|
||||||
|
|
||||||
assert parser_cls is TextDocumentParser
|
|
||||||
|
|
||||||
def test_get_parser_for_text_csv(self) -> None:
|
|
||||||
from paperless.parsers.registry import get_parser_registry
|
|
||||||
|
|
||||||
registry = get_parser_registry()
|
|
||||||
parser_cls = registry.get_parser_for_file("text/csv", "data.csv")
|
|
||||||
|
|
||||||
assert parser_cls is TextDocumentParser
|
|
||||||
|
|
||||||
def test_get_parser_for_unknown_type_returns_none(self) -> None:
|
|
||||||
from paperless.parsers.registry import get_parser_registry
|
|
||||||
|
|
||||||
registry = get_parser_registry()
|
|
||||||
parser_cls = registry.get_parser_for_file("application/pdf", "doc.pdf")
|
|
||||||
|
|
||||||
assert parser_cls is None
|
|
||||||
@@ -1,714 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for :mod:`paperless.parsers` (ParserProtocol) and
|
|
||||||
:mod:`paperless.parsers.registry` (ParserRegistry + module-level helpers).
|
|
||||||
|
|
||||||
All tests use pytest-style functions/classes — no unittest.TestCase.
|
|
||||||
The ``clean_registry`` fixture ensures complete isolation between tests by
|
|
||||||
resetting the module-level singleton before and after every test.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from importlib.metadata import EntryPoint
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Self
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from paperless.parsers import ParserProtocol
|
|
||||||
from paperless.parsers.registry import ParserRegistry
|
|
||||||
from paperless.parsers.registry import get_parser_registry
|
|
||||||
from paperless.parsers.registry import init_builtin_parsers
|
|
||||||
from paperless.parsers.registry import reset_parser_registry
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def dummy_parser_cls() -> type:
|
|
||||||
"""Return a class that fully satisfies :class:`ParserProtocol`.
|
|
||||||
|
|
||||||
GIVEN: A need to exercise registry and Protocol logic with a minimal
|
|
||||||
but complete parser.
|
|
||||||
WHEN: A test requests this fixture.
|
|
||||||
THEN: A class with all required attributes and methods is returned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class DummyParser:
|
|
||||||
name = "dummy-parser"
|
|
||||||
version = "0.1.0"
|
|
||||||
author = "Test Author"
|
|
||||||
url = "https://example.com/dummy-parser"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls) -> dict[str, str]:
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(
|
|
||||||
cls,
|
|
||||||
mime_type: str,
|
|
||||||
filename: str,
|
|
||||||
path: Path | None = None,
|
|
||||||
) -> int | None:
|
|
||||||
return 10
|
|
||||||
|
|
||||||
@property
|
|
||||||
def can_produce_archive(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def requires_pdf_rendition(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def parse(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
*,
|
|
||||||
produce_archive: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Required to exist, but doesn't need to do anything
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_text(self) -> str | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_date(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_archive_path(self) -> Path | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_thumbnail(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
) -> Path:
|
|
||||||
return Path("/tmp/thumbnail.webp")
|
|
||||||
|
|
||||||
def get_page_count(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
) -> int | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def extract_metadata(
|
|
||||||
self,
|
|
||||||
document_path: Path,
|
|
||||||
mime_type: str,
|
|
||||||
) -> list:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
||||||
"""
|
|
||||||
Required to exist, but doesn't need to do anything
|
|
||||||
"""
|
|
||||||
|
|
||||||
return DummyParser
|
|
||||||
|
|
||||||
|
|
||||||
class TestParserProtocol:
|
|
||||||
"""Verify runtime isinstance() checks against ParserProtocol."""
|
|
||||||
|
|
||||||
def test_compliant_class_instance_passes_isinstance(
|
|
||||||
self,
|
|
||||||
dummy_parser_cls: type,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A class that implements every method required by ParserProtocol.
|
|
||||||
WHEN: isinstance() is called with the Protocol.
|
|
||||||
THEN: The check passes (returns True).
|
|
||||||
"""
|
|
||||||
instance = dummy_parser_cls()
|
|
||||||
assert isinstance(instance, ParserProtocol)
|
|
||||||
|
|
||||||
def test_non_compliant_class_instance_fails_isinstance(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A plain class with no parser-related methods.
|
|
||||||
WHEN: isinstance() is called with ParserProtocol.
|
|
||||||
THEN: The check fails (returns False).
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Unrelated:
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert not isinstance(Unrelated(), ParserProtocol)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"missing_method",
|
|
||||||
[
|
|
||||||
pytest.param("parse", id="missing-parse"),
|
|
||||||
pytest.param("get_text", id="missing-get_text"),
|
|
||||||
pytest.param("get_thumbnail", id="missing-get_thumbnail"),
|
|
||||||
pytest.param("__enter__", id="missing-__enter__"),
|
|
||||||
pytest.param("__exit__", id="missing-__exit__"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_partial_compliant_fails_isinstance(
|
|
||||||
self,
|
|
||||||
dummy_parser_cls: type,
|
|
||||||
missing_method: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A class that satisfies ParserProtocol except for one method.
|
|
||||||
WHEN: isinstance() is called with ParserProtocol.
|
|
||||||
THEN: The check fails because the Protocol is not fully satisfied.
|
|
||||||
"""
|
|
||||||
# Create a subclass and delete the specified method to break compliance.
|
|
||||||
partial_cls = type(
|
|
||||||
"PartialParser",
|
|
||||||
(dummy_parser_cls,),
|
|
||||||
{missing_method: None}, # Replace with None — not callable
|
|
||||||
)
|
|
||||||
assert not isinstance(partial_cls(), ParserProtocol)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegistrySingleton:
|
|
||||||
"""Verify the module-level singleton lifecycle functions."""
|
|
||||||
|
|
||||||
def test_get_parser_registry_returns_instance(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: No registry has been created yet.
|
|
||||||
WHEN: get_parser_registry() is called.
|
|
||||||
THEN: A ParserRegistry instance is returned.
|
|
||||||
"""
|
|
||||||
registry = get_parser_registry()
|
|
||||||
assert isinstance(registry, ParserRegistry)
|
|
||||||
|
|
||||||
def test_get_parser_registry_same_instance_on_repeated_calls(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry instance was created by a prior call.
|
|
||||||
WHEN: get_parser_registry() is called a second time.
|
|
||||||
THEN: The exact same object (identity) is returned.
|
|
||||||
"""
|
|
||||||
first = get_parser_registry()
|
|
||||||
second = get_parser_registry()
|
|
||||||
assert first is second
|
|
||||||
|
|
||||||
def test_reset_parser_registry_gives_fresh_instance(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry instance already exists.
|
|
||||||
WHEN: reset_parser_registry() is called and then get_parser_registry()
|
|
||||||
is called again.
|
|
||||||
THEN: A new, distinct registry instance is returned.
|
|
||||||
"""
|
|
||||||
first = get_parser_registry()
|
|
||||||
reset_parser_registry()
|
|
||||||
second = get_parser_registry()
|
|
||||||
assert first is not second
|
|
||||||
|
|
||||||
def test_init_builtin_parsers_does_not_run_discover(
|
|
||||||
self,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: discover() would raise an exception if called.
|
|
||||||
WHEN: init_builtin_parsers() is called.
|
|
||||||
THEN: No exception is raised, confirming discover() was not invoked.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def exploding_discover(self) -> None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"discover() must not be called from init_builtin_parsers",
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(ParserRegistry, "discover", exploding_discover)
|
|
||||||
|
|
||||||
# Should complete without raising.
|
|
||||||
init_builtin_parsers()
|
|
||||||
|
|
||||||
def test_init_builtin_parsers_idempotent(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: init_builtin_parsers() has already been called once.
|
|
||||||
WHEN: init_builtin_parsers() is called a second time.
|
|
||||||
THEN: No error is raised and the same registry instance is reused.
|
|
||||||
"""
|
|
||||||
init_builtin_parsers()
|
|
||||||
# Capture the registry created by the first call.
|
|
||||||
import paperless.parsers.registry as reg_module
|
|
||||||
|
|
||||||
first_registry = reg_module._registry
|
|
||||||
|
|
||||||
init_builtin_parsers()
|
|
||||||
|
|
||||||
assert reg_module._registry is first_registry
|
|
||||||
|
|
||||||
|
|
||||||
class TestParserRegistryGetParserForFile:
|
|
||||||
"""Verify parser selection logic in get_parser_for_file()."""
|
|
||||||
|
|
||||||
def test_returns_none_when_no_parsers_registered(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry with no parsers registered.
|
|
||||||
WHEN: get_parser_for_file() is called for any MIME type.
|
|
||||||
THEN: None is returned.
|
|
||||||
"""
|
|
||||||
registry = ParserRegistry()
|
|
||||||
result = registry.get_parser_for_file("text/plain", "doc.txt")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_returns_none_for_unsupported_mime_type(
|
|
||||||
self,
|
|
||||||
dummy_parser_cls: type,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry with a parser that supports only 'text/plain'.
|
|
||||||
WHEN: get_parser_for_file() is called with 'application/pdf'.
|
|
||||||
THEN: None is returned.
|
|
||||||
"""
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(dummy_parser_cls)
|
|
||||||
result = registry.get_parser_for_file("application/pdf", "file.pdf")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_returns_parser_for_supported_mime_type(
|
|
||||||
self,
|
|
||||||
dummy_parser_cls: type,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry with a parser registered for 'text/plain'.
|
|
||||||
WHEN: get_parser_for_file() is called with 'text/plain'.
|
|
||||||
THEN: The registered parser class is returned.
|
|
||||||
"""
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(dummy_parser_cls)
|
|
||||||
result = registry.get_parser_for_file("text/plain", "readme.txt")
|
|
||||||
assert result is dummy_parser_cls
|
|
||||||
|
|
||||||
def test_highest_score_wins(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: Two parsers both supporting 'text/plain' with scores 5 and 20.
|
|
||||||
WHEN: get_parser_for_file() is called for 'text/plain'.
|
|
||||||
THEN: The parser with score 20 is returned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class LowScoreParser:
|
|
||||||
name = "low"
|
|
||||||
version = "1.0"
|
|
||||||
author = "A"
|
|
||||||
url = "https://example.com/low"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 5
|
|
||||||
|
|
||||||
class HighScoreParser:
|
|
||||||
name = "high"
|
|
||||||
version = "1.0"
|
|
||||||
author = "B"
|
|
||||||
url = "https://example.com/high"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 20
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(LowScoreParser)
|
|
||||||
registry.register_builtin(HighScoreParser)
|
|
||||||
result = registry.get_parser_for_file("text/plain", "readme.txt")
|
|
||||||
assert result is HighScoreParser
|
|
||||||
|
|
||||||
def test_parser_returning_none_score_is_skipped(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A parser that returns None from score() for the given file.
|
|
||||||
WHEN: get_parser_for_file() is called.
|
|
||||||
THEN: That parser is skipped and None is returned (no other candidates).
|
|
||||||
"""
|
|
||||||
|
|
||||||
class DecliningParser:
|
|
||||||
name = "declining"
|
|
||||||
version = "1.0"
|
|
||||||
author = "A"
|
|
||||||
url = "https://example.com"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return None # Explicitly declines
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(DecliningParser)
|
|
||||||
result = registry.get_parser_for_file("text/plain", "readme.txt")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_all_parsers_decline_returns_none(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: Multiple parsers that all return None from score().
|
|
||||||
WHEN: get_parser_for_file() is called.
|
|
||||||
THEN: None is returned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class AlwaysDeclines:
|
|
||||||
name = "declines"
|
|
||||||
version = "1.0"
|
|
||||||
author = "A"
|
|
||||||
url = "https://example.com"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return None
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(AlwaysDeclines)
|
|
||||||
registry._external.append(AlwaysDeclines)
|
|
||||||
result = registry.get_parser_for_file("text/plain", "file.txt")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_external_parser_beats_builtin_same_score(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: An external and a built-in parser both returning score 10.
|
|
||||||
WHEN: get_parser_for_file() is called.
|
|
||||||
THEN: The external parser wins because externals are evaluated first
|
|
||||||
and the first-seen-wins policy applies at equal scores.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class BuiltinParser:
|
|
||||||
name = "builtin"
|
|
||||||
version = "1.0"
|
|
||||||
author = "Core"
|
|
||||||
url = "https://example.com/builtin"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 10
|
|
||||||
|
|
||||||
class ExternalParser:
|
|
||||||
name = "external"
|
|
||||||
version = "2.0"
|
|
||||||
author = "Third Party"
|
|
||||||
url = "https://example.com/external"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 10
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(BuiltinParser)
|
|
||||||
registry._external.append(ExternalParser)
|
|
||||||
result = registry.get_parser_for_file("text/plain", "file.txt")
|
|
||||||
assert result is ExternalParser
|
|
||||||
|
|
||||||
def test_builtin_wins_when_external_declines(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: An external parser that declines (score None) and a built-in
|
|
||||||
that returns score 5.
|
|
||||||
WHEN: get_parser_for_file() is called.
|
|
||||||
THEN: The built-in parser is returned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class DecliningExternal:
|
|
||||||
name = "declining-external"
|
|
||||||
version = "1.0"
|
|
||||||
author = "Third Party"
|
|
||||||
url = "https://example.com/declining"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return None
|
|
||||||
|
|
||||||
class AcceptingBuiltin:
|
|
||||||
name = "accepting-builtin"
|
|
||||||
version = "1.0"
|
|
||||||
author = "Core"
|
|
||||||
url = "https://example.com/accepting"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 5
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(AcceptingBuiltin)
|
|
||||||
registry._external.append(DecliningExternal)
|
|
||||||
result = registry.get_parser_for_file("text/plain", "file.txt")
|
|
||||||
assert result is AcceptingBuiltin
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiscover:
|
|
||||||
"""Verify entrypoint discovery in ParserRegistry.discover()."""
|
|
||||||
|
|
||||||
def test_discover_with_no_entrypoints(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: No entrypoints are registered under 'paperless_ngx.parsers'.
|
|
||||||
WHEN: discover() is called.
|
|
||||||
THEN: _external remains empty and no errors are raised.
|
|
||||||
"""
|
|
||||||
registry = ParserRegistry()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"paperless.parsers.registry.entry_points",
|
|
||||||
return_value=[],
|
|
||||||
):
|
|
||||||
registry.discover()
|
|
||||||
|
|
||||||
assert registry._external == []
|
|
||||||
|
|
||||||
def test_discover_adds_valid_external_parser(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: One valid entrypoint whose loaded class has all required attrs.
|
|
||||||
WHEN: discover() is called.
|
|
||||||
THEN: The class is appended to _external.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class ValidExternal:
|
|
||||||
name = "valid-external"
|
|
||||||
version = "3.0.0"
|
|
||||||
author = "Someone"
|
|
||||||
url = "https://example.com/valid"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"application/pdf": ".pdf"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 5
|
|
||||||
|
|
||||||
mock_ep = MagicMock(spec=EntryPoint)
|
|
||||||
mock_ep.name = "valid_external"
|
|
||||||
mock_ep.load.return_value = ValidExternal
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"paperless.parsers.registry.entry_points",
|
|
||||||
return_value=[mock_ep],
|
|
||||||
):
|
|
||||||
registry.discover()
|
|
||||||
|
|
||||||
assert ValidExternal in registry._external
|
|
||||||
|
|
||||||
def test_discover_skips_entrypoint_with_load_error(
|
|
||||||
self,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: An entrypoint whose load() method raises ImportError.
|
|
||||||
WHEN: discover() is called.
|
|
||||||
THEN: The entrypoint is skipped, an error is logged, and _external
|
|
||||||
remains empty.
|
|
||||||
"""
|
|
||||||
mock_ep = MagicMock(spec=EntryPoint)
|
|
||||||
mock_ep.name = "broken_ep"
|
|
||||||
mock_ep.load.side_effect = ImportError("missing dependency")
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
|
|
||||||
with caplog.at_level(logging.ERROR, logger="paperless.parsers.registry"):
|
|
||||||
with patch(
|
|
||||||
"paperless.parsers.registry.entry_points",
|
|
||||||
return_value=[mock_ep],
|
|
||||||
):
|
|
||||||
registry.discover()
|
|
||||||
|
|
||||||
assert registry._external == []
|
|
||||||
assert any(
|
|
||||||
"broken_ep" in record.message
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno >= logging.ERROR
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_discover_skips_entrypoint_with_missing_attrs(
|
|
||||||
self,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A class loaded from an entrypoint that is missing the 'score'
|
|
||||||
attribute.
|
|
||||||
WHEN: discover() is called.
|
|
||||||
THEN: The entrypoint is skipped, a warning is logged, and _external
|
|
||||||
remains empty.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class MissingScore:
|
|
||||||
name = "missing-score"
|
|
||||||
version = "1.0"
|
|
||||||
author = "Someone"
|
|
||||||
url = "https://example.com"
|
|
||||||
|
|
||||||
# 'score' classmethod is intentionally absent.
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
mock_ep = MagicMock(spec=EntryPoint)
|
|
||||||
mock_ep.name = "missing_score_ep"
|
|
||||||
mock_ep.load.return_value = MissingScore
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="paperless.parsers.registry"):
|
|
||||||
with patch(
|
|
||||||
"paperless.parsers.registry.entry_points",
|
|
||||||
return_value=[mock_ep],
|
|
||||||
):
|
|
||||||
registry.discover()
|
|
||||||
|
|
||||||
assert registry._external == []
|
|
||||||
assert any(
|
|
||||||
"missing_score_ep" in record.message
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno >= logging.WARNING
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_discover_logs_loaded_parser_info(
|
|
||||||
self,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A valid entrypoint that loads successfully.
|
|
||||||
WHEN: discover() is called.
|
|
||||||
THEN: An INFO log message is emitted containing the parser name,
|
|
||||||
version, author, and entrypoint name.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class LoggableParser:
|
|
||||||
name = "loggable"
|
|
||||||
version = "4.2.0"
|
|
||||||
author = "Log Tester"
|
|
||||||
url = "https://example.com/loggable"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"image/png": ".png"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
mock_ep = MagicMock(spec=EntryPoint)
|
|
||||||
mock_ep.name = "loggable_ep"
|
|
||||||
mock_ep.load.return_value = LoggableParser
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="paperless.parsers.registry"):
|
|
||||||
with patch(
|
|
||||||
"paperless.parsers.registry.entry_points",
|
|
||||||
return_value=[mock_ep],
|
|
||||||
):
|
|
||||||
registry.discover()
|
|
||||||
|
|
||||||
info_messages = " ".join(
|
|
||||||
r.message for r in caplog.records if r.levelno == logging.INFO
|
|
||||||
)
|
|
||||||
assert "loggable" in info_messages
|
|
||||||
assert "4.2.0" in info_messages
|
|
||||||
assert "Log Tester" in info_messages
|
|
||||||
assert "loggable_ep" in info_messages
|
|
||||||
|
|
||||||
|
|
||||||
class TestLogSummary:
|
|
||||||
"""Verify log output from ParserRegistry.log_summary()."""
|
|
||||||
|
|
||||||
def test_log_summary_with_no_external_parsers(
|
|
||||||
self,
|
|
||||||
dummy_parser_cls: type,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry with one built-in parser and no external parsers.
|
|
||||||
WHEN: log_summary() is called.
|
|
||||||
THEN: The built-in parser name appears in the logs.
|
|
||||||
"""
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(dummy_parser_cls)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="paperless.parsers.registry"):
|
|
||||||
registry.log_summary()
|
|
||||||
|
|
||||||
all_messages = " ".join(r.message for r in caplog.records)
|
|
||||||
assert dummy_parser_cls.name in all_messages
|
|
||||||
|
|
||||||
def test_log_summary_with_external_parsers(
|
|
||||||
self,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry with one external parser registered.
|
|
||||||
WHEN: log_summary() is called.
|
|
||||||
THEN: The external parser name, version, author, and url appear in
|
|
||||||
the log output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class ExtParser:
|
|
||||||
name = "ext-parser"
|
|
||||||
version = "9.9.9"
|
|
||||||
author = "Ext Corp"
|
|
||||||
url = "https://ext.example.com"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return None
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry._external.append(ExtParser)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="paperless.parsers.registry"):
|
|
||||||
registry.log_summary()
|
|
||||||
|
|
||||||
all_messages = " ".join(r.message for r in caplog.records)
|
|
||||||
assert "ext-parser" in all_messages
|
|
||||||
assert "9.9.9" in all_messages
|
|
||||||
assert "Ext Corp" in all_messages
|
|
||||||
assert "https://ext.example.com" in all_messages
|
|
||||||
|
|
||||||
def test_log_summary_logs_no_third_party_message_when_none(
|
|
||||||
self,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry with no external parsers.
|
|
||||||
WHEN: log_summary() is called.
|
|
||||||
THEN: A message containing 'No third-party parsers discovered.' is
|
|
||||||
logged.
|
|
||||||
"""
|
|
||||||
registry = ParserRegistry()
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="paperless.parsers.registry"):
|
|
||||||
registry.log_summary()
|
|
||||||
|
|
||||||
all_messages = " ".join(r.message for r in caplog.records)
|
|
||||||
assert "No third-party parsers discovered." in all_messages
|
|
||||||
@@ -1,175 +1,186 @@
|
|||||||
import pytest
|
from unittest import mock
|
||||||
|
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from channels.testing import WebsocketCommunicator
|
from channels.testing import WebsocketCommunicator
|
||||||
from pytest_mock import MockerFixture
|
from django.test import TestCase
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.plugins.helpers import ProgressManager
|
from documents.plugins.helpers import ProgressManager
|
||||||
from documents.plugins.helpers import ProgressStatusOptions
|
from documents.plugins.helpers import ProgressStatusOptions
|
||||||
from paperless.asgi import application
|
from paperless.asgi import application
|
||||||
|
|
||||||
|
TEST_CHANNEL_LAYERS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
class TestWebSockets:
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def anyio_backend(self) -> str:
|
|
||||||
return "asyncio"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
|
class TestWebSockets(TestCase):
|
||||||
async def test_no_auth(self) -> None:
|
async def test_no_auth(self) -> None:
|
||||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||||
connected, _ = await communicator.connect()
|
connected, _ = await communicator.connect()
|
||||||
assert not connected
|
self.assertFalse(connected)
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@mock.patch("paperless.consumers.StatusConsumer.close")
|
||||||
async def test_close_on_no_auth(self, mocker: MockerFixture) -> None:
|
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
|
||||||
mock_auth = mocker.patch(
|
async def test_close_on_no_auth(self, _authenticated, mock_close) -> None:
|
||||||
"paperless.consumers.StatusConsumer._authenticated",
|
_authenticated.return_value = True
|
||||||
return_value=True,
|
|
||||||
)
|
|
||||||
mock_close = mocker.patch(
|
|
||||||
"paperless.consumers.StatusConsumer.close",
|
|
||||||
new_callable=mocker.AsyncMock,
|
|
||||||
)
|
|
||||||
|
|
||||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||||
connected, _ = await communicator.connect()
|
connected, _ = await communicator.connect()
|
||||||
assert connected
|
self.assertTrue(connected)
|
||||||
|
|
||||||
mock_auth.return_value = False
|
|
||||||
channel_layer = get_channel_layer()
|
|
||||||
assert channel_layer is not None
|
|
||||||
|
|
||||||
await channel_layer.group_send(
|
|
||||||
"status_updates",
|
|
||||||
{"type": "status_update", "data": {"task_id": "test"}},
|
|
||||||
)
|
|
||||||
await communicator.receive_nothing()
|
|
||||||
mock_close.assert_awaited_once()
|
|
||||||
mock_close.reset_mock()
|
|
||||||
|
|
||||||
await channel_layer.group_send(
|
|
||||||
"status_updates",
|
|
||||||
{
|
|
||||||
"type": "document_updated",
|
|
||||||
"data": {"document_id": 10, "modified": "2026-02-17T00:00:00Z"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await communicator.receive_nothing()
|
|
||||||
mock_close.assert_awaited_once()
|
|
||||||
mock_close.reset_mock()
|
|
||||||
|
|
||||||
await channel_layer.group_send(
|
|
||||||
"status_updates",
|
|
||||||
{"type": "documents_deleted", "data": {"documents": [1, 2, 3]}},
|
|
||||||
)
|
|
||||||
await communicator.receive_nothing()
|
|
||||||
mock_close.assert_awaited_once()
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_auth(self, mocker: MockerFixture) -> None:
|
|
||||||
mocker.patch(
|
|
||||||
"paperless.consumers.StatusConsumer._authenticated",
|
|
||||||
return_value=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
|
||||||
connected, _ = await communicator.connect()
|
|
||||||
assert connected
|
|
||||||
|
|
||||||
await communicator.disconnect()
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_receive_status_update(self, mocker: MockerFixture) -> None:
|
|
||||||
mocker.patch(
|
|
||||||
"paperless.consumers.StatusConsumer._authenticated",
|
|
||||||
return_value=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
|
||||||
connected, _ = await communicator.connect()
|
|
||||||
assert connected
|
|
||||||
|
|
||||||
message = {"type": "status_update", "data": {"task_id": "test"}}
|
message = {"type": "status_update", "data": {"task_id": "test"}}
|
||||||
channel_layer = get_channel_layer()
|
|
||||||
assert channel_layer is not None
|
|
||||||
await channel_layer.group_send("status_updates", message)
|
|
||||||
|
|
||||||
assert await communicator.receive_json_from() == message
|
_authenticated.return_value = False
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
await communicator.receive_nothing()
|
||||||
|
|
||||||
|
mock_close.assert_called_once()
|
||||||
|
mock_close.reset_mock()
|
||||||
|
|
||||||
|
message = {
|
||||||
|
"type": "document_updated",
|
||||||
|
"data": {"document_id": 10, "modified": "2026-02-17T00:00:00Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
await communicator.receive_nothing()
|
||||||
|
|
||||||
|
mock_close.assert_called_once()
|
||||||
|
mock_close.reset_mock()
|
||||||
|
|
||||||
|
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
|
||||||
|
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
await communicator.receive_nothing()
|
||||||
|
|
||||||
|
mock_close.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
|
||||||
|
async def test_auth(self, _authenticated) -> None:
|
||||||
|
_authenticated.return_value = True
|
||||||
|
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||||
|
connected, _ = await communicator.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
|
||||||
async def test_status_update_check_perms(self, mocker: MockerFixture) -> None:
|
async def test_receive_status_update(self, _authenticated) -> None:
|
||||||
user = mocker.MagicMock()
|
_authenticated.return_value = True
|
||||||
user.is_authenticated = True
|
|
||||||
user.is_superuser = False
|
|
||||||
user.id = 1
|
|
||||||
|
|
||||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||||
communicator.scope["user"] = user # type: ignore[typeddict-unknown-key]
|
|
||||||
connected, _ = await communicator.connect()
|
connected, _ = await communicator.connect()
|
||||||
assert connected
|
self.assertTrue(connected)
|
||||||
|
|
||||||
|
message = {"type": "status_update", "data": {"task_id": "test"}}
|
||||||
|
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
assert channel_layer is not None
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
|
||||||
# Message received as owner
|
response = await communicator.receive_json_from()
|
||||||
|
|
||||||
|
self.assertEqual(response, message)
|
||||||
|
|
||||||
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
async def test_status_update_check_perms(self) -> None:
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||||
|
|
||||||
|
communicator.scope["user"] = mock.Mock()
|
||||||
|
communicator.scope["user"].is_authenticated = True
|
||||||
|
communicator.scope["user"].is_superuser = False
|
||||||
|
communicator.scope["user"].id = 1
|
||||||
|
|
||||||
|
connected, _ = await communicator.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
|
||||||
|
# Test as owner
|
||||||
message = {"type": "status_update", "data": {"task_id": "test", "owner_id": 1}}
|
message = {"type": "status_update", "data": {"task_id": "test", "owner_id": 1}}
|
||||||
await channel_layer.group_send("status_updates", message)
|
channel_layer = get_channel_layer()
|
||||||
assert await communicator.receive_json_from() == message
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response, message)
|
||||||
|
|
||||||
# Message received via group membership
|
# Test with a group that the user belongs to
|
||||||
user.groups.filter.return_value.aexists = mocker.AsyncMock(return_value=True)
|
communicator.scope["user"].groups.filter.return_value.exists.return_value = True
|
||||||
message = {
|
message = {
|
||||||
"type": "status_update",
|
"type": "status_update",
|
||||||
"data": {"task_id": "test", "owner_id": 2, "groups_can_view": [1]},
|
"data": {"task_id": "test", "owner_id": 2, "groups_can_view": [1]},
|
||||||
}
|
}
|
||||||
await channel_layer.group_send("status_updates", message)
|
channel_layer = get_channel_layer()
|
||||||
assert await communicator.receive_json_from() == message
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response, message)
|
||||||
|
|
||||||
# Message not received for different owner with no group match
|
# Test with a different owner_id
|
||||||
user.groups.filter.return_value.aexists = mocker.AsyncMock(return_value=False)
|
|
||||||
message = {"type": "status_update", "data": {"task_id": "test", "owner_id": 2}}
|
message = {"type": "status_update", "data": {"task_id": "test", "owner_id": 2}}
|
||||||
await channel_layer.group_send("status_updates", message)
|
channel_layer = get_channel_layer()
|
||||||
assert await communicator.receive_nothing()
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
response = await communicator.receive_nothing()
|
||||||
|
self.assertNotEqual(response, message)
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
|
||||||
async def test_receive_documents_deleted(self, mocker: MockerFixture) -> None:
|
async def test_receive_documents_deleted(self, _authenticated) -> None:
|
||||||
mocker.patch(
|
_authenticated.return_value = True
|
||||||
"paperless.consumers.StatusConsumer._authenticated",
|
|
||||||
return_value=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||||
connected, _ = await communicator.connect()
|
connected, _ = await communicator.connect()
|
||||||
assert connected
|
self.assertTrue(connected)
|
||||||
|
|
||||||
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
|
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
|
||||||
channel_layer = get_channel_layer()
|
|
||||||
assert channel_layer is not None
|
|
||||||
await channel_layer.group_send("status_updates", message)
|
|
||||||
|
|
||||||
assert await communicator.receive_json_from() == message
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
|
||||||
|
self.assertEqual(response, message)
|
||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@mock.patch("paperless.consumers.StatusConsumer._can_view")
|
||||||
async def test_receive_document_updated(self, mocker: MockerFixture) -> None:
|
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
|
||||||
mocker.patch(
|
async def test_receive_document_updated(self, _authenticated, _can_view) -> None:
|
||||||
"paperless.consumers.StatusConsumer._authenticated",
|
_authenticated.return_value = True
|
||||||
return_value=True,
|
_can_view.return_value = True
|
||||||
)
|
|
||||||
mocker.patch(
|
|
||||||
"paperless.consumers.StatusConsumer._can_view",
|
|
||||||
return_value=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
communicator = WebsocketCommunicator(application, "/ws/status/")
|
||||||
connected, _ = await communicator.connect()
|
connected, _ = await communicator.connect()
|
||||||
assert connected
|
self.assertTrue(connected)
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
"type": "document_updated",
|
"type": "document_updated",
|
||||||
@@ -181,52 +192,67 @@ class TestWebSockets:
|
|||||||
"groups_can_view": [],
|
"groups_can_view": [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
assert channel_layer is not None
|
assert channel_layer is not None
|
||||||
await channel_layer.group_send("status_updates", message)
|
await channel_layer.group_send(
|
||||||
|
"status_updates",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
|
||||||
assert await communicator.receive_json_from() == message
|
response = await communicator.receive_json_from()
|
||||||
|
|
||||||
|
self.assertEqual(response, message)
|
||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
def test_manager_send_progress(self, mocker: MockerFixture) -> None:
|
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||||
mock_group_send = mocker.patch(
|
def test_manager_send_progress(self, mock_group_send) -> None:
|
||||||
"channels.layers.InMemoryChannelLayer.group_send",
|
|
||||||
)
|
|
||||||
|
|
||||||
with ProgressManager(task_id="test") as manager:
|
with ProgressManager(task_id="test") as manager:
|
||||||
manager.send_progress(
|
manager.send_progress(
|
||||||
ProgressStatusOptions.STARTED,
|
ProgressStatusOptions.STARTED,
|
||||||
"Test message",
|
"Test message",
|
||||||
1,
|
1,
|
||||||
10,
|
10,
|
||||||
extra_args={"foo": "bar"},
|
extra_args={
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert mock_group_send.call_args[0][1] == {
|
message = mock_group_send.call_args[0][1]
|
||||||
"type": "status_update",
|
|
||||||
"data": {
|
|
||||||
"filename": None,
|
|
||||||
"task_id": "test",
|
|
||||||
"current_progress": 1,
|
|
||||||
"max_progress": 10,
|
|
||||||
"status": ProgressStatusOptions.STARTED,
|
|
||||||
"message": "Test message",
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_manager_send_documents_deleted(self, mocker: MockerFixture) -> None:
|
self.assertEqual(
|
||||||
mock_group_send = mocker.patch(
|
message,
|
||||||
"channels.layers.InMemoryChannelLayer.group_send",
|
{
|
||||||
|
"type": "status_update",
|
||||||
|
"data": {
|
||||||
|
"filename": None,
|
||||||
|
"task_id": "test",
|
||||||
|
"current_progress": 1,
|
||||||
|
"max_progress": 10,
|
||||||
|
"status": ProgressStatusOptions.STARTED,
|
||||||
|
"message": "Test message",
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||||
|
def test_manager_send_documents_deleted(
|
||||||
|
self,
|
||||||
|
mock_group_send: mock.MagicMock,
|
||||||
|
) -> None:
|
||||||
with DocumentsStatusManager() as manager:
|
with DocumentsStatusManager() as manager:
|
||||||
manager.send_documents_deleted([1, 2, 3])
|
manager.send_documents_deleted([1, 2, 3])
|
||||||
|
|
||||||
assert mock_group_send.call_args[0][1] == {
|
message = mock_group_send.call_args[0][1]
|
||||||
"type": "documents_deleted",
|
|
||||||
"data": {
|
self.assertEqual(
|
||||||
"documents": [1, 2, 3],
|
message,
|
||||||
|
{
|
||||||
|
"type": "documents_deleted",
|
||||||
|
"data": {
|
||||||
|
"documents": [1, 2, 3],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
)
|
||||||
|
|||||||
50
src/paperless_text/parsers.py
Normal file
50
src/paperless_text/parsers.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from PIL import Image
|
||||||
|
from PIL import ImageDraw
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
from documents.parsers import DocumentParser
|
||||||
|
|
||||||
|
|
||||||
|
class TextDocumentParser(DocumentParser):
|
||||||
|
"""
|
||||||
|
This parser directly parses a text document (.txt, .md, or .csv)
|
||||||
|
"""
|
||||||
|
|
||||||
|
logging_name = "paperless.parsing.text"
|
||||||
|
|
||||||
|
def get_thumbnail(self, document_path: Path, mime_type, file_name=None) -> Path:
|
||||||
|
# Avoid reading entire file into memory
|
||||||
|
max_chars = 100_000
|
||||||
|
file_size_limit = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
if document_path.stat().st_size > file_size_limit:
|
||||||
|
text = "[File too large to preview]"
|
||||||
|
else:
|
||||||
|
with Path(document_path).open("r", encoding="utf-8", errors="replace") as f:
|
||||||
|
text = f.read(max_chars)
|
||||||
|
|
||||||
|
img = Image.new("RGB", (500, 700), color="white")
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
font = ImageFont.truetype(
|
||||||
|
font=settings.THUMBNAIL_FONT_NAME,
|
||||||
|
size=20,
|
||||||
|
layout_engine=ImageFont.Layout.BASIC,
|
||||||
|
)
|
||||||
|
draw.multiline_text((5, 5), text, font=font, fill="black", spacing=4)
|
||||||
|
|
||||||
|
out_path = self.tempdir / "thumb.webp"
|
||||||
|
img.save(out_path, format="WEBP")
|
||||||
|
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
def parse(self, document_path, mime_type, file_name=None) -> None:
|
||||||
|
self.text = self.read_file_handle_unicode_errors(document_path)
|
||||||
|
|
||||||
|
def get_settings(self) -> None:
|
||||||
|
"""
|
||||||
|
This parser does not implement additional settings yet
|
||||||
|
"""
|
||||||
|
return None
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
def get_parser(*args, **kwargs):
|
def get_parser(*args, **kwargs):
|
||||||
from paperless.parsers.text import TextDocumentParser
|
from paperless_text.parsers import TextDocumentParser
|
||||||
|
|
||||||
# The new TextDocumentParser does not accept the legacy logging_group /
|
return TextDocumentParser(*args, **kwargs)
|
||||||
# progress_callback kwargs injected by the old signal-based consumer.
|
|
||||||
# These are dropped here; Phase 4 will replace this signal path with the
|
|
||||||
# new ParserRegistry so the shim can be removed at that point.
|
|
||||||
kwargs.pop("logging_group", None)
|
|
||||||
kwargs.pop("progress_callback", None)
|
|
||||||
return TextDocumentParser()
|
|
||||||
|
|
||||||
|
|
||||||
def text_consumer_declaration(sender, **kwargs):
|
def text_consumer_declaration(sender, **kwargs):
|
||||||
|
|||||||
30
src/paperless_text/tests/conftest.py
Normal file
30
src/paperless_text/tests/conftest.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from paperless_text.parsers import TextDocumentParser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def sample_dir() -> Path:
|
||||||
|
return (Path(__file__).parent / Path("samples")).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def text_parser() -> Generator[TextDocumentParser, None, None]:
|
||||||
|
try:
|
||||||
|
parser = TextDocumentParser(logging_group=None)
|
||||||
|
yield parser
|
||||||
|
finally:
|
||||||
|
parser.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def sample_txt_file(sample_dir: Path) -> Path:
|
||||||
|
return sample_dir / "test.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def malformed_txt_file(sample_dir: Path) -> Path:
|
||||||
|
return sample_dir / "decode_error.txt"
|
||||||
69
src/paperless_text/tests/test_parser.py
Normal file
69
src/paperless_text/tests/test_parser.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from paperless_text.parsers import TextDocumentParser
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextParser:
|
||||||
|
def test_thumbnail(
|
||||||
|
self,
|
||||||
|
text_parser: TextDocumentParser,
|
||||||
|
sample_txt_file: Path,
|
||||||
|
) -> None:
|
||||||
|
# just make sure that it does not crash
|
||||||
|
f = text_parser.get_thumbnail(sample_txt_file, "text/plain")
|
||||||
|
assert f.exists()
|
||||||
|
assert f.is_file()
|
||||||
|
|
||||||
|
def test_parse(
|
||||||
|
self,
|
||||||
|
text_parser: TextDocumentParser,
|
||||||
|
sample_txt_file: Path,
|
||||||
|
) -> None:
|
||||||
|
text_parser.parse(sample_txt_file, "text/plain")
|
||||||
|
|
||||||
|
assert text_parser.get_text() == "This is a test file.\n"
|
||||||
|
assert text_parser.get_archive_path() is None
|
||||||
|
|
||||||
|
def test_parse_invalid_bytes(
|
||||||
|
self,
|
||||||
|
text_parser: TextDocumentParser,
|
||||||
|
malformed_txt_file: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Text file which contains invalid UTF bytes
|
||||||
|
WHEN:
|
||||||
|
- The file is parsed
|
||||||
|
THEN:
|
||||||
|
- Parsing continues
|
||||||
|
- Invalid bytes are removed
|
||||||
|
"""
|
||||||
|
|
||||||
|
text_parser.parse(malformed_txt_file, "text/plain")
|
||||||
|
|
||||||
|
assert text_parser.get_text() == "Pantothens<EFBFBD>ure\n"
|
||||||
|
assert text_parser.get_archive_path() is None
|
||||||
|
|
||||||
|
def test_thumbnail_large_file(self, text_parser: TextDocumentParser) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A very large text file (>50MB)
|
||||||
|
WHEN:
|
||||||
|
- A thumbnail is requested
|
||||||
|
THEN:
|
||||||
|
- A thumbnail is created without reading the entire file into memory
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
delete=False,
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
suffix=".txt",
|
||||||
|
) as tmp:
|
||||||
|
tmp.write("A" * (51 * 1024 * 1024)) # 51 MB of 'A'
|
||||||
|
large_file = Path(tmp.name)
|
||||||
|
|
||||||
|
thumb = text_parser.get_thumbnail(large_file, "text/plain")
|
||||||
|
assert thumb.exists()
|
||||||
|
assert thumb.is_file()
|
||||||
|
large_file.unlink()
|
||||||
@@ -12,7 +12,6 @@ def tika_parser() -> Generator[TikaDocumentParser, None, None]:
|
|||||||
parser = TikaDocumentParser(logging_group=None)
|
parser = TikaDocumentParser(logging_group=None)
|
||||||
yield parser
|
yield parser
|
||||||
finally:
|
finally:
|
||||||
# TODO(stumpylog): Cleanup once all parsers are handled
|
|
||||||
parser.cleanup()
|
parser.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
234
uv.lock
generated
234
uv.lock
generated
@@ -1172,14 +1172,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "drf-spectacular-sidecar"
|
name = "drf-spectacular-sidecar"
|
||||||
version = "2026.3.1"
|
version = "2026.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/42/2f8c1b2846399d47094ec414bc0d6a7cce7ba95fd6545a97285eee89f7f1/drf_spectacular_sidecar-2026.3.1.tar.gz", hash = "sha256:5b7fedad66e3851f2f442480792c08115d79217959d01645b93d3d2258938be1", size = 2461501, upload-time = "2026-03-01T11:31:19.708Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1e/81/c7b0e3ccbd5a039c4f4fcfecf88391a666ca1406a953886e2f39295b1c90/drf_spectacular_sidecar-2026.1.1.tar.gz", hash = "sha256:6f7c173a8ddbbbdafc7a27e028614b65f07a89ca90f996a432d57460463b56be", size = 2468060, upload-time = "2026-01-01T11:27:12.682Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/28/2d5e64d101ebc5180674fcaf7b5a35e398e2f8d9688b2e8d52b0e1394e7d/drf_spectacular_sidecar-2026.3.1-py3-none-any.whl", hash = "sha256:864edb83e022e13e3941c325c3cc0c954c843fa2e1d0bc95e81887664b2d3dad", size = 2481725, upload-time = "2026-03-01T11:31:18.469Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/96/38725edda526f3e9e597f531beeec94b0ef433d9494f06a13b7636eecb6e/drf_spectacular_sidecar-2026.1.1-py3-none-any.whl", hash = "sha256:af8df62f1b594ec280351336d837eaf2402ab25a6bc2a1fad7aee9935821070f", size = 2489520, upload-time = "2026-01-01T11:27:11.056Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1230,11 +1230,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "faker"
|
name = "faker"
|
||||||
version = "40.8.0"
|
version = "40.5.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/70/03/14428edc541467c460d363f6e94bee9acc271f3e62470630fc9a647d0cf2/faker-40.8.0.tar.gz", hash = "sha256:936a3c9be6c004433f20aa4d99095df5dec82b8c7ad07459756041f8c1728875", size = 1956493, upload-time = "2026-03-04T16:18:48.161Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/03/2a/96fff3edcb10f6505143448a4b91535f77b74865cec45be52690ee280443/faker-40.5.1.tar.gz", hash = "sha256:70222361cd82aa10cb86066d1a4e8f47f2bcdc919615c412045a69c4e6da0cd3", size = 1952684, upload-time = "2026-02-23T21:34:38.362Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/3b/c6348f1e285e75b069085b18110a4e6325b763a5d35d5e204356fc7c20b3/faker-40.8.0-py3-none-any.whl", hash = "sha256:eb21bdba18f7a8375382eb94fb436fce07046893dc94cb20817d28deb0c3d579", size = 1989124, upload-time = "2026-03-04T16:18:46.45Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/a9/1eed4db92d0aec2f9bfdf1faae0ab0418b5e121dda5701f118a7a4f0cd6a/faker-40.5.1-py3-none-any.whl", hash = "sha256:c69640c1e13bad49b4bcebcbf1b52f9f1a872b6ea186c248ada34d798f1661bf", size = 1987053, upload-time = "2026-02-23T21:34:36.418Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1251,11 +1251,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.25.2"
|
version = "3.20.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1393,74 +1393,74 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "granian"
|
name = "granian"
|
||||||
version = "2.7.2"
|
version = "2.7.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/19/d4ea523715ba8dd2ed295932cc3dda6bb197060f78aada6e886ff08587b2/granian-2.7.2.tar.gz", hash = "sha256:cdae2f3a26fa998d41fefad58f1d1c84a0b035a6cc9377addd81b51ba82f927f", size = 128969, upload-time = "2026-02-24T23:04:23.314Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/43/75/bdea4ab49a02772a3007e667284764081d401169e96d0270d95509e3e240/granian-2.7.0.tar.gz", hash = "sha256:bee8e8a81a259e6f08613c973062df9db5f8451b521bb0259ed8f27d3e2bab23", size = 127963, upload-time = "2026-02-02T11:39:57.525Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/58/dcf0e8a54b9a7f8b7482ed617bca08503a47eb6b702aea73cda9efd2c81c/granian-2.7.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3a0d33ada95a1421e5a22d447d918e5615ff0aa37f12de5b84455afe89970875", size = 6522860, upload-time = "2026-02-24T23:02:15.901Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/28/a3ee3f2220c0b9045f8caa2a2cb7484618961b7500f88594349a7889d391/granian-2.7.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e76afb483d7f42a0b911bdb447d282f70ad7a96caabd4c99cdc300117c5f8977", size = 4580966, upload-time = "2026-02-02T11:38:14.077Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/dd/398de0f273fdcf0e96bd70d8cd97364625176990e67457f11e23f95772bd/granian-2.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ee26f0258cc1b6ccf87c7bdcee6d1f90710505522fc9880ec02b299fb15679ad", size = 6135934, upload-time = "2026-02-24T23:02:18.52Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/60/b53da9c255f6853a5516d0f8a3e7325c24123f0f7e77856558c49810f4ce/granian-2.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:628523302274f95ca967f295a9aa7bc4ade5e1eced42afc60d06dfe20f2da07a", size = 4210344, upload-time = "2026-02-02T11:38:15.34Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/b7/7bf635bbdfb88dfc6591fa2ce5c3837ab9535e57e197a780c4a338363de7/granian-2.7.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f52338cfab08b8cdaadaa5b93665e0be5b4c4f718fbd132d76ceacacb9ff864e", size = 7138393, upload-time = "2026-02-24T23:02:19.911Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/bb/c3380106565bc99edfb90baafa1a8081a4334709ce0200d207ddda36275e/granian-2.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a62560b64a17e1cbae61038285d5fa8a32613ada9a46f05047dc607ea7d38f23", size = 5130258, upload-time = "2026-02-02T11:38:17.175Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/90/e424fd8a703add1e8922390503be8d057882b35b42ba51796157aabd659b/granian-2.7.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e377d03a638fecb6949ab05c8fd4a76f892993aed17c602d179bfd56aebc2de", size = 6467189, upload-time = "2026-02-24T23:02:21.896Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/8f/2c3348d6d33807e3b818ac07366b5251e811ce2548fbe82e0b55982d8a13/granian-2.7.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47b8e0e9497d24466d6511443cc18f22f18405aab5a7e2fece1dd38206af88c4", size = 4576496, upload-time = "2026-02-02T11:38:18.577Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/9a/5de24d7e2dba1aa9fbac6f0a80dace975cfac1b7c7624ece21da75a38987/granian-2.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f742f3ca1797a746fae4a9337fe5d966460c957fa8efeaccf464b872e158d3d", size = 6870813, upload-time = "2026-02-24T23:02:23.972Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/71/d1d146170a23f3523d8629b47f849b30ba0d513eb519188ce5d7bfd1b916/granian-2.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc6039c61a07b2d36462c487b66b131ae3fd862bdc8fb81d6e5c206c1a2b683c", size = 4975062, upload-time = "2026-02-02T11:38:20.084Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/cd/a604e38237857f4ad4262eadc409f94fe08fed3e86fa0b8734479cc5bfb1/granian-2.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ca4402e8f28a958f0c0f6ebff94cd0b04ca79690aded785648a438bc3c875ba3", size = 7046583, upload-time = "2026-02-24T23:02:25.94Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/f9/f3acbf8c41cd10ff81109bd9078d3228f23e52bab8673763c65739a87e30/granian-2.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f3b0442beb11b035ee09959726f44b3730d0b55688110defd1d9a9a6c7486955", size = 4827755, upload-time = "2026-02-02T11:38:21.817Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/ad/79eaae0cddd90c4e191b37674cedd8f4863b44465cb435b10396d0f12c82/granian-2.7.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1f9a899123b0d084783626e5225608094f1d2f6fc81b3a7c77ab8daac33ab74a", size = 7121958, upload-time = "2026-02-24T23:02:27.641Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/f8/503135b89539feea2be495b47858c22409ba77ffcb71920ae0727c674189/granian-2.7.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:741d0b58a5133cc5902b3129a8a4c55143f0f8769a80e7aa80caadc64c9f1d8b", size = 4939033, upload-time = "2026-02-02T11:38:23.033Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/51/e5c923b1baa003f5b4b7fc148be6f8d2e3cabe55d41040fe8139da52e31b/granian-2.7.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:56ba4bef79d0ae3736328038deed2b5d281b11672bc0b08ffc8ce6210e406ef8", size = 7303047, upload-time = "2026-02-24T23:02:30.863Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/90/aaabe2c1162d07a6af55532b6f616199aa237805ef1d732fa78d9883d217/granian-2.7.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:02a6fe6a19f290b70bc23feeb3809511becdaff2263b0469f02c28772af97652", size = 5292980, upload-time = "2026-02-02T11:38:24.823Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/c0/ebd68144a3ce9ead1a3192ac02e1c26e4874df1257435ce6137adf92fedb/granian-2.7.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ea46e3f43d94715aa89d1f2f5754753d46e6b653d561b82b0291e62a31bdfb35", size = 7011349, upload-time = "2026-02-24T23:02:32.887Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/aa/d1eb7342676893ab0ec1e66cceca4450bec3f29c488db2a92af5b4211d4d/granian-2.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8239b1a661271428c3e358e4bdcaaaf877a432cc593e93fc6b5a612ae521b06a", size = 5087230, upload-time = "2026-02-02T11:38:26.09Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/ed/37f5d7d887ec9159dd8f5b1c9c38cee711d51016d203959f2d51c536a33b/granian-2.7.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a836f3f8ebfe61cb25d9afb655f2e5d3851154fd2ad97d47bb4fb202817212fc", size = 6451593, upload-time = "2026-02-24T23:02:36.203Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/1a/b6d7840bfd9cd9bed627b138e6e8e49d1961997adba30ee39ad75d07ed58/granian-2.7.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d9c42562dcbf52848d0a9d0db58f8f2e790586eb0c363b8ad1b30fe0bd362117", size = 4572728, upload-time = "2026-02-02T11:38:30.143Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/06/84ee67a68504836a52c48ec3b4b2b406cbd927c9b43aae89d82db8d097a0/granian-2.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09b1c543ba30886dea515a156baf6d857bbb8b57dbfd8b012c578b93c80ef0c3", size = 6101239, upload-time = "2026-02-24T23:02:37.636Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/93/f8f7224d9eaaaf4dbf493035a85287fa2e27c17e5f7aacc01821d8aa66b4/granian-2.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3421bd5c90430073e1f3f88fc63bc8d0a8ee547a9a5c06d577a281f384160bd", size = 4195034, upload-time = "2026-02-02T11:38:32.007Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/50/ece7dc8efe144542cd626b88b1475b649e2eaa3eb5f7541ca57390151b05/granian-2.7.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d334d4fbefb97001e78aa8067deafb107b867c102ba2120b4b2ec989fa58a89", size = 7079443, upload-time = "2026-02-24T23:02:39.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/db/66843a35e1b6345da2a1c71839fb9aa7eb0f17d380fbf4cb5c7e06eb6f85/granian-2.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b8057dc81772932e208f2327b5e347459eb78896118e27af9845801e267cec5", size = 5123768, upload-time = "2026-02-02T11:38:33.449Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/0f37b531d3cc96b8538cca2dc86eda92102e0ee345b30aa689354194a4cb/granian-2.7.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c86081d8c87989db69650e9d0e50ed925b8cd5dad21e0a86aa72d7a45f45925", size = 6428683, upload-time = "2026-02-24T23:02:41.827Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/ce/631c5c1f7a4e6b8c98ec857b3e6795fe64e474b6f48df388ac701a21f3fe/granian-2.7.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e5e70f438b1a4787d76566770e98bf7732407efa02802f38f10c960247107d7", size = 4562424, upload-time = "2026-02-02T11:38:34.815Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/09/228626706554b389407270e2a6b19b7dee06d6890e8c01a39c6a785827fd/granian-2.7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9eda33dca2c8bc6471bb6e9e25863077bca3877a1bba4069cd5e0ee2de41765", size = 6959520, upload-time = "2026-02-24T23:02:43.488Z" },
|
{ url = "https://files.pythonhosted.org/packages/28/41/19bdfa3719e22c4dcf6fa1a53323551a37aa58a4ca7a768db6a0ba714ab0/granian-2.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:213dd224a47c7bfcbb91718c7eeb56d6067825a28dcae50f537964e2dafb729a", size = 5006002, upload-time = "2026-02-02T11:38:36.76Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/c0/a639ceabd59b8acae2d71b5c918fcb2d42f8ef98994eedcf9a8b6813731d/granian-2.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9cf69aaff6f632074ffbe7c1ee214e50f64be36101b7cb8253eeec1d460f2dba", size = 6991548, upload-time = "2026-02-24T23:02:44.954Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/5b/3b40f489e2449eb58df93ad38f42d1a6c2910502a4bc8017c047e16d637c/granian-2.7.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:bb5be27c0265268d43bab9a878ac27a20b4288843ffc9fda1009b8226673f629", size = 4825073, upload-time = "2026-02-02T11:38:37.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/99/a35ed838a3095dcad02ae3944d19ebafe1d5a98cdc72bb61835fb5faf933/granian-2.7.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f761a748cc7f3843b430422d2539da679daf5d3ef0259a101b90d5e55a0aafa7", size = 7121475, upload-time = "2026-02-24T23:02:46.991Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/92/b6de6f8c4146409efb58aee75277b810d54de03a1687d33f1f3f1feb3395/granian-2.7.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a6ff95aede82903c06eb560a32b10e9235fdafc4568c8fe7dcac28d62be5ffa2", size = 4928628, upload-time = "2026-02-02T11:38:39.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/24/3952c464432b904ec1cf537d2bd80d2dfde85524fa428ab9db2b5afe653c/granian-2.7.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:41c7b8390b78647fe34662ed7296e1465dad4a5112af9b0ecf8e367083d6c76a", size = 7243647, upload-time = "2026-02-24T23:02:49.165Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/21/d8a191dcfbf8422b868ab847829670075ba3e4325611e0a9fd2dc909a142/granian-2.7.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e44f0c1676b27582df26d47cf466fedebd72f520edc2025f125c83ff58af77f9", size = 5282898, upload-time = "2026-02-02T11:38:40.815Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/fa/ab39e39c6b78eab6b42cf5bb36f56badde2aaafc3807f03f781d00e7861a/granian-2.7.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a052ed466da5922cb443435a95a0c751566943278a6f22cef3d2e19d4e7ecdea", size = 7048915, upload-time = "2026-02-24T23:02:50.773Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/46/2746f1a4f0f093576fb64b63c3f022f254c6d2c4cc66d37dd881608397ce/granian-2.7.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9241b72f95ceb57e2bbce55e0f61c250c1c02e9d2f8531b027dd3dc204209fdd", size = 5118453, upload-time = "2026-02-02T11:38:42.716Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/bc/cf0bc29f583096a842cf0f26ae2fe40c72ed5286d4548be99ecfcdbb17e2/granian-2.7.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:76b840ff13dde8838fd33cd096f2e7cadf2c21a499a67f695f53de57deab6ff8", size = 6440868, upload-time = "2026-02-24T23:02:53.619Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/df/b68626242fb4913df0968ee5662f5a394857b3d6fc4ee17c94be69664491/granian-2.7.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:bc61451791c8963232e4921c6805e7c2e366635e1e658267b1854889116ff6d7", size = 4572200, upload-time = "2026-02-02T11:38:46.194Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/0d/bae1dcd2182ba5d9a5df33eb50b56dc5bbe67e31033d822e079aa8c1ff30/granian-2.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:00ccc8d7284bc7360f310179d0b4d17e5ca3077bbe24427e9e9310df397e3831", size = 6097336, upload-time = "2026-02-24T23:02:55.185Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/15/2fe28bca0751d9dc46e5c7e9e4b0c4fd1a55e3e8ba062f28292322ee160b/granian-2.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e274a0d6a01c475b9135212106ca5b69f5ec2f67f4ca6ce812d185d80255cdf5", size = 4195415, upload-time = "2026-02-02T11:38:47.78Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/7d/3e0a7f32b0ad5faa1d847c51191391552fa239821c95fc7c022688985df2/granian-2.7.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:675987c1b321dc8af593db8639e00c25277449b32e8c1b2ddd46b35f28d9fac4", size = 7098742, upload-time = "2026-02-24T23:02:57.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/2a/d4dc40e58a55835cac5296f5090cc3ce2d43332ad486bbf78b3a00e46199/granian-2.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34bd28075adae3453c596ee20089e0288379e3fdf1cec8bafff89bb175ea0eb4", size = 5122981, upload-time = "2026-02-02T11:38:49.55Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/41/3b44386d636ac6467f0f13f45474c71fc3b90a4f0ba8b536de91b2845a09/granian-2.7.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:681c6fbe3354aaa6251e6191ec89f5174ac3b9fbc4b4db606fea456d01969fcb", size = 6430667, upload-time = "2026-02-24T23:02:59.789Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/fe/8c79837df620dc0eca6a8b799505910cbba2d85d92ccc58d1c549f7027be/granian-2.7.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f526583b72cf9e6ca9a4849c781ed546f44005f0ad4b5c7eb1090e1ebec209bf", size = 4561440, upload-time = "2026-02-02T11:38:50.799Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/70/7b24e187aed3fb7ac2b29d2480a045559a509ef9fec54cffb8694a2d94af/granian-2.7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5c9ae65af5e572dca27d8ca0da4c5180b08473ac47e6f5329699e9455a5cc3", size = 6948424, upload-time = "2026-02-24T23:03:01.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/4f/e7/d7abfaa9829ff50cddc27919bd3ce5a335402ebbbaa650e96fe579136674/granian-2.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95ac07d5314e03e667210349dfc76124d69726731007c24716e21a2554cc15ca", size = 5005076, upload-time = "2026-02-02T11:38:52.157Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/4c/cb74c367f9efb874f2c8433fe9bf3e824f05cf719f2251d40e29e07f08c0/granian-2.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e37fab2be919ceb195db00d7f49ec220444b1ecaa07c03f7c1c874cacff9de83", size = 7000407, upload-time = "2026-02-24T23:03:03.214Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/45/108afaa0636c93b6a8ff12810787e4a1ea27fffe59f12ca0de7c784b119a/granian-2.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f6812e342c41ca80e1b34fb6c9a7e51a4bbd14f59025bd1bb59d45a39e02b8d5", size = 4825142, upload-time = "2026-02-02T11:38:53.506Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/98/dfed3966ed7fbd3aae56e123598f90dc206484092b8373d0a71e2d8b82a8/granian-2.7.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:8ec167ab30f5396b5caaff16820a39f4e91986d2fe5bdc02992a03c2b2b2b313", size = 7121626, upload-time = "2026-02-24T23:03:05.349Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/eb/cedf4675b1047490f819ce8bd1ee1ea74b6c772ae9d9dd1c117ae690a3eb/granian-2.7.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a4099ba59885123405699a5313757556ff106f90336dccdf4ceda76f32657d0", size = 4927830, upload-time = "2026-02-02T11:38:54.92Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/82/acec732a345cd03b2f6e48ac04b66b7b8b61f5c50eb08d7421fc8c56591a/granian-2.7.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:63f426d793f2116d23be265dd826bec1e623680baf94cc270fe08923113a86ba", size = 7253447, upload-time = "2026-02-24T23:03:06.986Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/b5/2d7a2e03ba29a6915ad41502e2870899b9eb54861e3d06ad8470c5e70b41/granian-2.7.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c487731fbae86808410e88c587eb4071213812c5f52570b7981bf07a1b84be25", size = 5282142, upload-time = "2026-02-02T11:38:56.445Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/2b/64779e69b08c1ff1bfc09a4ede904ab761ff63f936c275710886057c52f7/granian-2.7.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1617cbb4efe3112f07fb6762cf81d2d9fe4bdb78971d1fd0a310f8b132f6a51e", size = 7053005, upload-time = "2026-02-24T23:03:09.021Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/e7/c851b2e2351727186b4bc4a35df832e2e97e4f77b8a93dfdb6daa098cf9e/granian-2.7.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ca4877ebf8873488ba72a299206621bd0c6febb8f091f3da62117c1fe344501f", size = 5117907, upload-time = "2026-02-02T11:38:57.852Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/49/9eb88875d709db7e7844e1c681546448dab5ff5651cd1c1d80ac4b1de4e3/granian-2.7.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:016c5857c8baedeab7eb065f98417f5ea26bb72b0f7e0544fe76071efc5ab255", size = 6401748, upload-time = "2026-02-24T23:03:12.802Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/2f/c9bcd4aa36d3092fe88a623e60aa89bd4ff16836803a633b8b454946a845/granian-2.7.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:e1df8e4669b4fb69b373b2ab40a10a8c511eeb41838d65adb375d1c0e4e7454c", size = 4493110, upload-time = "2026-02-02T11:39:01.294Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/80/85726ad9999ed89cb6a32f7f57eb50ce7261459d9c30c3b194ae4c5aa2c5/granian-2.7.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dcbe01fa141adf3f90964e86a959e250754aa7c6dad8fa7a855e6fd382de4c13", size = 6101265, upload-time = "2026-02-24T23:03:14.435Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/b4/02d11870255920d35f8eab390e509d3688fe0018011bb606aa00057b778f/granian-2.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6331ed9d3eb06cfba737dfb8efa3f0a8b4d4312a5af91c0a67bfbaa078b62eb4", size = 4122388, upload-time = "2026-02-02T11:39:02.509Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/82/0df56a42b9f4c327d0e0b052f43369127e1b565b9e66bf2c9488f1c8d759/granian-2.7.2-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:283ba23817a685784b66f45423d2f25715fdc076c8ffb43c49a807ee56a0ffc0", size = 6249488, upload-time = "2026-02-24T23:03:16.387Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/50/dfad5a414a2e3e14c30cd0d54cef1dab4874a67c1e6f8b1124d9998ed8b2/granian-2.7.0-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:093e1c277eddba00eaa94ca82ff7a9ab57b0554cd7013e5b2f3468635dbe520d", size = 4379344, upload-time = "2026-02-02T11:39:04.489Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/cc/d83a351560a3d6377672636129c52f06f8393f5831c5ee0f06f274883ea6/granian-2.7.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3258419c741897273ce155568b5a9cbacb7700a00516e87119a90f7d520d6783", size = 7104734, upload-time = "2026-02-24T23:03:17.993Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/53/ef086af03ef31aa3c1dbff2da5928a9b5dd1f48d8ebee18dd6628951ae9e/granian-2.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8e8e317bdc9ca9905d0b20f665f8fe31080c7f13d90675439113932bb3272c24", size = 5069172, upload-time = "2026-02-02T11:39:05.757Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/d1/539907ee96d0ee2bcceabb4a6a9643b75378d6dfea09b7a9e4fd22cdf977/granian-2.7.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a196125c4837491c139c9cc83541b48c408c92b9cfbbf004fd28717f9c02ad21", size = 6785504, upload-time = "2026-02-24T23:03:19.763Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/57/117864ea46c6cbcbeff733a4da736e814b06d6634beeb201b9db176bd6be/granian-2.7.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:391e8589265178fd7f444b6711b6dda157a6b66059a15bf1033ffceeaf26918c", size = 4848246, upload-time = "2026-02-02T11:39:07.048Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/bf/4b6f45882f8341e7c6cb824d693deb94c306be6525b483c76fb373d1e749/granian-2.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:746555ac8a2dcd9257bfe7ad58f1d7a60892bc4613df6a7d8f736692b3bb3b88", size = 6902790, upload-time = "2026-02-24T23:03:22.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/da/2d45b7b6638a77362228d6770a61fa2bc3feae6c52a80993c230f344b197/granian-2.7.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:49b6873f4a8ee7a1ea627ff98d67ecdd644cfc18aab475b2e15f651dbcbe4140", size = 4669023, upload-time = "2026-02-02T11:39:09.612Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/b8/832970d2d4b144b87be39f5b9dfd31fdb17f298dc238a0b2100c95002cf8/granian-2.7.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:5ac1843c6084933a54a07d9dcae643365f1d83aaff3fd4f2676ea301185e4e8b", size = 7082682, upload-time = "2026-02-24T23:03:23.875Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/69/49e54eb6ed67ccf471c19d4c65f64197dd5a416d501620519e28ea92c82e/granian-2.7.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:39778147c7527de0bcda12cd9c38863d4e6a80d3a8a96ddeb6fe2d1342f337db", size = 4896002, upload-time = "2026-02-02T11:39:10.996Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/bc/1521dbf026d1c9d2465cd54e016efd8ff6e1e72eff521071dab20dd61c44/granian-2.7.2-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:3612eb6a3f4351dd2c4df246ed0d21056c0556a6b1ed772dd865310aa55a9ba9", size = 7264742, upload-time = "2026-02-24T23:03:25.562Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/f1/a864a78029265d06a6fd61c760c8facf032be0d345deca5081718cbb006f/granian-2.7.0-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:8135d0a4574dc5a0acf3a815fc6cad5bbe9075ef86df2c091ec34fbd21639c1c", size = 5239945, upload-time = "2026-02-02T11:39:12.726Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/ae/00884ab77045a2f54db90932f9d1ca522201e2a6b2cf2a9b38840db0fd54/granian-2.7.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:34708b145e31b4538e0556704a07454a76d6776c55c5bc3a1335e80ef6b3bae3", size = 7062571, upload-time = "2026-02-24T23:03:27.278Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/33/feef40e4570b771d815c1ddd1008ccc9c0e81ce5a015deded6788e919f18/granian-2.7.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:47df2d9e50f22fa820b34fd38ceeeedc0b97994fa164425fa30e746759db8a44", size = 5078968, upload-time = "2026-02-02T11:39:14.048Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/4a/8ce622f4f7d58e035d121b9957dd5a8929028dc99cfc5d2bf7f2aa28912c/granian-2.7.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:592806c28c491f9c1d1501bac706ecf5e72b73969f20f912678d53308786d658", size = 6442041, upload-time = "2026-02-24T23:03:30.986Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/6a/b8d58474bbcbca450f030fd41b65c94ae0afb5e8f58c39fbea2df4efee2b/granian-2.7.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:23c6531b75c94c7b533812aed4f40dc93008c406cfa5629ec93397cd0f6770cb", size = 4569780, upload-time = "2026-02-02T11:39:16.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/62/7d36ed38a40a68c2856b6d2a6fedd40833e7f82eb90ba0d03f2d69ffadf5/granian-2.7.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9dcde3968b921654bde999468e97d03031f28668bc1fc145c81d8bedb0fb2a4", size = 6100793, upload-time = "2026-02-24T23:03:32.734Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/dc/a8b11425ebdf6cb58e1084fdb7759d853ca7f0b00376e4bb66300322f5d3/granian-2.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e4939b86f2b7918202ce56cb01c2efe20a393c742d41640b444e82c8b444b614", size = 4195285, upload-time = "2026-02-02T11:39:18.596Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/c5/17fea68f4cb280c217cbd65534664722c9c9b0138c2754e20c235d70b5f4/granian-2.7.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d4d78408283ec51f0fb00557856b4593947ad5b48287c04e1c22764a0ac28a5", size = 7119810, upload-time = "2026-02-24T23:03:34.807Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/b5/6cc0b94f997d93f4b1510b2d953f07a7f1d16a143d60b53e0e50b887fa12/granian-2.7.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38fa10adf3c4d50e31a08401e6701ee2488613d905bb316cad456e5ebad5aa81", size = 5121311, upload-time = "2026-02-02T11:39:20.092Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/76/35e240d107e0f158662652fd61191de4fb0c2c080e3786ca8f16c71547b7/granian-2.7.2-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d28b078e8087f794b83822055f95caf93d83b23f47f4efcd5e2f0f7a5d8a81", size = 6450789, upload-time = "2026-02-24T23:03:36.81Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/f9/df3d862874cf4b233f97253bb78991ae4f31179a5581beaa41a2100e3bce/granian-2.7.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9b366a9fd713a20321e668768b122b7b0140bfaeb3cb0557b6cb11dce827a4fb", size = 4557737, upload-time = "2026-02-02T11:39:21.992Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/55/a6d08cfecc808149a910e51c57883ab26fad69d922dc2e76fb2d87469e2d/granian-2.7.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ff7a93123ab339ba6cad51cc7141f8880ec47b152ce2491595bb08edda20106", size = 6902672, upload-time = "2026-02-24T23:03:38.655Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/7f/e3063368345f39188afe5baa1ab62fdd951097656cd83bec3964f91f6e66/granian-2.7.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a916413e0dcd5c6eaf7f7413a6d899f7ba53a988d08e3b3c7ab2e0b5fa687559", size = 5004108, upload-time = "2026-02-02T11:39:23.306Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/2e/c86d95f324248fcc5dcaf034c9f688b32f7a488f0b2a4a25e6673776107f/granian-2.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a52effb9889f0944f0353afd6ce5a9d9aa83826d44bbf3c8013e978a3d6ef7b7", size = 6964399, upload-time = "2026-02-24T23:03:40.459Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/eb/892bcc0cfc44ed791795bab251e0b6ed767397182bac134d9f0fcecc552e/granian-2.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e315adf24162294d35ca4bed66c8f66ac15a0696f2cb462e729122d148f6d958", size = 4823143, upload-time = "2026-02-02T11:39:24.696Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/4b/44fde33fe10245a3fba76bf843c387fad2d548244345115b9d87e1c40994/granian-2.7.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:76c987c3ca78bf7666ab053c3ed7e3af405af91b2e5ce2f1cf92634c1581e238", size = 7034929, upload-time = "2026-02-24T23:03:42.149Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/e0/ff8528bf620b6da7833171f6d30bfe4b4b1d6e7d155b634bd17590e0c4b4/granian-2.7.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:486f8785e716f76f96534aaba25acd5dee1a8398725ffd2a55f0833689c75933", size = 4926328, upload-time = "2026-02-02T11:39:26.111Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/76/38d205cb527046241a9ee4f51048bf44101c626ad4d2af16dd9d14dc1db6/granian-2.7.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:6590f8092c2bb6614e561ba771f084cbf72ecbc38dbf9849762ac38718085c29", size = 7259609, upload-time = "2026-02-24T23:03:43.852Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/f7/fb0a761d39245295660703a42e9448f3c04ce1f26b2f62e044d179167880/granian-2.7.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:0e5e2c1c6ff1501e3675e5237096b90b767f506bb0ef88594310b7b9eaa95532", size = 5281190, upload-time = "2026-02-02T11:39:27.68Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/37/04245c7259e65f1083ce193875c6c44da4c98604d3b00a264a74dd4f042b/granian-2.7.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7c1ce9b0c9446b680e9545e7fc95a75f0c53a25dedcf924b1750c3e5ba5bf908", size = 7073161, upload-time = "2026-02-24T23:03:45.655Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/d8/860e7e96ea109c6db431c8284040d265758bded35f9ce2de05f3969d7c0c/granian-2.7.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:d4418b417f9c2162b4fa9ec41ec34ed3e8ed891463bb058873034222be53542f", size = 5117989, upload-time = "2026-02-02T11:39:29.008Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/07/0e56fb4f178e14b4c1fa1f6f00586ca81761ccbe2d8803f2c12b6b17a7d6/granian-2.7.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a698d9b662d5648c8ae3dc01ad01688e1a8afc3525e431e7cddb841c53e5e291", size = 6415279, upload-time = "2026-02-24T23:03:48.932Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/9a/500ab01ae273870e8fc056956cc49716707b4a0e76fb2b5993258e1494f7/granian-2.7.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:b4367c088c00bdc38a8a495282070010914931edb4c488499f290c91018d9e80", size = 4492656, upload-time = "2026-02-02T11:39:31.614Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/bc/3e69305bf34806cd852f4683deec844a2cb9a4d8888d7f172b507f6080a8/granian-2.7.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:17516095b520b3c039ddbe41a6beb2c59d554b668cc229d36d82c93154a799af", size = 6090528, upload-time = "2026-02-24T23:03:50.52Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/26/86dc5a6fff60ee0cc38c2fcd1a0d4cebd52e6764a9f752a20458001ca57e/granian-2.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c8f3df224284ed1ff673f61de652337d7721100bf4cfc336b2047005b0edb2e0", size = 4122201, upload-time = "2026-02-02T11:39:33.162Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/10/7d58a922b44417a6207c0a3230b0841cd7385a36fc518ac15fed16ebf6f7/granian-2.7.2-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:96b0fd9eac60f939b3cbe44c8f32a42fdb7c1a1a9e07ca89e7795cdc7a606beb", size = 6252291, upload-time = "2026-02-24T23:03:52.248Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/60/887dc5a099135ff449adcdea9a2aa38f39673baf99de9acb78077b701432/granian-2.7.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6682c08b0d82ad75f8e9d1571254630133e1563c49f0600c2e2dc26cec743ae7", size = 4377489, upload-time = "2026-02-02T11:39:34.532Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/56/65776c6d759dcef9cce15bc11bdea2c64fe668088faf35d87916bd88f595/granian-2.7.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e50fb13e053384b8bd3823d4967606c6fd89f2b0d20e64de3ae212b85ffdfed2", size = 7106748, upload-time = "2026-02-24T23:03:53.994Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/6b/68c12f8c4c1f1c109bf55d66beeb37a817fd908af5d5d9b48afcbdc3e623/granian-2.7.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d6ccc3bdc2248775b6bd292d7d37a1bff79eb1aaf931f3a217ea9fb9a6fe7ca4", size = 5067294, upload-time = "2026-02-02T11:39:35.84Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/ee/d9ed836316607401f158ac264a3f770469d1b1edbf119402777a9eff1833/granian-2.7.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bb1ef13125bc05ab2e18869ed311beaeb085a4c4c195d55d0865f5753a4c0b4", size = 6778883, upload-time = "2026-02-24T23:03:55.574Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/4f/be4f9c129f5f80f52654f257abe91f647defec020fa134b3600013b7219d/granian-2.7.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5431272a4d6f49a200aeb7b01010a3785b93b9bd8cd813d98ed29c8e9ba1c476", size = 4848356, upload-time = "2026-02-02T11:39:37.443Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/46/eabab80e07a14527c336dec6d902329399f3ba2b82dc94b6435651021359/granian-2.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b1c77189335070c6ba6b8d158518fde4c50f892753620f0b22a7552ad4347143", size = 6903426, upload-time = "2026-02-24T23:03:57.296Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/aa/f6efcfb435f370a6f3626bd5837465bfb71950f6b3cb3c74e54b176c72e2/granian-2.7.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:790b150255576775672f26dbcbd6eb05f70260dd661b91ce462f6f3846db9501", size = 4669022, upload-time = "2026-02-02T11:39:38.782Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/8a/8ce186826066f6d453316229383a5be3b0b8a4130146c21f321ee64fe2cb/granian-2.7.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:1777166c3c853eed4440adb3cbbf34bba2b77d595bfc143a5826904a80b22f34", size = 7083877, upload-time = "2026-02-24T23:03:59.425Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/36/e86050c476046ef1f0aae0eb86d098fa787abfc8887a131c82baccc7565e/granian-2.7.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ce9be999273c181e4b65efbbd82a5bc6f223f1db3463660514d1dc229c8ba760", size = 4895567, upload-time = "2026-02-02T11:39:40.144Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/eb/91ed4646ce1c920ad39db0bcddb6f4755e1823002b14fb026104e3eb8bce/granian-2.7.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0ffac19208ae548f3647c849579b803beaed2b50dfb0f3790ad26daac0033484", size = 7267282, upload-time = "2026-02-24T23:04:01.218Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/5e/25283ff7fc12fcf42ae8a5687243119739cf4b0bf5ccb1c32d11d37987b1/granian-2.7.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:319b34f18ed3162354513acb5a9e8cee720ac166cd88fe05f0f057703eb47e4f", size = 5238652, upload-time = "2026-02-02T11:39:41.648Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/2f/58cba479254530ab09132e150e4ab55362f6e875d9e82b6790477843e0aa/granian-2.7.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:82f34e78c1297bf5a1b6a5097e30428db98b59fce60a7387977b794855c0c3bc", size = 7054941, upload-time = "2026-02-24T23:04:03.211Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/60/06148781120e086c7437aa9513198025ea1eb847cb2e244d5e2b9801782e/granian-2.7.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:b01bed8ad748840e7ab49373f642076f3bc459e39937a4ce11c5be03e67cdfd9", size = 5079018, upload-time = "2026-02-02T11:39:43.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/71/f21b26c7dc7a8bc9d8288552c9c12128e73f1c3f04799b6e28a0a269b9b0/granian-2.7.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5613ee8c1233a79e56e1735e19c8c70af22a8c6b5808d7c1423dc5387bee4c05", size = 6504773, upload-time = "2026-02-24T23:04:06.498Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/0b/39ebf1b791bbd4049239ecfee8f072321211879e5617a023921961be1d55/granian-2.7.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:24a1f6a894bea95ef0e603bebacbccd19c319c0da493bb4fde8b94b8629f3dc8", size = 4581648, upload-time = "2026-02-02T11:39:45.991Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/68/282fbf5418f9348f657f505dc744cdca70ac850d39a805b21395211bf099/granian-2.7.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0cd6fee79f585de2e1a90b6a311f62b3768c7cda649bc0e02908157ffa2553cc", size = 6138096, upload-time = "2026-02-24T23:04:09.138Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/cd/4642192520478bba4cd547124d92607c958a0786864ebe378f3008b40048/granian-2.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:c2799497ac896cffea85512983c5d9eb4ae51ebacd7a9a5fd3d2ac81f1755fac", size = 4214257, upload-time = "2026-02-02T11:39:47.507Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/e0/b578709020f84c07ad2ca88f77ac67fd2c62e6b16f93ff8c8d65b7d99296/granian-2.7.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e94c825f8b327114f7062d158c502a540ef5819f809e10158f0edddddaf41bb9", size = 6900043, upload-time = "2026-02-24T23:04:11.015Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/3f/615f93753c3b682219fe546196fc9eb3a045d846e57883312c97de4d785a/granian-2.7.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b66a15d004136e641706e0e5522b3509151e2027a0677cf4fa97d049d9ddfa41", size = 4979656, upload-time = "2026-02-02T11:39:48.838Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/2f/a2671cc160f29ccf8e605eb8fa113c01051b0d7947048c5b29eb4e603384/granian-2.7.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a6adea5fb8a537d18f3f2b848023151063bc45896415fdebfeb0bf0663d5a03b", size = 7040211, upload-time = "2026-02-24T23:04:13.31Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/68/1f2c36a964f93bfe8d6189431b8425acc591b735e47d8898b2e70c478398/granian-2.7.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:de5a6fa93d2138ba2372d20d97b87c1af75fa16a59a93841745326825c3ddf83", size = 4844448, upload-time = "2026-02-02T11:39:50.5Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/ce/df9bba3b211cda2d47535bb21bc040007e021e8c8adc20ce36619f903bc4/granian-2.7.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2392ab03cb92b1b2d4363f450b2d875177e10f0e22d67a4423052e6885e430f2", size = 7118085, upload-time = "2026-02-24T23:04:15.05Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/23/d8c83fe6a6656026c734c2ea771cbcdec6f0010e749f8ab0db1bfc8a3dfe/granian-2.7.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:aacda2ad46724490c4cd811b8dcadff2260603a3e95ca0d8c33552d791a3c6ac", size = 4930755, upload-time = "2026-02-02T11:39:51.866Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/87/37124b2ee0cddce6ba438b0ff879ddae094ae2c92b24b28ffbe35110931f/granian-2.7.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:406c0bb1f5bf55c72cfbfdfd2ccec21299eb3f7b311d85c4889dde357fd36f33", size = 7314667, upload-time = "2026-02-24T23:04:16.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/e5/2a86ee18544185e72fc50b50985b6bfb4504f7835875d2636f573e100071/granian-2.7.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:7efb5ebdb308ed1685a80cded6ea51447753e8afe92c21fc3abf9a06a9eb5d2e", size = 5295728, upload-time = "2026-02-02T11:39:53.364Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/ac/8b142ed352bc525e3c97440aab312928beebc735927b0cf979692bfcda3b/granian-2.7.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:362a6001daa2ce62532a49df407fe545076052ef29289a76d5760064d820f48b", size = 7004934, upload-time = "2026-02-24T23:04:19.059Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/bd/0d47d17769601c56d876b289456f27799611571227b99ad300e221600bbd/granian-2.7.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ae96b75420d01d9a7dbe1bd84f1898b2b0ade6883db59bfe2b233d7c28c6b0df", size = 5095149, upload-time = "2026-02-02T11:39:54.767Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -2958,10 +2958,10 @@ requires-dist = [
|
|||||||
{ name = "djangorestframework", specifier = "~=3.16" },
|
{ name = "djangorestframework", specifier = "~=3.16" },
|
||||||
{ name = "djangorestframework-guardian", specifier = "~=0.4.0" },
|
{ name = "djangorestframework-guardian", specifier = "~=0.4.0" },
|
||||||
{ name = "drf-spectacular", specifier = "~=0.28" },
|
{ name = "drf-spectacular", specifier = "~=0.28" },
|
||||||
{ name = "drf-spectacular-sidecar", specifier = "~=2026.3.1" },
|
{ name = "drf-spectacular-sidecar", specifier = "~=2026.1.1" },
|
||||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||||
{ name = "faiss-cpu", specifier = ">=1.10" },
|
{ name = "faiss-cpu", specifier = ">=1.10" },
|
||||||
{ name = "filelock", specifier = "~=3.25.2" },
|
{ name = "filelock", specifier = "~=3.20.3" },
|
||||||
{ name = "flower", specifier = "~=2.0.1" },
|
{ name = "flower", specifier = "~=2.0.1" },
|
||||||
{ name = "gotenberg-client", specifier = "~=0.13.1" },
|
{ name = "gotenberg-client", specifier = "~=0.13.1" },
|
||||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.7.0" },
|
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.7.0" },
|
||||||
@@ -2995,7 +2995,7 @@ requires-dist = [
|
|||||||
{ name = "rapidfuzz", specifier = "~=3.14.0" },
|
{ name = "rapidfuzz", specifier = "~=3.14.0" },
|
||||||
{ name = "redis", extras = ["hiredis"], specifier = "~=5.2.1" },
|
{ name = "redis", extras = ["hiredis"], specifier = "~=5.2.1" },
|
||||||
{ name = "regex", specifier = ">=2025.9.18" },
|
{ name = "regex", specifier = ">=2025.9.18" },
|
||||||
{ name = "scikit-learn", specifier = "~=1.8.0" },
|
{ name = "scikit-learn", specifier = "~=1.7.0" },
|
||||||
{ name = "sentence-transformers", specifier = ">=4.1" },
|
{ name = "sentence-transformers", specifier = ">=4.1" },
|
||||||
{ name = "setproctitle", specifier = "~=1.3.4" },
|
{ name = "setproctitle", specifier = "~=1.3.4" },
|
||||||
{ name = "tika-client", specifier = "~=0.10.0" },
|
{ name = "tika-client", specifier = "~=0.10.0" },
|
||||||
@@ -3011,7 +3011,7 @@ provides-extras = ["mariadb", "postgres", "webserver"]
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "daphne" },
|
{ name = "daphne" },
|
||||||
{ name = "factory-boy", specifier = "~=3.3.1" },
|
{ name = "factory-boy", specifier = "~=3.3.1" },
|
||||||
{ name = "faker", specifier = "~=40.8.0" },
|
{ name = "faker", specifier = "~=40.5.1" },
|
||||||
{ name = "imagehash" },
|
{ name = "imagehash" },
|
||||||
{ name = "prek", specifier = "~=0.3.0" },
|
{ name = "prek", specifier = "~=0.3.0" },
|
||||||
{ name = "pytest", specifier = "~=9.0.0" },
|
{ name = "pytest", specifier = "~=9.0.0" },
|
||||||
@@ -3034,7 +3034,7 @@ lint = [
|
|||||||
testing = [
|
testing = [
|
||||||
{ name = "daphne" },
|
{ name = "daphne" },
|
||||||
{ name = "factory-boy", specifier = "~=3.3.1" },
|
{ name = "factory-boy", specifier = "~=3.3.1" },
|
||||||
{ name = "faker", specifier = "~=40.8.0" },
|
{ name = "faker", specifier = "~=40.5.1" },
|
||||||
{ name = "imagehash" },
|
{ name = "imagehash" },
|
||||||
{ name = "pytest", specifier = "~=9.0.0" },
|
{ name = "pytest", specifier = "~=9.0.0" },
|
||||||
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
||||||
@@ -3657,15 +3657,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrefly"
|
name = "pyrefly"
|
||||||
version = "0.55.0"
|
version = "0.54.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/c4/76e0797215e62d007f81f86c9c4fb5d6202685a3f5e70810f3fd94294f92/pyrefly-0.55.0.tar.gz", hash = "sha256:434c3282532dd4525c4840f2040ed0eb79b0ec8224fe18d957956b15471f2441", size = 5135682, upload-time = "2026-03-03T00:46:38.122Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/81/44/c10b16a302fda90d0af1328f880b232761b510eab546616a7be2fdf35a57/pyrefly-0.54.0.tar.gz", hash = "sha256:c6663be64d492f0d2f2a411ada9f28a6792163d34133639378b7f3dd9a8dca94", size = 5098893, upload-time = "2026-02-23T15:44:35.111Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/b0/16e50cf716784513648e23e726a24f71f9544aa4f86103032dcaa5ff71a2/pyrefly-0.55.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:49aafcefe5e2dd4256147db93e5b0ada42bff7d9a60db70e03d1f7055338eec9", size = 12210073, upload-time = "2026-03-03T00:46:15.51Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/99/8fdcdb4e55f0227fdd9f6abce36b619bab1ecb0662b83b66adc8cba3c788/pyrefly-0.54.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:58a3f092b6dc25ef79b2dc6c69a40f36784ca157c312bfc0baea463926a9db6d", size = 12223973, upload-time = "2026-02-23T15:44:14.278Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/ad/89500c01bac3083383011600370289fbc67700c5be46e781787392628a3a/pyrefly-0.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2827426e6b28397c13badb93c0ede0fb0f48046a7a89e3d774cda04e8e2067cd", size = 11767474, upload-time = "2026-03-03T00:46:18.003Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/35/c2aaf87a76003ad27b286594d2e5178f811eaa15bfe3d98dba2b47d56dd1/pyrefly-0.54.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:615081414106dd95873bc39c3a4bed68754c6cc24a8177ac51d22f88f88d3eb3", size = 11785585, upload-time = "2026-02-23T15:44:17.468Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/68/4c66b260f817f304ead11176ff13985625f7c269e653304b4bdb546551af/pyrefly-0.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7346b2d64dc575bd61aa3bca854fbf8b5a19a471cbdb45e0ca1e09861b63488c", size = 33260395, upload-time = "2026-03-03T00:46:20.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/4a/ced02691ed67e5a897714979196f08ad279ec7ec7f63c45e00a75a7f3c0e/pyrefly-0.54.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbcaf20f5fe585079079a95205c1f3cd4542d17228cdf1df560288880623b70", size = 33381977, upload-time = "2026-02-23T15:44:19.736Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/09/10bd48c9f860064f29f412954126a827d60f6451512224912c265e26bbe6/pyrefly-0.55.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:233b861b4cff008b1aff62f4f941577ed752e4d0060834229eb9b6826e6973c9", size = 35848269, upload-time = "2026-03-03T00:46:23.418Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/ce/72a117ed437c8f6950862181014b41e36f3c3997580e29b772b71e78d587/pyrefly-0.54.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d5da116c0d34acfbd66663addd3ca8aa78a636f6692a66e078126d3620a883", size = 35962821, upload-time = "2026-02-23T15:44:22.357Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/39/bc65cdd5243eb2dfea25dd1321f9a5a93e8d9c3a308501c4c6c05d011585/pyrefly-0.55.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5aa85657d76da1d25d081a49f0e33c8fc3ec91c1a0f185a8ed393a5a3d9e178", size = 38449820, upload-time = "2026-03-03T00:46:26.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/de/89013f5ae0a35d2b6b01274a92a35ee91431ea001050edf0a16748d39875/pyrefly-0.54.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef3ac27f1a4baaf67aead64287d3163350844794aca6315ad1a9650b16ec26a", size = 38496689, upload-time = "2026-02-23T15:44:25.236Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4283,7 +4283,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scikit-learn"
|
name = "scikit-learn"
|
||||||
version = "1.8.0"
|
version = "1.7.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "joblib", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "joblib", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
@@ -4291,32 +4291,28 @@ dependencies = [
|
|||||||
{ name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "threadpoolctl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "threadpoolctl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4813,16 +4809,18 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tornado"
|
name = "tornado"
|
||||||
version = "6.5.5"
|
version = "6.5.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
{ url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user