mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-26 19:02:45 +00:00
Compare commits
3 Commits
dev
...
chore/plug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a192d021f | ||
|
|
1e30490a46 | ||
|
|
bd9e529a63 |
3
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -21,6 +21,7 @@ body:
|
|||||||
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
|
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
|
||||||
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
|
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
|
||||||
- Disable any custom container initialization scripts, if using
|
- Disable any custom container initialization scripts, if using
|
||||||
|
- Remove any third-party parser plugins — issues caused by or requiring changes to a third-party plugin will be closed without investigation.
|
||||||
|
|
||||||
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
|
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -120,5 +121,7 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have already searched for relevant existing issues and discussions before opening this report.
|
- label: I have already searched for relevant existing issues and discussions before opening this report.
|
||||||
required: true
|
required: true
|
||||||
|
- label: I have reproduced this issue with all third-party parser plugins removed. I understand that issues caused by third-party plugins will be closed without investigation.
|
||||||
|
required: true
|
||||||
- label: I have updated the title field above with a concise description.
|
- label: I have updated the title field above with a concise description.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -723,6 +723,81 @@ services:
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
## Installing third-party parser plugins {#parser-plugins}
|
||||||
|
|
||||||
|
Third-party parser plugins extend Paperless-ngx to support additional file
|
||||||
|
formats. A plugin is a Python package that advertises itself under the
|
||||||
|
`paperless_ngx.parsers` entry point group. Refer to the
|
||||||
|
[developer documentation](development.md#making-custom-parsers) for how to
|
||||||
|
create one.
|
||||||
|
|
||||||
|
!!! warning "Third-party plugins are not officially supported"
|
||||||
|
|
||||||
|
The Paperless-ngx maintainers do not provide support for third-party
|
||||||
|
plugins. Issues caused by or requiring changes to a third-party plugin
|
||||||
|
will be closed without further investigation. Always reproduce problems
|
||||||
|
with all plugins removed before filing a bug report.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Use a [custom container initialization script](#custom-container-initialization)
|
||||||
|
to install the package before the webserver starts. Create a shell script and
|
||||||
|
mount it into `/custom-cont-init.d`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# /path/to/my/scripts/install-parsers.sh
|
||||||
|
|
||||||
|
pip install my-paperless-parser-package
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it in your `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
webserver:
|
||||||
|
# ...
|
||||||
|
volumes:
|
||||||
|
- /path/to/my/scripts:/custom-cont-init.d:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
The script runs as `root` before the webserver starts, so the package will be
|
||||||
|
available when Paperless-ngx discovers plugins at startup.
|
||||||
|
|
||||||
|
### Bare metal
|
||||||
|
|
||||||
|
Install the package into the same Python environment that runs Paperless-ngx.
|
||||||
|
If you followed the standard bare-metal install guide, that is the `paperless`
|
||||||
|
user's environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -Hu paperless pip3 install my-paperless-parser-package
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using `uv` or a virtual environment, activate it first and then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv pip install my-paperless-parser-package
|
||||||
|
# or
|
||||||
|
pip install my-paperless-parser-package
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart all Paperless-ngx services after installation so the new plugin is
|
||||||
|
discovered.
|
||||||
|
|
||||||
|
### Verifying installation
|
||||||
|
|
||||||
|
On the next startup, check the application logs for a line confirming
|
||||||
|
discovery:
|
||||||
|
|
||||||
|
```
|
||||||
|
Loaded third-party parser 'My Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
|
||||||
|
```
|
||||||
|
|
||||||
|
If this line does not appear, verify that the package is installed in the
|
||||||
|
correct environment and that its `pyproject.toml` declares the
|
||||||
|
`paperless_ngx.parsers` entry point.
|
||||||
|
|
||||||
## MySQL Caveats {#mysql-caveats}
|
## MySQL Caveats {#mysql-caveats}
|
||||||
|
|
||||||
### Case Sensitivity
|
### Case Sensitivity
|
||||||
|
|||||||
@@ -370,121 +370,363 @@ docker build --file Dockerfile --tag paperless:local .
|
|||||||
|
|
||||||
## Extending Paperless-ngx
|
## Extending Paperless-ngx
|
||||||
|
|
||||||
Paperless-ngx does not have any fancy plugin systems and will probably never
|
Paperless-ngx supports third-party document parsers via a Python entry point
|
||||||
have. However, some parts of the application have been designed to allow
|
plugin system. Plugins are distributed as ordinary Python packages and
|
||||||
easy integration of additional features without any modification to the
|
discovered automatically at startup — no changes to the Paperless-ngx source
|
||||||
base code.
|
are required.
|
||||||
|
|
||||||
|
!!! warning "Third-party plugins are not officially supported"
|
||||||
|
|
||||||
|
The Paperless-ngx maintainers do not provide support for third-party
|
||||||
|
plugins. Issues that are caused by or require changes to a third-party
|
||||||
|
plugin will be closed without further investigation. If you believe you
|
||||||
|
have found a bug in Paperless-ngx itself (not in a plugin), please
|
||||||
|
reproduce it with all third-party plugins removed before filing an issue.
|
||||||
|
|
||||||
### Making custom parsers
|
### Making custom parsers
|
||||||
|
|
||||||
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
|
- Extracting plain-text content from the document
|
||||||
- Creating a thumbnail
|
- Generating a thumbnail image
|
||||||
- _optional:_ Retrieving a created date from the original
|
- _optional:_ Detecting the document's creation date
|
||||||
- _optional:_ Creating an archived document from the original
|
- _optional:_ Producing a searchable PDF archive copy
|
||||||
|
|
||||||
Custom parsers can be added to Paperless-ngx to support more file types. In
|
Custom parsers are distributed as ordinary Python packages and registered
|
||||||
order to do that, you need to write the parser itself and announce its
|
via a [setuptools entry point](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
|
||||||
existence to Paperless-ngx.
|
No changes to the Paperless-ngx source are required.
|
||||||
|
|
||||||
The parser itself must extend `documents.parsers.DocumentParser` and
|
#### 1. Implementing the parser class
|
||||||
must implement the methods `parse` and `get_thumbnail`. You can provide
|
|
||||||
your own implementation to `get_date` if you don't want to rely on
|
Your parser must satisfy the `ParserProtocol` structural interface defined in
|
||||||
Paperless-ngx' default date guessing mechanisms.
|
`paperless.parsers`. The simplest approach is to write a plain class — no base
|
||||||
|
class is required, only the right attributes and methods.
|
||||||
|
|
||||||
|
**Class-level identity attributes**
|
||||||
|
|
||||||
|
The registry reads these before instantiating the parser, so they must be
|
||||||
|
plain class attributes (not instance attributes or properties):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class MyCustomParser(DocumentParser):
|
class MyCustomParser:
|
||||||
|
name = "My Format Parser" # human-readable name shown in logs
|
||||||
def parse(self, document_path, mime_type):
|
version = "1.0.0" # semantic version string
|
||||||
# This method does not return anything. Rather, you should assign
|
author = "Acme Corp" # author / organisation
|
||||||
# whatever you got from the document to the following fields:
|
url = "https://example.com/my-parser" # docs or issue tracker
|
||||||
|
|
||||||
# The content of the document.
|
|
||||||
self.text = "content"
|
|
||||||
|
|
||||||
# Optional: path to a PDF document that you created from the original.
|
|
||||||
self.archive_path = os.path.join(self.tempdir, "archived.pdf")
|
|
||||||
|
|
||||||
# Optional: "created" date of the document.
|
|
||||||
self.date = get_created_from_metadata(document_path)
|
|
||||||
|
|
||||||
def get_thumbnail(self, document_path, mime_type):
|
|
||||||
# This should return the path to a thumbnail you created for this
|
|
||||||
# document.
|
|
||||||
return os.path.join(self.tempdir, "thumb.webp")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you encounter any issues during parsing, raise a
|
**Declaring supported MIME types**
|
||||||
`documents.parsers.ParseError`.
|
|
||||||
|
|
||||||
The `self.tempdir` directory is a temporary directory that is guaranteed
|
Return a `dict` mapping MIME type strings to preferred file extensions
|
||||||
to be empty and removed after consumption finished. You can use that
|
(including the leading dot). Paperless-ngx uses the extension when storing
|
||||||
directory to store any intermediate files and also use it to store the
|
archive copies and serving files for download.
|
||||||
thumbnail / archived document.
|
|
||||||
|
|
||||||
After that, you need to announce your parser to Paperless-ngx. You need to
|
|
||||||
connect a handler to the `document_consumer_declaration` signal. Have a
|
|
||||||
look in the file `src/paperless_tesseract/apps.py` on how that's done.
|
|
||||||
The handler is a method that returns information about your parser:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def myparser_consumer_declaration(sender, **kwargs):
|
@classmethod
|
||||||
|
def supported_mime_types(cls) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"parser": MyCustomParser,
|
"application/x-my-format": ".myf",
|
||||||
"weight": 0,
|
"application/x-my-format-alt": ".myf",
|
||||||
"mime_types": {
|
|
||||||
"application/pdf": ".pdf",
|
|
||||||
"image/jpeg": ".jpg",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `parser` is a reference to a class that extends `DocumentParser`.
|
**Scoring**
|
||||||
- `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
|
|
||||||
override the parsers provided by Paperless-ngx.
|
|
||||||
- `mime_types` is a dictionary. The keys are the mime types your
|
|
||||||
parser supports and the value is the default file extension that
|
|
||||||
Paperless-ngx should use when storing files and serving them for
|
|
||||||
download. We could guess that from the file extensions, but some
|
|
||||||
mime types have many extensions associated with them and the Python
|
|
||||||
methods responsible for guessing the extension do not always return
|
|
||||||
the same value.
|
|
||||||
|
|
||||||
## Using Visual Studio Code devcontainer
|
When more than one parser can handle a file, the registry calls `score()` on
|
||||||
|
each candidate and picks the one with the highest result. Return `None` to
|
||||||
|
decline handling a file even though the MIME type is listed as supported (for
|
||||||
|
example, when a required external service is not configured).
|
||||||
|
|
||||||
Another easy way to get started with development is to use Visual Studio
|
| Score | Meaning |
|
||||||
Code devcontainers. This approach will create a preconfigured development
|
| ------ | ------------------------------------------------- |
|
||||||
environment with all of the required tools and dependencies.
|
| `None` | Decline — do not handle this file |
|
||||||
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
|
| `10` | Default priority used by all built-in parsers |
|
||||||
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
|
| `> 10` | Override a built-in parser for the same MIME type |
|
||||||
contain more information about the specific tasks and launch configurations (see the
|
|
||||||
non-standard "description" field).
|
|
||||||
|
|
||||||
To get started:
|
```python
|
||||||
|
@classmethod
|
||||||
|
def score(
|
||||||
|
cls,
|
||||||
|
mime_type: str,
|
||||||
|
filename: str,
|
||||||
|
path: "Path | None" = None,
|
||||||
|
) -> int | None:
|
||||||
|
# Inspect filename or file bytes here if needed.
|
||||||
|
return 10
|
||||||
|
```
|
||||||
|
|
||||||
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
|
**Archive and rendition flags**
|
||||||
|
|
||||||
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
```python
|
||||||
|
@property
|
||||||
|
def can_produce_archive(self) -> bool:
|
||||||
|
"""True if parse() can produce a searchable PDF archive copy."""
|
||||||
|
return True # or False if your parser doesn't produce PDFs
|
||||||
|
|
||||||
3. In case your host operating system is Windows:
|
@property
|
||||||
- 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.
|
def requires_pdf_rendition(self) -> bool:
|
||||||
- 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.
|
"""True if the original format cannot be displayed by a browser
|
||||||
|
(e.g. DOCX, ODT) and the PDF output must always be kept."""
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
**Context manager — temp directory lifecycle**
|
||||||
will initialize the database tables and create a superuser. Then you can compile the front end
|
|
||||||
for production or run the frontend in debug mode.
|
|
||||||
|
|
||||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
Paperless-ngx always uses parsers as context managers. Create a temporary
|
||||||
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
working directory in `__enter__` (or `__init__`) and remove it in `__exit__`
|
||||||
|
regardless of whether an exception occurred. Store intermediate files,
|
||||||
|
thumbnails, and archive PDFs inside this directory.
|
||||||
|
|
||||||
## Developing Date Parser Plugins
|
```python
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Self
|
||||||
|
from types import TracebackType
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class MyCustomParser:
|
||||||
|
...
|
||||||
|
|
||||||
|
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
|
||||||
|
self._archive_path: Path | 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:
|
||||||
|
shutil.rmtree(self._tempdir, ignore_errors=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional context — `configure()`**
|
||||||
|
|
||||||
|
The consumer calls `configure()` with a `ParserContext` after instantiation
|
||||||
|
and before `parse()`. If your parser doesn't need context, a no-op
|
||||||
|
implementation is fine:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from paperless.parsers import ParserContext
|
||||||
|
|
||||||
|
def configure(self, context: ParserContext) -> None:
|
||||||
|
pass # override if you need context.mailrule_id, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parsing**
|
||||||
|
|
||||||
|
`parse()` is the core method. It must not return a value; instead, store
|
||||||
|
results in instance attributes and expose them via the accessor methods below.
|
||||||
|
Raise `documents.parsers.ParseError` on any unrecoverable failure.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from documents.parsers import ParseError
|
||||||
|
|
||||||
|
def parse(
|
||||||
|
self,
|
||||||
|
document_path: Path,
|
||||||
|
mime_type: str,
|
||||||
|
*,
|
||||||
|
produce_archive: bool = True,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
self._text = extract_text_from_my_format(document_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise ParseError(f"Failed to parse {document_path}: {e}") from e
|
||||||
|
|
||||||
|
if produce_archive and self.can_produce_archive:
|
||||||
|
archive = self._tempdir / "archived.pdf"
|
||||||
|
convert_to_pdf(document_path, archive)
|
||||||
|
self._archive_path = archive
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result accessors**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_text(self) -> str | None:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def get_date(self) -> "datetime.datetime | None":
|
||||||
|
# Return a datetime extracted from the document, or None to let
|
||||||
|
# Paperless-ngx use its default date-guessing logic.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_archive_path(self) -> Path | None:
|
||||||
|
return self._archive_path
|
||||||
|
```
|
||||||
|
|
||||||
|
**Thumbnail**
|
||||||
|
|
||||||
|
`get_thumbnail()` may be called independently of `parse()`. Return the path
|
||||||
|
to a WebP image inside `self._tempdir`. The image should be roughly 500 × 700
|
||||||
|
pixels.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
|
||||||
|
thumb = self._tempdir / "thumb.webp"
|
||||||
|
render_thumbnail(document_path, thumb)
|
||||||
|
return thumb
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional methods**
|
||||||
|
|
||||||
|
These are called by the API on demand, not during the consumption pipeline.
|
||||||
|
Implement them if your format supports the information; otherwise return
|
||||||
|
`None` / `[]`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
|
||||||
|
return count_pages(document_path)
|
||||||
|
|
||||||
|
def extract_metadata(
|
||||||
|
self,
|
||||||
|
document_path: Path,
|
||||||
|
mime_type: str,
|
||||||
|
) -> "list[MetadataEntry]":
|
||||||
|
# Must never raise. Return [] if metadata cannot be read.
|
||||||
|
from paperless.parsers import MetadataEntry
|
||||||
|
return [
|
||||||
|
MetadataEntry(
|
||||||
|
namespace="https://example.com/ns/",
|
||||||
|
prefix="ex",
|
||||||
|
key="Author",
|
||||||
|
value="Alice",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Registering via entry point
|
||||||
|
|
||||||
|
Add the following to your package's `pyproject.toml`. The key (left of `=`)
|
||||||
|
is an arbitrary name used only in log output; the value is the
|
||||||
|
`module:ClassName` import path.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.entry-points."paperless_ngx.parsers"]
|
||||||
|
my_parser = "my_package.parsers:MyCustomParser"
|
||||||
|
```
|
||||||
|
|
||||||
|
Install your package into the same Python environment as Paperless-ngx (or
|
||||||
|
add it to the Docker image), and the parser will be discovered automatically
|
||||||
|
on the next startup. No configuration changes are needed.
|
||||||
|
|
||||||
|
To verify discovery, check the application logs at startup for a line like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Loaded third-party parser 'My Format Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Utilities
|
||||||
|
|
||||||
|
`paperless.parsers.utils` provides helpers you can import directly:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| `read_file_handle_unicode_errors(path)` | Read a file as UTF-8, replacing invalid bytes instead of raising |
|
||||||
|
| `get_page_count_for_pdf(path)` | Count pages in a PDF using pikepdf |
|
||||||
|
| `extract_pdf_metadata(path)` | Extract XMP metadata from a PDF as a `list[MetadataEntry]` |
|
||||||
|
|
||||||
|
#### Minimal example
|
||||||
|
|
||||||
|
A complete, working parser for a hypothetical plain-XML format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Self
|
||||||
|
from types import TracebackType
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from documents.parsers import ParseError
|
||||||
|
from paperless.parsers import ParserContext
|
||||||
|
|
||||||
|
|
||||||
|
class XmlDocumentParser:
|
||||||
|
name = "XML Parser"
|
||||||
|
version = "1.0.0"
|
||||||
|
author = "Acme Corp"
|
||||||
|
url = "https://example.com/xml-parser"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def supported_mime_types(cls) -> dict[str, str]:
|
||||||
|
return {"application/xml": ".xml", "text/xml": ".xml"}
|
||||||
|
|
||||||
|
@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 __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, exc_val, exc_tb) -> None:
|
||||||
|
shutil.rmtree(self._tempdir, ignore_errors=True)
|
||||||
|
|
||||||
|
def configure(self, context: ParserContext) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def parse(self, document_path: Path, mime_type: str, *, produce_archive: bool = True) -> None:
|
||||||
|
try:
|
||||||
|
tree = ET.parse(document_path)
|
||||||
|
self._text = " ".join(tree.getroot().itertext())
|
||||||
|
except ET.ParseError as e:
|
||||||
|
raise ParseError(f"XML parse error: {e}") from e
|
||||||
|
|
||||||
|
def get_text(self) -> str | None:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def get_date(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_archive_path(self) -> Path | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
img = Image.new("RGB", (500, 700), color="white")
|
||||||
|
ImageDraw.Draw(img).text((10, 10), "XML Document", fill="black")
|
||||||
|
out = self._tempdir / "thumb.webp"
|
||||||
|
img.save(out, format="WEBP")
|
||||||
|
return out
|
||||||
|
|
||||||
|
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 []
|
||||||
|
```
|
||||||
|
|
||||||
|
### Developing date parser plugins
|
||||||
|
|
||||||
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
|
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
|
||||||
|
|
||||||
### Creating a Date Parser Plugin
|
#### Creating a Date Parser Plugin
|
||||||
|
|
||||||
To create a custom date parser plugin, you need to:
|
To create a custom date parser plugin, you need to:
|
||||||
|
|
||||||
@@ -492,7 +734,7 @@ To create a custom date parser plugin, you need to:
|
|||||||
2. Implement the required abstract method
|
2. Implement the required abstract method
|
||||||
3. Register your plugin via an entry point
|
3. Register your plugin via an entry point
|
||||||
|
|
||||||
#### 1. Implementing the Parser Class
|
##### 1. Implementing the Parser Class
|
||||||
|
|
||||||
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
|
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
|
||||||
|
|
||||||
@@ -532,7 +774,7 @@ class MyDateParserPlugin(DateParserPluginBase):
|
|||||||
yield another_datetime
|
yield another_datetime
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Configuration and Helper Methods
|
##### 2. Configuration and Helper Methods
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -565,11 +807,11 @@ def _filter_date(
|
|||||||
"""
|
"""
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Resource Management (Optional)
|
##### 3. Resource Management (Optional)
|
||||||
|
|
||||||
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
|
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
|
||||||
|
|
||||||
#### 4. Registering Your Plugin
|
##### 4. Registering Your Plugin
|
||||||
|
|
||||||
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
|
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
|
||||||
|
|
||||||
@@ -580,7 +822,7 @@ my_parser = "my_package.parsers:MyDateParserPlugin"
|
|||||||
|
|
||||||
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
|
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
|
||||||
|
|
||||||
### Plugin Discovery
|
#### Plugin Discovery
|
||||||
|
|
||||||
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
|
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
|
||||||
|
|
||||||
@@ -591,7 +833,7 @@ Paperless-ngx automatically discovers and loads date parser plugins at runtime.
|
|||||||
|
|
||||||
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
|
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
|
||||||
|
|
||||||
### Example: Simple Date Parser
|
#### Example: Simple Date Parser
|
||||||
|
|
||||||
Here's a minimal example that only looks for ISO 8601 dates:
|
Here's a minimal example that only looks for ISO 8601 dates:
|
||||||
|
|
||||||
@@ -623,3 +865,30 @@ class ISODateParserPlugin(DateParserPluginBase):
|
|||||||
if filtered_date is not None:
|
if filtered_date is not None:
|
||||||
yield filtered_date
|
yield filtered_date
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Using Visual Studio Code devcontainer
|
||||||
|
|
||||||
|
Another easy way to get started with development is to use Visual Studio
|
||||||
|
Code devcontainers. This approach will create a preconfigured development
|
||||||
|
environment with all of the required tools and dependencies.
|
||||||
|
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
|
||||||
|
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
|
||||||
|
contain more information about the specific tasks and launch configurations (see the
|
||||||
|
non-standard "description" field).
|
||||||
|
|
||||||
|
To get started:
|
||||||
|
|
||||||
|
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
|
||||||
|
|
||||||
|
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:
|
||||||
|
- 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
|
||||||
|
will initialize the database tables and create a superuser. Then you can compile the front end
|
||||||
|
for production or run the frontend in debug mode.
|
||||||
|
|
||||||
|
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
||||||
|
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
||||||
|
|||||||
@@ -297,11 +297,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">88</context>
|
<context context-type="linenumber">87</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">90</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
|
||||||
@@ -324,11 +324,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">95</context>
|
<context context-type="linenumber">94</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">97</context>
|
<context context-type="linenumber">96</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
|
||||||
@@ -375,15 +375,15 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">55</context>
|
<context context-type="linenumber">54</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">274</context>
|
<context context-type="linenumber">273</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">276</context>
|
<context context-type="linenumber">275</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5890330709052835856" datatype="html">
|
<trans-unit id="5890330709052835856" datatype="html">
|
||||||
@@ -728,11 +728,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">309</context>
|
<context context-type="linenumber">308</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">312</context>
|
<context context-type="linenumber">311</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2272120016352772836" datatype="html">
|
<trans-unit id="2272120016352772836" datatype="html">
|
||||||
@@ -1139,11 +1139,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">234</context>
|
<context context-type="linenumber">233</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">236</context>
|
<context context-type="linenumber">235</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
||||||
@@ -1700,7 +1700,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">205</context>
|
<context context-type="linenumber">204</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
|
||||||
@@ -1782,15 +1782,15 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">164</context>
|
<context context-type="linenumber">156</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">238</context>
|
<context context-type="linenumber">230</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">263</context>
|
<context context-type="linenumber">255</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2991443309752293110" datatype="html">
|
<trans-unit id="2991443309752293110" datatype="html">
|
||||||
@@ -1801,11 +1801,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">297</context>
|
<context context-type="linenumber">296</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">299</context>
|
<context context-type="linenumber">298</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="103921551219467537" datatype="html">
|
<trans-unit id="103921551219467537" datatype="html">
|
||||||
@@ -2224,11 +2224,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">257</context>
|
<context context-type="linenumber">256</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">260</context>
|
<context context-type="linenumber">259</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3818027200170621545" datatype="html">
|
<trans-unit id="3818027200170621545" datatype="html">
|
||||||
@@ -2581,11 +2581,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">288</context>
|
<context context-type="linenumber">287</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">290</context>
|
<context context-type="linenumber">289</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4569276013106377105" datatype="html">
|
<trans-unit id="4569276013106377105" datatype="html">
|
||||||
@@ -2897,90 +2897,90 @@
|
|||||||
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
|
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">47</context>
|
<context context-type="linenumber">46</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2127032578120864096" datatype="html">
|
<trans-unit id="2127032578120864096" datatype="html">
|
||||||
<source>My Profile</source>
|
<source>My Profile</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">51</context>
|
<context context-type="linenumber">50</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3797778920049399855" datatype="html">
|
<trans-unit id="3797778920049399855" datatype="html">
|
||||||
<source>Logout</source>
|
<source>Logout</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">58</context>
|
<context context-type="linenumber">57</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4895326106573044490" datatype="html">
|
<trans-unit id="4895326106573044490" datatype="html">
|
||||||
<source>Documentation</source>
|
<source>Documentation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">63</context>
|
<context context-type="linenumber">62</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">318</context>
|
<context context-type="linenumber">317</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">321</context>
|
<context context-type="linenumber">320</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="472206565520537964" datatype="html">
|
<trans-unit id="472206565520537964" datatype="html">
|
||||||
<source>Saved views</source>
|
<source>Saved views</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">105</context>
|
<context context-type="linenumber">104</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">135</context>
|
<context context-type="linenumber">134</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6988090220128974198" datatype="html">
|
<trans-unit id="6988090220128974198" datatype="html">
|
||||||
<source>Open documents</source>
|
<source>Open documents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">144</context>
|
<context context-type="linenumber">143</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5687256342387781369" datatype="html">
|
<trans-unit id="5687256342387781369" datatype="html">
|
||||||
<source>Close all</source>
|
<source>Close all</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">164</context>
|
<context context-type="linenumber">163</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">166</context>
|
<context context-type="linenumber">165</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3897348120591552265" datatype="html">
|
<trans-unit id="3897348120591552265" datatype="html">
|
||||||
<source>Manage</source>
|
<source>Manage</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">175</context>
|
<context context-type="linenumber">174</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8008131619909556709" datatype="html">
|
<trans-unit id="8008131619909556709" datatype="html">
|
||||||
<source>Attributes</source>
|
<source>Attributes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">182</context>
|
<context context-type="linenumber">181</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">184</context>
|
<context context-type="linenumber">183</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7437910965833684826" datatype="html">
|
<trans-unit id="7437910965833684826" datatype="html">
|
||||||
<source>Correspondents</source>
|
<source>Correspondents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">210</context>
|
<context context-type="linenumber">209</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
|
||||||
@@ -2995,7 +2995,7 @@
|
|||||||
<source>Document types</source>
|
<source>Document types</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">215</context>
|
<context context-type="linenumber">214</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
||||||
@@ -3006,7 +3006,7 @@
|
|||||||
<source>Storage paths</source>
|
<source>Storage paths</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">220</context>
|
<context context-type="linenumber">219</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
||||||
@@ -3017,7 +3017,7 @@
|
|||||||
<source>Custom fields</source>
|
<source>Custom fields</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">225</context>
|
<context context-type="linenumber">224</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||||
@@ -3040,11 +3040,11 @@
|
|||||||
<source>Workflows</source>
|
<source>Workflows</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">243</context>
|
<context context-type="linenumber">242</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">245</context>
|
<context context-type="linenumber">244</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
@@ -3055,92 +3055,92 @@
|
|||||||
<source>Mail</source>
|
<source>Mail</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">250</context>
|
<context context-type="linenumber">249</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">253</context>
|
<context context-type="linenumber">252</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7844706011418789951" datatype="html">
|
<trans-unit id="7844706011418789951" datatype="html">
|
||||||
<source>Administration</source>
|
<source>Administration</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">268</context>
|
<context context-type="linenumber">267</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3008420115644088420" datatype="html">
|
<trans-unit id="3008420115644088420" datatype="html">
|
||||||
<source>Configuration</source>
|
<source>Configuration</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">281</context>
|
<context context-type="linenumber">280</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">283</context>
|
<context context-type="linenumber">282</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1534029177398918729" datatype="html">
|
<trans-unit id="1534029177398918729" datatype="html">
|
||||||
<source>GitHub</source>
|
<source>GitHub</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">328</context>
|
<context context-type="linenumber">327</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4112664765954374539" datatype="html">
|
<trans-unit id="4112664765954374539" datatype="html">
|
||||||
<source>is available.</source>
|
<source>is available.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">337,338</context>
|
<context context-type="linenumber">336,337</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1175891574282637937" datatype="html">
|
<trans-unit id="1175891574282637937" datatype="html">
|
||||||
<source>Click to view.</source>
|
<source>Click to view.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">338</context>
|
<context context-type="linenumber">337</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9811291095862612" datatype="html">
|
<trans-unit id="9811291095862612" datatype="html">
|
||||||
<source>Paperless-ngx can automatically check for updates</source>
|
<source>Paperless-ngx can automatically check for updates</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">342</context>
|
<context context-type="linenumber">341</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="894819944961861800" datatype="html">
|
<trans-unit id="894819944961861800" datatype="html">
|
||||||
<source> How does this work? </source>
|
<source> How does this work? </source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">349,351</context>
|
<context context-type="linenumber">348,350</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="509090351011426949" datatype="html">
|
<trans-unit id="509090351011426949" datatype="html">
|
||||||
<source>Update available</source>
|
<source>Update available</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">362</context>
|
<context context-type="linenumber">361</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1542489069631984294" datatype="html">
|
<trans-unit id="1542489069631984294" datatype="html">
|
||||||
<source>Sidebar views updated</source>
|
<source>Sidebar views updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">383</context>
|
<context context-type="linenumber">343</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3547923076537026828" datatype="html">
|
<trans-unit id="3547923076537026828" datatype="html">
|
||||||
<source>Error updating sidebar views</source>
|
<source>Error updating sidebar views</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">386</context>
|
<context context-type="linenumber">346</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2526035785704676448" datatype="html">
|
<trans-unit id="2526035785704676448" datatype="html">
|
||||||
<source>An error occurred while saving update checking settings.</source>
|
<source>An error occurred while saving update checking settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">407</context>
|
<context context-type="linenumber">367</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4580988005648117665" datatype="html">
|
<trans-unit id="4580988005648117665" datatype="html">
|
||||||
@@ -11187,21 +11187,21 @@
|
|||||||
<source>Successfully completed one-time migratration of settings to the database!</source>
|
<source>Successfully completed one-time migratration of settings to the database!</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
|
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
|
||||||
<context context-type="linenumber">635</context>
|
<context context-type="linenumber">609</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5558341108007064934" datatype="html">
|
<trans-unit id="5558341108007064934" datatype="html">
|
||||||
<source>Unable to migrate settings to the database, please try saving manually.</source>
|
<source>Unable to migrate settings to the database, please try saving manually.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
|
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
|
||||||
<context context-type="linenumber">636</context>
|
<context context-type="linenumber">610</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1168781785897678748" datatype="html">
|
<trans-unit id="1168781785897678748" datatype="html">
|
||||||
<source>You can restart the tour from the settings page.</source>
|
<source>You can restart the tour from the settings page.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
|
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
|
||||||
<context context-type="linenumber">708</context>
|
<context context-type="linenumber">683</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3852289441366561594" datatype="html">
|
<trans-unit id="3852289441366561594" datatype="html">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
||||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||||
(click)="mobileSearchHidden = false; isMenuCollapsed = !isMenuCollapsed">
|
(click)="isMenuCollapsed = !isMenuCollapsed">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||||
@@ -24,8 +24,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
|
||||||
[class.mobile-hidden]="mobileSearchHidden">
|
|
||||||
<div class="col-12 col-md-7">
|
<div class="col-12 col-md-7">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search></pngx-global-search>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,7 +378,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden"
|
<main role="main" class="ms-sm-auto px-md-4"
|
||||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -44,23 +44,6 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
top: 3.5rem;
|
top: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
|
||||||
max-height: 4.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
|
|
||||||
|
|
||||||
&.mobile-hidden {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main.mobile-search-hidden {
|
|
||||||
padding-top: 56px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|||||||
@@ -293,59 +293,6 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(component.isMenuCollapsed).toBeTruthy()
|
expect(component.isMenuCollapsed).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
|
|
||||||
Object.defineProperty(globalThis, 'innerWidth', {
|
|
||||||
value: 767,
|
|
||||||
})
|
|
||||||
|
|
||||||
component.ngOnInit()
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'scrollY', {
|
|
||||||
configurable: true,
|
|
||||||
value: 40,
|
|
||||||
})
|
|
||||||
component.onWindowScroll()
|
|
||||||
expect(component.mobileSearchHidden).toBe(true)
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'scrollY', {
|
|
||||||
configurable: true,
|
|
||||||
value: 0,
|
|
||||||
})
|
|
||||||
component.onWindowScroll()
|
|
||||||
expect(component.mobileSearchHidden).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should keep mobile search visible on desktop scroll or resize', () => {
|
|
||||||
Object.defineProperty(globalThis, 'innerWidth', {
|
|
||||||
value: 1024,
|
|
||||||
})
|
|
||||||
component.ngOnInit()
|
|
||||||
component.mobileSearchHidden = true
|
|
||||||
|
|
||||||
component.onWindowScroll()
|
|
||||||
|
|
||||||
expect(component.mobileSearchHidden).toBe(false)
|
|
||||||
|
|
||||||
component.mobileSearchHidden = true
|
|
||||||
component.onWindowResize()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should keep mobile search visible while the mobile menu is expanded', () => {
|
|
||||||
Object.defineProperty(globalThis, 'innerWidth', {
|
|
||||||
value: 767,
|
|
||||||
})
|
|
||||||
component.ngOnInit()
|
|
||||||
component.isMenuCollapsed = false
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'scrollY', {
|
|
||||||
configurable: true,
|
|
||||||
value: 40,
|
|
||||||
})
|
|
||||||
component.onWindowScroll()
|
|
||||||
|
|
||||||
expect(component.mobileSearchHidden).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support close document & navigate on close current doc', () => {
|
it('should support close document & navigate on close current doc', () => {
|
||||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||||
closeSpy.mockReturnValue(of(true))
|
closeSpy.mockReturnValue(of(true))
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
|
|||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
|
import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
|
||||||
|
|
||||||
const SCROLL_THRESHOLD = 16
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-app-frame',
|
selector: 'pngx-app-frame',
|
||||||
templateUrl: './app-frame.component.html',
|
templateUrl: './app-frame.component.html',
|
||||||
@@ -96,10 +94,6 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
slimSidebarAnimating: boolean = false
|
slimSidebarAnimating: boolean = false
|
||||||
|
|
||||||
public mobileSearchHidden: boolean = false
|
|
||||||
|
|
||||||
private lastScrollY: number = 0
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
const permissionsService = this.permissionsService
|
const permissionsService = this.permissionsService
|
||||||
@@ -117,8 +111,6 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.lastScrollY = window.scrollY
|
|
||||||
|
|
||||||
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
||||||
this.checkForUpdates()
|
this.checkForUpdates()
|
||||||
}
|
}
|
||||||
@@ -271,38 +263,6 @@ export class AppFrameComponent
|
|||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
|
||||||
onWindowResize(): void {
|
|
||||||
if (!this.isMobileViewport()) {
|
|
||||||
this.mobileSearchHidden = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('window:scroll')
|
|
||||||
onWindowScroll(): void {
|
|
||||||
const currentScrollY = window.scrollY
|
|
||||||
|
|
||||||
if (!this.isMobileViewport() || this.isMenuCollapsed === false) {
|
|
||||||
this.mobileSearchHidden = false
|
|
||||||
this.lastScrollY = currentScrollY
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = currentScrollY - this.lastScrollY
|
|
||||||
|
|
||||||
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
|
|
||||||
this.mobileSearchHidden = false
|
|
||||||
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
|
|
||||||
this.mobileSearchHidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastScrollY = currentScrollY
|
|
||||||
}
|
|
||||||
|
|
||||||
private isMobileViewport(): boolean {
|
|
||||||
return window.innerWidth < 768
|
|
||||||
}
|
|
||||||
|
|
||||||
closeMenu() {
|
closeMenu() {
|
||||||
this.isMenuCollapsed = true
|
this.isMenuCollapsed = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,20 +56,13 @@ $paperless-card-breakpoints: (
|
|||||||
|
|
||||||
.sticky-top {
|
.sticky-top {
|
||||||
z-index: 990; // below main navbar
|
z-index: 990; // below main navbar
|
||||||
top: calc(7rem - 2px); // height of navbar + search row (mobile)
|
top: calc(7rem - 2px); // height of navbar (mobile)
|
||||||
transition: top 0.2s ease;
|
|
||||||
|
|
||||||
@media (min-width: 580px) {
|
@media (min-width: 580px) {
|
||||||
top: 3.5rem; // height of navbar
|
top: 3.5rem; // height of navbar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 579.98px) {
|
|
||||||
:host-context(main.mobile-search-hidden) .sticky-top {
|
|
||||||
top: calc(3.5rem - 2px); // height of navbar only when search is hidden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table .form-check {
|
.table .form-check {
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
FILTER_HAS_TAGS_ANY,
|
FILTER_HAS_TAGS_ANY,
|
||||||
} from '../data/filter-rule-type'
|
} from '../data/filter-rule-type'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
|
|
||||||
import { SETTINGS_KEYS } from '../data/ui-settings'
|
import { SETTINGS_KEYS } from '../data/ui-settings'
|
||||||
import { PermissionsGuard } from '../guards/permissions.guard'
|
import { PermissionsGuard } from '../guards/permissions.guard'
|
||||||
import { DocumentListViewService } from './document-list-view.service'
|
import { DocumentListViewService } from './document-list-view.service'
|
||||||
@@ -249,29 +248,6 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores only known list view state fields from local storage', () => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG,
|
|
||||||
'{"currentPage":3,"sortField":"title","sortReverse":false,"__proto__":{"polluted":true},"injected":"ignored"}'
|
|
||||||
)
|
|
||||||
|
|
||||||
const restoredService = TestBed.runInInjectionContext(
|
|
||||||
() => new DocumentListViewService()
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(restoredService.currentPage).toEqual(3)
|
|
||||||
expect(restoredService.sortField).toEqual('title')
|
|
||||||
expect(restoredService.sortReverse).toBeFalsy()
|
|
||||||
expect(
|
|
||||||
(restoredService as any).activeListViewState.injected
|
|
||||||
).toBeUndefined()
|
|
||||||
expect(({} as any).polluted).toBeUndefined()
|
|
||||||
} finally {
|
|
||||||
localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should load from query params', () => {
|
it('should load from query params', () => {
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
const page = 2
|
const page = 2
|
||||||
|
|||||||
@@ -24,20 +24,6 @@ const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
|
|||||||
(f) => f.id
|
(f) => f.id
|
||||||
).filter((f) => f !== DisplayField.ADDED)
|
).filter((f) => f !== DisplayField.ADDED)
|
||||||
|
|
||||||
const RESTORABLE_LIST_VIEW_STATE_KEYS: (keyof ListViewState)[] = [
|
|
||||||
'title',
|
|
||||||
'documents',
|
|
||||||
'currentPage',
|
|
||||||
'collectionSize',
|
|
||||||
'sortField',
|
|
||||||
'sortReverse',
|
|
||||||
'filterRules',
|
|
||||||
'selected',
|
|
||||||
'pageSize',
|
|
||||||
'displayMode',
|
|
||||||
'displayFields',
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures the current state of the list view.
|
* Captures the current state of the list view.
|
||||||
*/
|
*/
|
||||||
@@ -126,32 +112,6 @@ export class DocumentListViewService {
|
|||||||
|
|
||||||
private displayFieldsInitialized: boolean = false
|
private displayFieldsInitialized: boolean = false
|
||||||
|
|
||||||
private restoreListViewState(savedState: unknown): ListViewState {
|
|
||||||
const newState = this.defaultListViewState()
|
|
||||||
|
|
||||||
if (
|
|
||||||
!savedState ||
|
|
||||||
typeof savedState !== 'object' ||
|
|
||||||
Array.isArray(savedState)
|
|
||||||
) {
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedState = savedState as Partial<
|
|
||||||
Record<keyof ListViewState, unknown>
|
|
||||||
>
|
|
||||||
const mutableState = newState as Record<keyof ListViewState, unknown>
|
|
||||||
|
|
||||||
for (const key of RESTORABLE_LIST_VIEW_STATE_KEYS) {
|
|
||||||
const value = parsedState[key]
|
|
||||||
if (value != null) {
|
|
||||||
mutableState[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeSavedViewId() {
|
get activeSavedViewId() {
|
||||||
return this._activeSavedViewId
|
return this._activeSavedViewId
|
||||||
}
|
}
|
||||||
@@ -167,7 +127,14 @@ export class DocumentListViewService {
|
|||||||
if (documentListViewConfigJson) {
|
if (documentListViewConfigJson) {
|
||||||
try {
|
try {
|
||||||
let savedState: ListViewState = JSON.parse(documentListViewConfigJson)
|
let savedState: ListViewState = JSON.parse(documentListViewConfigJson)
|
||||||
let newState = this.restoreListViewState(savedState)
|
// Remove null elements from the restored state
|
||||||
|
Object.keys(savedState).forEach((k) => {
|
||||||
|
if (savedState[k] == null) {
|
||||||
|
delete savedState[k]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// only use restored state attributes instead of defaults if they are not null
|
||||||
|
let newState = Object.assign(this.defaultListViewState(), savedState)
|
||||||
this.listViewStates.set(null, newState)
|
this.listViewStates.set(null, newState)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
|
localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
|
||||||
|
|||||||
@@ -166,23 +166,6 @@ describe('SettingsService', () => {
|
|||||||
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
|
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores unsafe top-level keys from loaded settings', () => {
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
|
||||||
)
|
|
||||||
const payload = JSON.parse(
|
|
||||||
JSON.stringify(ui_settings).replace(
|
|
||||||
'"settings":{',
|
|
||||||
'"settings":{"__proto__":{"polluted":"yes"},'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
payload.settings.app_title = 'Safe Title'
|
|
||||||
req.flush(payload)
|
|
||||||
|
|
||||||
expect(settingsService.get(SETTINGS_KEYS.APP_TITLE)).toEqual('Safe Title')
|
|
||||||
expect(({} as any).polluted).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('correctly allows updating settings of various types', () => {
|
it('correctly allows updating settings of various types', () => {
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
|||||||
@@ -276,8 +276,6 @@ const ISO_LANGUAGE_OPTION: LanguageOption = {
|
|||||||
dateInputFormat: 'yyyy-mm-dd',
|
dateInputFormat: 'yyyy-mm-dd',
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -293,7 +291,7 @@ export class SettingsService {
|
|||||||
|
|
||||||
protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/'
|
protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/'
|
||||||
|
|
||||||
private settings: Record<string, any> = {}
|
private settings: Object = {}
|
||||||
currentUser: User
|
currentUser: User
|
||||||
|
|
||||||
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
||||||
@@ -322,21 +320,6 @@ export class SettingsService {
|
|||||||
this._renderer = rendererFactory.createRenderer(null, null)
|
this._renderer = rendererFactory.createRenderer(null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSafeObjectKey(key: string): boolean {
|
|
||||||
return !UNSAFE_OBJECT_KEYS.has(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
private assignSafeSettings(source: Record<string, any>) {
|
|
||||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of Object.keys(source)) {
|
|
||||||
if (!this.isSafeObjectKey(key)) continue
|
|
||||||
this.settings[key] = source[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is called by the app initializer in app.module
|
// this is called by the app initializer in app.module
|
||||||
public initializeSettings(): Observable<UiSettings> {
|
public initializeSettings(): Observable<UiSettings> {
|
||||||
return this.http.get<UiSettings>(this.baseUrl).pipe(
|
return this.http.get<UiSettings>(this.baseUrl).pipe(
|
||||||
@@ -355,7 +338,7 @@ export class SettingsService {
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
tap((uisettings) => {
|
tap((uisettings) => {
|
||||||
this.assignSafeSettings(uisettings.settings)
|
Object.assign(this.settings, uisettings.settings)
|
||||||
if (this.get(SETTINGS_KEYS.APP_TITLE)?.length) {
|
if (this.get(SETTINGS_KEYS.APP_TITLE)?.length) {
|
||||||
environment.appTitle = this.get(SETTINGS_KEYS.APP_TITLE)
|
environment.appTitle = this.get(SETTINGS_KEYS.APP_TITLE)
|
||||||
}
|
}
|
||||||
@@ -550,11 +533,7 @@ export class SettingsService {
|
|||||||
let settingObj = this.settings
|
let settingObj = this.settings
|
||||||
keys.forEach((keyPart, index) => {
|
keys.forEach((keyPart, index) => {
|
||||||
keyPart = keyPart.replace(/-/g, '_')
|
keyPart = keyPart.replace(/-/g, '_')
|
||||||
if (
|
if (!settingObj.hasOwnProperty(keyPart)) return
|
||||||
!this.isSafeObjectKey(keyPart) ||
|
|
||||||
!Object.prototype.hasOwnProperty.call(settingObj, keyPart)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if (index == keys.length - 1) value = settingObj[keyPart]
|
if (index == keys.length - 1) value = settingObj[keyPart]
|
||||||
else settingObj = settingObj[keyPart]
|
else settingObj = settingObj[keyPart]
|
||||||
})
|
})
|
||||||
@@ -600,9 +579,7 @@ export class SettingsService {
|
|||||||
const keys = key.replace('general-settings:', '').split(':')
|
const keys = key.replace('general-settings:', '').split(':')
|
||||||
keys.forEach((keyPart, index) => {
|
keys.forEach((keyPart, index) => {
|
||||||
keyPart = keyPart.replace(/-/g, '_')
|
keyPart = keyPart.replace(/-/g, '_')
|
||||||
if (!this.isSafeObjectKey(keyPart)) return
|
if (!settingObj.hasOwnProperty(keyPart)) settingObj[keyPart] = {}
|
||||||
if (!Object.prototype.hasOwnProperty.call(settingObj, keyPart))
|
|
||||||
settingObj[keyPart] = {}
|
|
||||||
if (index == keys.length - 1) settingObj[keyPart] = value
|
if (index == keys.length - 1) settingObj[keyPart] = value
|
||||||
else settingObj = settingObj[keyPart]
|
else settingObj = settingObj[keyPart]
|
||||||
})
|
})
|
||||||
@@ -625,10 +602,7 @@ export class SettingsService {
|
|||||||
|
|
||||||
maybeMigrateSettings() {
|
maybeMigrateSettings() {
|
||||||
if (
|
if (
|
||||||
!Object.prototype.hasOwnProperty.call(
|
!this.settings.hasOwnProperty('documentListSize') &&
|
||||||
this.settings,
|
|
||||||
'documentListSize'
|
|
||||||
) &&
|
|
||||||
localStorage.getItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
|
localStorage.getItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
|
||||||
) {
|
) {
|
||||||
// lets migrate
|
// lets migrate
|
||||||
@@ -636,7 +610,8 @@ export class SettingsService {
|
|||||||
const errorMessage = $localize`Unable to migrate settings to the database, please try saving manually.`
|
const errorMessage = $localize`Unable to migrate settings to the database, please try saving manually.`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const key of Object.values(SETTINGS_KEYS)) {
|
for (const setting in SETTINGS_KEYS) {
|
||||||
|
const key = SETTINGS_KEYS[setting]
|
||||||
const value = localStorage.getItem(key)
|
const value = localStorage.getItem(key)
|
||||||
this.set(key, value)
|
this.set(key, value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -46,7 +46,6 @@ from documents.signals import document_consumption_started
|
|||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||||
from documents.utils import compute_checksum
|
|
||||||
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
|
||||||
@@ -197,7 +196,9 @@ class ConsumerPlugin(
|
|||||||
version_doc = Document(
|
version_doc = Document(
|
||||||
root_document=root_doc_frozen,
|
root_document=root_doc_frozen,
|
||||||
version_index=next_version_index + 1,
|
version_index=next_version_index + 1,
|
||||||
checksum=compute_checksum(file_for_checksum),
|
checksum=hashlib.md5(
|
||||||
|
file_for_checksum.read_bytes(),
|
||||||
|
).hexdigest(),
|
||||||
content=text or "",
|
content=text or "",
|
||||||
page_count=page_count,
|
page_count=page_count,
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
@@ -337,15 +338,18 @@ class ConsumerPlugin(
|
|||||||
Return the document object if it was successfully created.
|
Return the document object if it was successfully created.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Preflight has already run including progress update to 0%
|
tempdir = None
|
||||||
self.log.info(f"Consuming {self.filename}")
|
|
||||||
|
|
||||||
# For the actual work, copy the file into a tempdir
|
try:
|
||||||
with tempfile.TemporaryDirectory(
|
# Preflight has already run including progress update to 0%
|
||||||
prefix="paperless-ngx",
|
self.log.info(f"Consuming {self.filename}")
|
||||||
dir=settings.SCRATCH_DIR,
|
|
||||||
) as tmpdir:
|
# For the actual work, copy the file into a tempdir
|
||||||
self.working_copy = Path(tmpdir) / Path(self.filename)
|
tempdir = tempfile.TemporaryDirectory(
|
||||||
|
prefix="paperless-ngx",
|
||||||
|
dir=settings.SCRATCH_DIR,
|
||||||
|
)
|
||||||
|
self.working_copy = Path(tempdir.name) / Path(self.filename)
|
||||||
copy_file_with_basic_stats(self.input_doc.original_file, self.working_copy)
|
copy_file_with_basic_stats(self.input_doc.original_file, self.working_copy)
|
||||||
self.unmodified_original = None
|
self.unmodified_original = None
|
||||||
|
|
||||||
@@ -377,7 +381,7 @@ class ConsumerPlugin(
|
|||||||
self.log.debug(f"Detected mime type after qpdf: {mime_type}")
|
self.log.debug(f"Detected mime type after qpdf: {mime_type}")
|
||||||
# Save the original file for later
|
# Save the original file for later
|
||||||
self.unmodified_original = (
|
self.unmodified_original = (
|
||||||
Path(tmpdir) / Path("uo") / Path(self.filename)
|
Path(tempdir.name) / Path("uo") / Path(self.filename)
|
||||||
)
|
)
|
||||||
self.unmodified_original.parent.mkdir(exist_ok=True)
|
self.unmodified_original.parent.mkdir(exist_ok=True)
|
||||||
copy_file_with_basic_stats(
|
copy_file_with_basic_stats(
|
||||||
@@ -396,6 +400,7 @@ class ConsumerPlugin(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not parser_class:
|
if not parser_class:
|
||||||
|
tempdir.cleanup()
|
||||||
self._fail(
|
self._fail(
|
||||||
ConsumerStatusShortMessage.UNSUPPORTED_TYPE,
|
ConsumerStatusShortMessage.UNSUPPORTED_TYPE,
|
||||||
f"Unsupported mime type {mime_type}",
|
f"Unsupported mime type {mime_type}",
|
||||||
@@ -410,274 +415,280 @@ class ConsumerPlugin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.run_pre_consume_script()
|
self.run_pre_consume_script()
|
||||||
|
except:
|
||||||
|
if tempdir:
|
||||||
|
tempdir.cleanup()
|
||||||
|
raise
|
||||||
|
|
||||||
# This doesn't parse the document yet, but gives us a parser.
|
# This doesn't parse the document yet, but gives us a parser.
|
||||||
with parser_class() as document_parser:
|
with parser_class() as document_parser:
|
||||||
document_parser.configure(
|
document_parser.configure(
|
||||||
ParserContext(mailrule_id=self.input_doc.mailrule_id),
|
ParserContext(mailrule_id=self.input_doc.mailrule_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.log.debug(
|
self.log.debug(f"Parser: {document_parser.name} v{document_parser.version}")
|
||||||
f"Parser: {document_parser.name} v{document_parser.version}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse the document. This may take some time.
|
# Parse the document. This may take some time.
|
||||||
|
|
||||||
text = None
|
text = None
|
||||||
date = None
|
date = None
|
||||||
thumbnail = None
|
thumbnail = None
|
||||||
archive_path = None
|
archive_path = None
|
||||||
page_count = None
|
page_count = None
|
||||||
|
|
||||||
try:
|
|
||||||
self._send_progress(
|
|
||||||
20,
|
|
||||||
100,
|
|
||||||
ProgressStatusOptions.WORKING,
|
|
||||||
ConsumerStatusShortMessage.PARSING_DOCUMENT,
|
|
||||||
)
|
|
||||||
self.log.debug(f"Parsing {self.filename}...")
|
|
||||||
|
|
||||||
document_parser.parse(self.working_copy, mime_type)
|
|
||||||
|
|
||||||
self.log.debug(f"Generating thumbnail for {self.filename}...")
|
|
||||||
self._send_progress(
|
|
||||||
70,
|
|
||||||
100,
|
|
||||||
ProgressStatusOptions.WORKING,
|
|
||||||
ConsumerStatusShortMessage.GENERATING_THUMBNAIL,
|
|
||||||
)
|
|
||||||
thumbnail = document_parser.get_thumbnail(
|
|
||||||
self.working_copy,
|
|
||||||
mime_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
text = document_parser.get_text()
|
|
||||||
date = document_parser.get_date()
|
|
||||||
if date is None:
|
|
||||||
self._send_progress(
|
|
||||||
90,
|
|
||||||
100,
|
|
||||||
ProgressStatusOptions.WORKING,
|
|
||||||
ConsumerStatusShortMessage.PARSE_DATE,
|
|
||||||
)
|
|
||||||
with get_date_parser() as date_parser:
|
|
||||||
date = next(date_parser.parse(self.filename, text), None)
|
|
||||||
archive_path = document_parser.get_archive_path()
|
|
||||||
page_count = document_parser.get_page_count(
|
|
||||||
self.working_copy,
|
|
||||||
mime_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
except ParseError as e:
|
|
||||||
self._fail(
|
|
||||||
str(e),
|
|
||||||
f"Error occurred while consuming document {self.filename}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
exception=e,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self._fail(
|
|
||||||
str(e),
|
|
||||||
f"Unexpected error while consuming document {self.filename}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
exception=e,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Prepare the document classifier.
|
|
||||||
|
|
||||||
# TODO: I don't really like to do this here, but this way we avoid
|
|
||||||
# reloading the classifier multiple times, since there are multiple
|
|
||||||
# post-consume hooks that all require the classifier.
|
|
||||||
|
|
||||||
classifier = load_classifier()
|
|
||||||
|
|
||||||
|
try:
|
||||||
self._send_progress(
|
self._send_progress(
|
||||||
95,
|
20,
|
||||||
100,
|
100,
|
||||||
ProgressStatusOptions.WORKING,
|
ProgressStatusOptions.WORKING,
|
||||||
ConsumerStatusShortMessage.SAVE_DOCUMENT,
|
ConsumerStatusShortMessage.PARSING_DOCUMENT,
|
||||||
)
|
)
|
||||||
# now that everything is done, we can start to store the document
|
self.log.debug(f"Parsing {self.filename}...")
|
||||||
# in the system. This will be a transaction and reasonably fast.
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
# store the document.
|
|
||||||
if self.input_doc.root_document_id:
|
|
||||||
# If this is a new version of an existing document, we need
|
|
||||||
# to make sure we're not creating a new document, but updating
|
|
||||||
# the existing one.
|
|
||||||
root_doc = Document.objects.get(
|
|
||||||
pk=self.input_doc.root_document_id,
|
|
||||||
)
|
|
||||||
original_document = self._create_version_from_root(
|
|
||||||
root_doc,
|
|
||||||
text=text,
|
|
||||||
page_count=page_count,
|
|
||||||
mime_type=mime_type,
|
|
||||||
)
|
|
||||||
actor = None
|
|
||||||
|
|
||||||
# Save the new version, potentially creating an audit log entry for the version addition if enabled.
|
document_parser.parse(self.working_copy, mime_type)
|
||||||
if (
|
|
||||||
settings.AUDIT_LOG_ENABLED
|
|
||||||
and self.metadata.actor_id is not None
|
|
||||||
):
|
|
||||||
actor = User.objects.filter(
|
|
||||||
pk=self.metadata.actor_id,
|
|
||||||
).first()
|
|
||||||
if actor is not None:
|
|
||||||
from auditlog.context import ( # type: ignore[import-untyped]
|
|
||||||
set_actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
with set_actor(actor):
|
self.log.debug(f"Generating thumbnail for {self.filename}...")
|
||||||
original_document.save()
|
self._send_progress(
|
||||||
else:
|
70,
|
||||||
|
100,
|
||||||
|
ProgressStatusOptions.WORKING,
|
||||||
|
ConsumerStatusShortMessage.GENERATING_THUMBNAIL,
|
||||||
|
)
|
||||||
|
thumbnail = document_parser.get_thumbnail(self.working_copy, mime_type)
|
||||||
|
|
||||||
|
text = document_parser.get_text()
|
||||||
|
date = document_parser.get_date()
|
||||||
|
if date is None:
|
||||||
|
self._send_progress(
|
||||||
|
90,
|
||||||
|
100,
|
||||||
|
ProgressStatusOptions.WORKING,
|
||||||
|
ConsumerStatusShortMessage.PARSE_DATE,
|
||||||
|
)
|
||||||
|
with get_date_parser() as date_parser:
|
||||||
|
date = next(date_parser.parse(self.filename, text), None)
|
||||||
|
archive_path = document_parser.get_archive_path()
|
||||||
|
page_count = document_parser.get_page_count(
|
||||||
|
self.working_copy,
|
||||||
|
mime_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ParseError as e:
|
||||||
|
if tempdir:
|
||||||
|
tempdir.cleanup()
|
||||||
|
self._fail(
|
||||||
|
str(e),
|
||||||
|
f"Error occurred while consuming document {self.filename}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
exception=e,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if tempdir:
|
||||||
|
tempdir.cleanup()
|
||||||
|
self._fail(
|
||||||
|
str(e),
|
||||||
|
f"Unexpected error while consuming document {self.filename}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
exception=e,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare the document classifier.
|
||||||
|
|
||||||
|
# TODO: I don't really like to do this here, but this way we avoid
|
||||||
|
# reloading the classifier multiple times, since there are multiple
|
||||||
|
# post-consume hooks that all require the classifier.
|
||||||
|
|
||||||
|
classifier = load_classifier()
|
||||||
|
|
||||||
|
self._send_progress(
|
||||||
|
95,
|
||||||
|
100,
|
||||||
|
ProgressStatusOptions.WORKING,
|
||||||
|
ConsumerStatusShortMessage.SAVE_DOCUMENT,
|
||||||
|
)
|
||||||
|
# now that everything is done, we can start to store the document
|
||||||
|
# in the system. This will be a transaction and reasonably fast.
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# store the document.
|
||||||
|
if self.input_doc.root_document_id:
|
||||||
|
# If this is a new version of an existing document, we need
|
||||||
|
# to make sure we're not creating a new document, but updating
|
||||||
|
# the existing one.
|
||||||
|
root_doc = Document.objects.get(
|
||||||
|
pk=self.input_doc.root_document_id,
|
||||||
|
)
|
||||||
|
original_document = self._create_version_from_root(
|
||||||
|
root_doc,
|
||||||
|
text=text,
|
||||||
|
page_count=page_count,
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
actor = None
|
||||||
|
|
||||||
|
# Save the new version, potentially creating an audit log entry for the version addition if enabled.
|
||||||
|
if (
|
||||||
|
settings.AUDIT_LOG_ENABLED
|
||||||
|
and self.metadata.actor_id is not None
|
||||||
|
):
|
||||||
|
actor = User.objects.filter(
|
||||||
|
pk=self.metadata.actor_id,
|
||||||
|
).first()
|
||||||
|
if actor is not None:
|
||||||
|
from auditlog.context import ( # type: ignore[import-untyped]
|
||||||
|
set_actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
with set_actor(actor):
|
||||||
original_document.save()
|
original_document.save()
|
||||||
else:
|
else:
|
||||||
original_document.save()
|
original_document.save()
|
||||||
|
|
||||||
# Create a log entry for the version addition, if enabled
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
|
||||||
from auditlog.models import ( # type: ignore[import-untyped]
|
|
||||||
LogEntry,
|
|
||||||
)
|
|
||||||
|
|
||||||
LogEntry.objects.log_create(
|
|
||||||
instance=root_doc,
|
|
||||||
changes={
|
|
||||||
"Version Added": ["None", original_document.id],
|
|
||||||
},
|
|
||||||
action=LogEntry.Action.UPDATE,
|
|
||||||
actor=actor,
|
|
||||||
additional_data={
|
|
||||||
"reason": "Version added",
|
|
||||||
"version_id": original_document.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
document = original_document
|
|
||||||
else:
|
else:
|
||||||
document = self._store(
|
original_document.save()
|
||||||
text=text,
|
|
||||||
date=date,
|
# Create a log entry for the version addition, if enabled
|
||||||
page_count=page_count,
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
mime_type=mime_type,
|
from auditlog.models import ( # type: ignore[import-untyped]
|
||||||
|
LogEntry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If we get here, it was successful. Proceed with post-consume
|
LogEntry.objects.log_create(
|
||||||
# hooks. If they fail, nothing will get changed.
|
instance=root_doc,
|
||||||
|
changes={
|
||||||
document_consumption_finished.send(
|
"Version Added": ["None", original_document.id],
|
||||||
sender=self.__class__,
|
},
|
||||||
document=document,
|
action=LogEntry.Action.UPDATE,
|
||||||
logging_group=self.logging_group,
|
actor=actor,
|
||||||
classifier=classifier,
|
additional_data={
|
||||||
original_file=self.unmodified_original
|
"reason": "Version added",
|
||||||
if self.unmodified_original
|
"version_id": original_document.id,
|
||||||
else self.working_copy,
|
},
|
||||||
|
)
|
||||||
|
document = original_document
|
||||||
|
else:
|
||||||
|
document = self._store(
|
||||||
|
text=text,
|
||||||
|
date=date,
|
||||||
|
page_count=page_count,
|
||||||
|
mime_type=mime_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
# After everything is in the database, copy the files into
|
# If we get here, it was successful. Proceed with post-consume
|
||||||
# place. If this fails, we'll also rollback the transaction.
|
# hooks. If they fail, nothing will get changed.
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
|
||||||
generated_filename = generate_unique_filename(document)
|
document_consumption_finished.send(
|
||||||
|
sender=self.__class__,
|
||||||
|
document=document,
|
||||||
|
logging_group=self.logging_group,
|
||||||
|
classifier=classifier,
|
||||||
|
original_file=self.unmodified_original
|
||||||
|
if self.unmodified_original
|
||||||
|
else self.working_copy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# After everything is in the database, copy the files into
|
||||||
|
# place. If this fails, we'll also rollback the transaction.
|
||||||
|
with FileLock(settings.MEDIA_LOCK):
|
||||||
|
generated_filename = generate_unique_filename(document)
|
||||||
|
if (
|
||||||
|
len(str(generated_filename))
|
||||||
|
> Document.MAX_STORED_FILENAME_LENGTH
|
||||||
|
):
|
||||||
|
self.log.warning(
|
||||||
|
"Generated source filename exceeds db path limit, falling back to default naming",
|
||||||
|
)
|
||||||
|
generated_filename = generate_filename(
|
||||||
|
document,
|
||||||
|
use_format=False,
|
||||||
|
)
|
||||||
|
document.filename = generated_filename
|
||||||
|
create_source_path_directory(document.source_path)
|
||||||
|
|
||||||
|
self._write(
|
||||||
|
self.unmodified_original
|
||||||
|
if self.unmodified_original is not None
|
||||||
|
else self.working_copy,
|
||||||
|
document.source_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._write(
|
||||||
|
thumbnail,
|
||||||
|
document.thumbnail_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if archive_path and Path(archive_path).is_file():
|
||||||
|
generated_archive_filename = generate_unique_filename(
|
||||||
|
document,
|
||||||
|
archive_filename=True,
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
len(str(generated_filename))
|
len(str(generated_archive_filename))
|
||||||
> Document.MAX_STORED_FILENAME_LENGTH
|
> Document.MAX_STORED_FILENAME_LENGTH
|
||||||
):
|
):
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Generated source filename exceeds db path limit, falling back to default naming",
|
"Generated archive filename exceeds db path limit, falling back to default naming",
|
||||||
)
|
)
|
||||||
generated_filename = generate_filename(
|
generated_archive_filename = generate_filename(
|
||||||
document,
|
|
||||||
use_format=False,
|
|
||||||
)
|
|
||||||
document.filename = generated_filename
|
|
||||||
create_source_path_directory(document.source_path)
|
|
||||||
|
|
||||||
self._write(
|
|
||||||
self.unmodified_original
|
|
||||||
if self.unmodified_original is not None
|
|
||||||
else self.working_copy,
|
|
||||||
document.source_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._write(
|
|
||||||
thumbnail,
|
|
||||||
document.thumbnail_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
if archive_path and Path(archive_path).is_file():
|
|
||||||
generated_archive_filename = generate_unique_filename(
|
|
||||||
document,
|
document,
|
||||||
archive_filename=True,
|
archive_filename=True,
|
||||||
|
use_format=False,
|
||||||
)
|
)
|
||||||
if (
|
document.archive_filename = generated_archive_filename
|
||||||
len(str(generated_archive_filename))
|
create_source_path_directory(document.archive_path)
|
||||||
> Document.MAX_STORED_FILENAME_LENGTH
|
self._write(
|
||||||
):
|
archive_path,
|
||||||
self.log.warning(
|
document.archive_path,
|
||||||
"Generated archive filename exceeds db path limit, falling back to default naming",
|
|
||||||
)
|
|
||||||
generated_archive_filename = generate_filename(
|
|
||||||
document,
|
|
||||||
archive_filename=True,
|
|
||||||
use_format=False,
|
|
||||||
)
|
|
||||||
document.archive_filename = generated_archive_filename
|
|
||||||
create_source_path_directory(document.archive_path)
|
|
||||||
self._write(
|
|
||||||
archive_path,
|
|
||||||
document.archive_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
document.archive_checksum = compute_checksum(
|
|
||||||
document.archive_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Don't save with the lock active. Saving will cause the file
|
|
||||||
# renaming logic to acquire the lock as well.
|
|
||||||
# This triggers things like file renaming
|
|
||||||
document.save()
|
|
||||||
|
|
||||||
if document.root_document_id:
|
|
||||||
document_updated.send(
|
|
||||||
sender=self.__class__,
|
|
||||||
document=document.root_document,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete the file only if it was successfully consumed
|
with Path(archive_path).open("rb") as f:
|
||||||
self.log.debug(
|
document.archive_checksum = hashlib.md5(
|
||||||
f"Deleting original file {self.input_doc.original_file}",
|
f.read(),
|
||||||
)
|
).hexdigest()
|
||||||
self.input_doc.original_file.unlink()
|
|
||||||
self.log.debug(f"Deleting working copy {self.working_copy}")
|
|
||||||
self.working_copy.unlink()
|
|
||||||
if self.unmodified_original is not None: # pragma: no cover
|
|
||||||
self.log.debug(
|
|
||||||
f"Deleting unmodified original file {self.unmodified_original}",
|
|
||||||
)
|
|
||||||
self.unmodified_original.unlink()
|
|
||||||
|
|
||||||
# https://github.com/jonaswinkler/paperless-ng/discussions/1037
|
# Don't save with the lock active. Saving will cause the file
|
||||||
shadow_file = (
|
# renaming logic to acquire the lock as well.
|
||||||
Path(self.input_doc.original_file).parent
|
# This triggers things like file renaming
|
||||||
/ f"._{Path(self.input_doc.original_file).name}"
|
document.save()
|
||||||
|
|
||||||
|
if document.root_document_id:
|
||||||
|
document_updated.send(
|
||||||
|
sender=self.__class__,
|
||||||
|
document=document.root_document,
|
||||||
)
|
)
|
||||||
|
|
||||||
if Path(shadow_file).is_file():
|
# Delete the file only if it was successfully consumed
|
||||||
self.log.debug(f"Deleting shadow file {shadow_file}")
|
self.log.debug(
|
||||||
Path(shadow_file).unlink()
|
f"Deleting original file {self.input_doc.original_file}",
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._fail(
|
|
||||||
str(e),
|
|
||||||
f"The following error occurred while storing document "
|
|
||||||
f"{self.filename} after parsing: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
exception=e,
|
|
||||||
)
|
)
|
||||||
|
self.input_doc.original_file.unlink()
|
||||||
|
self.log.debug(f"Deleting working copy {self.working_copy}")
|
||||||
|
self.working_copy.unlink()
|
||||||
|
if self.unmodified_original is not None: # pragma: no cover
|
||||||
|
self.log.debug(
|
||||||
|
f"Deleting unmodified original file {self.unmodified_original}",
|
||||||
|
)
|
||||||
|
self.unmodified_original.unlink()
|
||||||
|
|
||||||
|
# https://github.com/jonaswinkler/paperless-ng/discussions/1037
|
||||||
|
shadow_file = (
|
||||||
|
Path(self.input_doc.original_file).parent
|
||||||
|
/ f"._{Path(self.input_doc.original_file).name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if Path(shadow_file).is_file():
|
||||||
|
self.log.debug(f"Deleting shadow file {shadow_file}")
|
||||||
|
Path(shadow_file).unlink()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._fail(
|
||||||
|
str(e),
|
||||||
|
f"The following error occurred while storing document "
|
||||||
|
f"{self.filename} after parsing: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
exception=e,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tempdir.cleanup()
|
||||||
|
|
||||||
self.run_post_consume_script(document)
|
self.run_post_consume_script(document)
|
||||||
|
|
||||||
@@ -774,7 +785,7 @@ class ConsumerPlugin(
|
|||||||
title=title[:127],
|
title=title[:127],
|
||||||
content=text,
|
content=text,
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
checksum=compute_checksum(file_for_checksum),
|
checksum=hashlib.md5(file_for_checksum.read_bytes()).hexdigest(),
|
||||||
created=create_date,
|
created=create_date,
|
||||||
modified=create_date,
|
modified=create_date,
|
||||||
page_count=page_count,
|
page_count=page_count,
|
||||||
@@ -822,7 +833,7 @@ class ConsumerPlugin(
|
|||||||
self.metadata.view_users is not None
|
self.metadata.view_users is not None
|
||||||
or self.metadata.view_groups is not None
|
or self.metadata.view_groups is not None
|
||||||
or self.metadata.change_users is not None
|
or self.metadata.change_users is not None
|
||||||
or self.metadata.change_groups is not None
|
or self.metadata.change_users is not None
|
||||||
):
|
):
|
||||||
permissions = {
|
permissions = {
|
||||||
"view": {
|
"view": {
|
||||||
@@ -855,7 +866,7 @@ class ConsumerPlugin(
|
|||||||
Path(source).open("rb") as read_file,
|
Path(source).open("rb") as read_file,
|
||||||
Path(target).open("wb") as write_file,
|
Path(target).open("wb") as write_file,
|
||||||
):
|
):
|
||||||
shutil.copyfileobj(read_file, write_file)
|
write_file.write(read_file.read())
|
||||||
|
|
||||||
# Attempt to copy file's original stats, but it's ok if we can't
|
# Attempt to copy file's original stats, but it's ok if we can't
|
||||||
try:
|
try:
|
||||||
@@ -891,9 +902,10 @@ class ConsumerPreflightPlugin(
|
|||||||
|
|
||||||
def pre_check_duplicate(self) -> None:
|
def pre_check_duplicate(self) -> None:
|
||||||
"""
|
"""
|
||||||
Using the SHA256 of the file, check this exact file doesn't already exist
|
Using the MD5 of the file, check this exact file doesn't already exist
|
||||||
"""
|
"""
|
||||||
checksum = compute_checksum(Path(self.input_doc.original_file))
|
with Path(self.input_doc.original_file).open("rb") as f:
|
||||||
|
checksum = hashlib.md5(f.read()).hexdigest()
|
||||||
existing_doc = Document.global_objects.filter(
|
existing_doc = Document.global_objects.filter(
|
||||||
Q(checksum=checksum) | Q(archive_checksum=checksum),
|
Q(checksum=checksum) | Q(archive_checksum=checksum),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ from documents.models import WorkflowTrigger
|
|||||||
from documents.settings import EXPORTER_ARCHIVE_NAME
|
from documents.settings import EXPORTER_ARCHIVE_NAME
|
||||||
from documents.settings import EXPORTER_FILE_NAME
|
from documents.settings import EXPORTER_FILE_NAME
|
||||||
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
||||||
from documents.utils import compute_checksum
|
|
||||||
from documents.utils import copy_file_with_basic_stats
|
from documents.utils import copy_file_with_basic_stats
|
||||||
from paperless import version
|
from paperless import version
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
@@ -694,7 +693,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
source_stat = source.stat()
|
source_stat = source.stat()
|
||||||
target_stat = target.stat()
|
target_stat = target.stat()
|
||||||
if self.compare_checksums and source_checksum:
|
if self.compare_checksums and source_checksum:
|
||||||
target_checksum = compute_checksum(target)
|
target_checksum = hashlib.md5(target.read_bytes()).hexdigest()
|
||||||
perform_copy = target_checksum != source_checksum
|
perform_copy = target_checksum != source_checksum
|
||||||
elif (
|
elif (
|
||||||
source_stat.st_mtime != target_stat.st_mtime
|
source_stat.st_mtime != target_stat.st_mtime
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.migrations")
|
|
||||||
|
|
||||||
_CHUNK_SIZE = 65536 # 64 KiB — avoids loading entire files into memory
|
|
||||||
_BATCH_SIZE = 500 # documents per bulk_update call
|
|
||||||
_PROGRESS_INTERVAL = 500 # log a progress line every N documents
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(path: Path) -> str:
|
|
||||||
h = hashlib.sha256()
|
|
||||||
with path.open("rb") as fh:
|
|
||||||
while chunk := fh.read(_CHUNK_SIZE):
|
|
||||||
h.update(chunk)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def recompute_checksums(apps, schema_editor):
|
|
||||||
"""Recompute all document checksums from MD5 to SHA256."""
|
|
||||||
Document = apps.get_model("documents", "Document")
|
|
||||||
|
|
||||||
total = Document.objects.count()
|
|
||||||
if total == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Recomputing SHA-256 checksums for %d document(s)...", total)
|
|
||||||
|
|
||||||
batch: list = []
|
|
||||||
processed = 0
|
|
||||||
|
|
||||||
for doc in Document.objects.only(
|
|
||||||
"pk",
|
|
||||||
"filename",
|
|
||||||
"checksum",
|
|
||||||
"archive_filename",
|
|
||||||
"archive_checksum",
|
|
||||||
).iterator(chunk_size=_BATCH_SIZE):
|
|
||||||
updated_fields: list[str] = []
|
|
||||||
|
|
||||||
# Reconstruct source path the same way Document.source_path does
|
|
||||||
fname = str(doc.filename) if doc.filename else f"{doc.pk:07}.pdf"
|
|
||||||
source_path = (settings.ORIGINALS_DIR / Path(fname)).resolve()
|
|
||||||
|
|
||||||
if source_path.exists():
|
|
||||||
doc.checksum = _sha256(source_path)
|
|
||||||
updated_fields.append("checksum")
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Document %s: original file %s not found, checksum not updated.",
|
|
||||||
doc.pk,
|
|
||||||
source_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mirror Document.has_archive_version: archive_filename is not None
|
|
||||||
if doc.archive_filename is not None:
|
|
||||||
archive_path = (
|
|
||||||
settings.ARCHIVE_DIR / Path(str(doc.archive_filename))
|
|
||||||
).resolve()
|
|
||||||
if archive_path.exists():
|
|
||||||
doc.archive_checksum = _sha256(archive_path)
|
|
||||||
updated_fields.append("archive_checksum")
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Document %s: archive file %s not found, checksum not updated.",
|
|
||||||
doc.pk,
|
|
||||||
archive_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
if updated_fields:
|
|
||||||
batch.append(doc)
|
|
||||||
|
|
||||||
processed += 1
|
|
||||||
|
|
||||||
if len(batch) >= _BATCH_SIZE:
|
|
||||||
Document.objects.bulk_update(batch, ["checksum", "archive_checksum"])
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
if processed % _PROGRESS_INTERVAL == 0:
|
|
||||||
logger.info(
|
|
||||||
"SHA-256 checksum progress: %d/%d (%d%%)",
|
|
||||||
processed,
|
|
||||||
total,
|
|
||||||
processed * 100 // total,
|
|
||||||
)
|
|
||||||
|
|
||||||
if batch:
|
|
||||||
Document.objects.bulk_update(batch, ["checksum", "archive_checksum"])
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"SHA-256 checksum recomputation complete: %d document(s) processed.",
|
|
||||||
total,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("documents", "0015_document_version_index_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="document",
|
|
||||||
name="checksum",
|
|
||||||
field=models.CharField(
|
|
||||||
editable=False,
|
|
||||||
help_text="The checksum of the original document.",
|
|
||||||
max_length=64,
|
|
||||||
verbose_name="checksum",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="document",
|
|
||||||
name="archive_checksum",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
editable=False,
|
|
||||||
help_text="The checksum of the archived document.",
|
|
||||||
max_length=64,
|
|
||||||
null=True,
|
|
||||||
verbose_name="archive checksum",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RunPython(recompute_checksums, migrations.RunPython.noop),
|
|
||||||
]
|
|
||||||
@@ -216,14 +216,14 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
|
|
||||||
checksum = models.CharField(
|
checksum = models.CharField(
|
||||||
_("checksum"),
|
_("checksum"),
|
||||||
max_length=64,
|
max_length=32,
|
||||||
editable=False,
|
editable=False,
|
||||||
help_text=_("The checksum of the original document."),
|
help_text=_("The checksum of the original document."),
|
||||||
)
|
)
|
||||||
|
|
||||||
archive_checksum = models.CharField(
|
archive_checksum = models.CharField(
|
||||||
_("archive checksum"),
|
_("archive checksum"),
|
||||||
max_length=64,
|
max_length=32,
|
||||||
editable=False,
|
editable=False,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ is an identity function that adds no overhead.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -29,7 +30,6 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.utils import compute_checksum
|
|
||||||
from paperless.config import GeneralConfig
|
from paperless.config import GeneralConfig
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.sanity_checker")
|
logger = logging.getLogger("paperless.sanity_checker")
|
||||||
@@ -218,7 +218,7 @@ def _check_original(
|
|||||||
|
|
||||||
present_files.discard(source_path)
|
present_files.discard(source_path)
|
||||||
try:
|
try:
|
||||||
checksum = compute_checksum(source_path)
|
checksum = hashlib.md5(source_path.read_bytes()).hexdigest()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
messages.error(doc.pk, f"Cannot read original file of document: {e}")
|
messages.error(doc.pk, f"Cannot read original file of document: {e}")
|
||||||
else:
|
else:
|
||||||
@@ -255,7 +255,7 @@ def _check_archive(
|
|||||||
|
|
||||||
present_files.discard(archive_path)
|
present_files.discard(archive_path)
|
||||||
try:
|
try:
|
||||||
checksum = compute_checksum(archive_path)
|
checksum = hashlib.md5(archive_path.read_bytes()).hexdigest()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
messages.error(
|
messages.error(
|
||||||
doc.pk,
|
doc.pk,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
@@ -52,15 +53,14 @@ from documents.models import Tag
|
|||||||
from documents.models import WorkflowRun
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.plugins.base import ConsumeTaskPlugin
|
from documents.plugins.base import ConsumeTaskPlugin
|
||||||
|
from documents.plugins.base import ProgressManager
|
||||||
from documents.plugins.base import StopConsumeTaskError
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
from documents.plugins.helpers import ProgressManager
|
|
||||||
from documents.plugins.helpers import ProgressStatusOptions
|
from documents.plugins.helpers import ProgressStatusOptions
|
||||||
from documents.sanity_checker import SanityCheckFailedException
|
from documents.sanity_checker import SanityCheckFailedException
|
||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
from documents.signals.handlers import cleanup_document_deletion
|
from documents.signals.handlers import cleanup_document_deletion
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.signals.handlers import send_websocket_document_updated
|
from documents.signals.handlers import send_websocket_document_updated
|
||||||
from documents.utils import compute_checksum
|
|
||||||
from documents.workflows.utils import get_workflows_for_trigger
|
from documents.workflows.utils import get_workflows_for_trigger
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
@@ -328,7 +328,8 @@ def update_document_content_maybe_archive_file(document_id) -> None:
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
oldDocument = Document.objects.get(pk=document.pk)
|
oldDocument = Document.objects.get(pk=document.pk)
|
||||||
if parser.get_archive_path():
|
if parser.get_archive_path():
|
||||||
checksum = compute_checksum(parser.get_archive_path())
|
with Path(parser.get_archive_path()).open("rb") as f:
|
||||||
|
checksum = hashlib.md5(f.read()).hexdigest()
|
||||||
# I'm going to save first so that in case the file move
|
# I'm going to save first so that in case the file move
|
||||||
# fails, the database is rolled back.
|
# fails, the database is rolled back.
|
||||||
# We also don't use save() since that triggers the filehandling
|
# We also don't use save() since that triggers the filehandling
|
||||||
@@ -532,13 +533,13 @@ def check_scheduled_workflows() -> None:
|
|||||||
id__in=matched_ids,
|
id__in=matched_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
if documents.exists():
|
if documents.count() > 0:
|
||||||
documents = prefilter_documents_by_workflowtrigger(
|
documents = prefilter_documents_by_workflowtrigger(
|
||||||
documents,
|
documents,
|
||||||
trigger,
|
trigger,
|
||||||
)
|
)
|
||||||
|
|
||||||
if documents.exists():
|
if documents.count() > 0:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Found {documents.count()} documents for trigger {trigger}",
|
f"Found {documents.count()} documents for trigger {trigger}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<p>
|
<p>
|
||||||
{% translate "Please sign in." %}
|
{% translate "Please sign in." %}
|
||||||
{% if ACCOUNT_ALLOW_SIGNUPS %}
|
{% if ACCOUNT_ALLOW_SIGNUPS %}
|
||||||
<br/>{% translate "Don't have an account yet?" %} <a href="{{ signup_url }}">{% translate "Sign up" %}</a>
|
<br/>{% blocktrans %}Don't have an account yet? <a href="{{ signup_url }}">Sign up</a>{% endblocktrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endblock form_top_content %}
|
{% endblock form_top_content %}
|
||||||
@@ -25,12 +25,12 @@
|
|||||||
{% translate "Username" as i18n_username %}
|
{% translate "Username" as i18n_username %}
|
||||||
{% translate "Password" as i18n_password %}
|
{% translate "Password" as i18n_password %}
|
||||||
<div class="form-floating form-stacked-top">
|
<div class="form-floating form-stacked-top">
|
||||||
<input type="text" name="login" id="inputUsername" placeholder="{{ i18n_username|force_escape }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
|
<input type="text" name="login" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
|
||||||
<label for="inputUsername">{{ i18n_username|force_escape }}</label>
|
<label for="inputUsername">{{ i18n_username }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-floating form-stacked-bottom">
|
<div class="form-floating form-stacked-bottom">
|
||||||
<input type="password" name="password" id="inputPassword" placeholder="{{ i18n_password|force_escape }}" class="form-control" required>
|
<input type="password" name="password" id="inputPassword" placeholder="{{ i18n_password }}" class="form-control" required>
|
||||||
<label for="inputPassword">{{ i18n_password|force_escape }}</label>
|
<label for="inputPassword">{{ i18n_password }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid mt-3">
|
<div class="d-grid mt-3">
|
||||||
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
|
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% translate "Email" as i18n_email %}
|
{% translate "Email" as i18n_email %}
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input type="email" name="{{form.email.name}}" id="inputEmail" placeholder="{{ i18n_email|force_escape }}" class="form-control" required>
|
<input type="email" name="{{form.email.name}}" id="inputEmail" placeholder="{{ i18n_email }}" class="form-control" required>
|
||||||
<label for="inputEmail">{{ i18n_email|force_escape }}</label>
|
<label for="inputEmail">{{ i18n_email }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid mt-3">
|
<div class="d-grid mt-3">
|
||||||
<button class="btn btn-lg btn-primary" type="submit">{% translate "Send me instructions!" %}</button>
|
<button class="btn btn-lg btn-primary" type="submit">{% translate "Send me instructions!" %}</button>
|
||||||
|
|||||||
@@ -17,12 +17,12 @@
|
|||||||
{% translate "New Password" as i18n_new_password1 %}
|
{% translate "New Password" as i18n_new_password1 %}
|
||||||
{% translate "Confirm Password" as i18n_new_password2 %}
|
{% translate "Confirm Password" as i18n_new_password2 %}
|
||||||
<div class="form-floating form-stacked-top">
|
<div class="form-floating form-stacked-top">
|
||||||
<input type="password" name="{{form.password1.name}}" id="inputPassword1" placeholder="{{ i18n_new_password1|force_escape }}" class="form-control" required>
|
<input type="password" name="{{form.password1.name}}" id="inputPassword1" placeholder="{{ i18n_new_password1 }}" class="form-control" required>
|
||||||
<label for="inputPassword1">{{ i18n_new_password1|force_escape }}</label>
|
<label for="inputPassword1">{{ i18n_new_password1 }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-floating form-stacked-bottom">
|
<div class="form-floating form-stacked-bottom">
|
||||||
<input type="password" name="{{form.password2.name}}" id="inputPassword2" placeholder="{{ i18n_new_password2|force_escape }}" class="form-control" required>
|
<input type="password" name="{{form.password2.name}}" id="inputPassword2" placeholder="{{ i18n_new_password2 }}" class="form-control" required>
|
||||||
<label for="inputPassword2">{{ i18n_new_password2|force_escape }}</label>
|
<label for="inputPassword2">{{ i18n_new_password2 }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid mt-3">
|
<div class="d-grid mt-3">
|
||||||
<button class="btn btn-lg btn-primary" type="submit">{% translate "Change my password" %}</button>
|
<button class="btn btn-lg btn-primary" type="submit">{% translate "Change my password" %}</button>
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
|
|
||||||
{% block form_content %}
|
{% block form_content %}
|
||||||
{% url 'account_login' as login_url %}
|
{% url 'account_login' as login_url %}
|
||||||
<p>{% translate "Your new password has been set. You can now" %} <a href="{{ login_url }}">{% translate "log in" %}</a>.</p>
|
<p>{% blocktranslate %}Your new password has been set. You can now <a href="{{ login_url }}">log in</a>{% endblocktranslate %}.</p>
|
||||||
{% endblock form_content %}
|
{% endblock form_content %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% block form_top_content %}
|
{% block form_top_content %}
|
||||||
{% if not FIRST_INSTALL %}
|
{% if not FIRST_INSTALL %}
|
||||||
<p>
|
<p>
|
||||||
{% translate "Already have an account?" %} <a href="{{ login_url }}">{% translate "Sign in" %}</a>
|
{% blocktrans %}Already have an account? <a href="{{ login_url }}">Sign in</a>{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock form_top_content %}
|
{% endblock form_top_content %}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
{% block form_content %}
|
{% block form_content %}
|
||||||
{% if FIRST_INSTALL %}
|
{% if FIRST_INSTALL %}
|
||||||
<p>
|
<p>
|
||||||
{% translate "Note: This is the first user account for this installation and will be granted superuser privileges." %}
|
{% blocktrans %}Note: This is the first user account for this installation and will be granted superuser privileges.{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% translate "Username" as i18n_username %}
|
{% translate "Username" as i18n_username %}
|
||||||
@@ -24,20 +24,20 @@
|
|||||||
{% translate "Password" as i18n_password1 %}
|
{% translate "Password" as i18n_password1 %}
|
||||||
{% translate "Password (again)" as i18n_password2 %}
|
{% translate "Password (again)" as i18n_password2 %}
|
||||||
<div class="form-floating form-stacked-top">
|
<div class="form-floating form-stacked-top">
|
||||||
<input type="text" name="username" id="inputUsername" placeholder="{{ i18n_username|force_escape }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
|
<input type="text" name="username" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
|
||||||
<label for="inputUsername">{{ i18n_username|force_escape }}</label>
|
<label for="inputUsername">{{ i18n_username }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-floating form-stacked-middle">
|
<div class="form-floating form-stacked-middle">
|
||||||
<input type="email" name="email" id="inputEmail" placeholder="{{ i18n_email|force_escape }}" class="form-control">
|
<input type="email" name="email" id="inputEmail" placeholder="{{ i18n_email }}" class="form-control">
|
||||||
<label for="inputEmail">{{ i18n_email|force_escape }}</label>
|
<label for="inputEmail">{{ i18n_email }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-floating form-stacked-middle">
|
<div class="form-floating form-stacked-middle">
|
||||||
<input type="password" name="password1" id="inputPassword1" placeholder="{{ i18n_password1|force_escape }}" class="form-control" required>
|
<input type="password" name="password1" id="inputPassword1" placeholder="{{ i18n_password1 }}" class="form-control" required>
|
||||||
<label for="inputPassword1">{{ i18n_password1|force_escape }}</label>
|
<label for="inputPassword1">{{ i18n_password1 }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-floating form-stacked-bottom">
|
<div class="form-floating form-stacked-bottom">
|
||||||
<input type="password" name="password2" id="inputPassword2" placeholder="{{ i18n_password2|force_escape }}" class="form-control" required>
|
<input type="password" name="password2" id="inputPassword2" placeholder="{{ i18n_password2 }}" class="form-control" required>
|
||||||
<label for="inputPassword2">{{ i18n_password2|force_escape }}</label>
|
<label for="inputPassword2">{{ i18n_password2 }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid mt-3">
|
<div class="d-grid mt-3">
|
||||||
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign up" %}</button>
|
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign up" %}</button>
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
|
|
||||||
{% block form_top_content %}
|
{% block form_top_content %}
|
||||||
<p>
|
<p>
|
||||||
{% translate "Your account is protected by two-factor authentication. Please enter an authenticator code:" %}
|
{% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %}
|
||||||
</p>
|
</p>
|
||||||
{% endblock form_top_content %}
|
{% endblock form_top_content %}
|
||||||
|
|
||||||
{% block form_content %}
|
{% block form_content %}
|
||||||
{% translate "Code" as i18n_code %}
|
{% translate "Code" as i18n_code %}
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input type="code" name="code" id="inputCode" autocomplete="one-time-code" placeholder="{{ i18n_code|force_escape }}" class="form-control" required autofocus>
|
<input type="code" name="code" id="inputCode" autocomplete="one-time-code" placeholder="{{ i18n_code }}" class="form-control" required autofocus>
|
||||||
<label for="inputCode">{{ i18n_code|force_escape }}</label>
|
<label for="inputCode">{{ i18n_code }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid mt-3">
|
<div class="d-grid mt-3">
|
||||||
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
|
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
|
|
||||||
{% block form_content %}
|
{% block form_content %}
|
||||||
{% url 'account_login' as login_url %}
|
{% url 'account_login' as login_url %}
|
||||||
<p>{% translate "An error occurred while attempting to login via your social network account. Back to the" %} <a href="{{ login_url }}">{% translate "login page" %}</a></p>
|
<p>{% blocktranslate %}An error occurred while attempting to login via your social network account. Back to the <a href="{{ login_url }}">login page</a>{% endblocktranslate %}</p>
|
||||||
{% endblock form_content %}
|
{% endblock form_content %}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
|
|
||||||
{% block form_content %}
|
{% block form_content %}
|
||||||
<p>
|
<p>
|
||||||
{% filter force_escape %}
|
{% blocktrans with provider.name as provider %}You are about to connect a new third-party account from {{ provider }}.{% endblocktrans %}
|
||||||
{% blocktrans with provider=provider.name %}You are about to connect a new third-party account from {{ provider }}.{% endblocktrans %}
|
|
||||||
{% endfilter %}
|
|
||||||
</p>
|
</p>
|
||||||
<div class="d-grid mt-3">
|
<div class="d-grid mt-3">
|
||||||
<button class="btn btn-lg btn-primary" type="submit">{% translate "Continue" %}</button>
|
<button class="btn btn-lg btn-primary" type="submit">{% translate "Continue" %}</button>
|
||||||
|
|||||||
@@ -7,20 +7,18 @@
|
|||||||
|
|
||||||
{% block form_content %}
|
{% block form_content %}
|
||||||
<p>
|
<p>
|
||||||
{% filter force_escape %}
|
{% blocktrans with provider_name=account.get_provider.name %}You are about to use your {{provider_name}} account to login.{% endblocktrans %}
|
||||||
{% blocktrans with provider_name=account.get_provider.name %}You are about to use your {{ provider_name }} account to login.{% endblocktrans %}
|
{% blocktrans %}As a final step, please complete the following form:{% endblocktrans %}
|
||||||
{% endfilter %}
|
|
||||||
{% translate "As a final step, please complete the following form:" %}
|
|
||||||
</p>
|
</p>
|
||||||
{% translate "Username" as i18n_username %}
|
{% translate "Username" as i18n_username %}
|
||||||
{% translate "Email (optional)" as i18n_email %}
|
{% translate "Email (optional)" as i18n_email %}
|
||||||
<div class="form-floating form-stacked-top">
|
<div class="form-floating form-stacked-top">
|
||||||
<input type="text" name="{{ form.username.name }}" id="inputUsername" placeholder="{{ i18n_username|force_escape }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus value="{{ form.username.value }}">
|
<input type="text" name="{{ form.username.name }}" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus value="{{ form.username.value }}">
|
||||||
<label for="inputUsername">{{ i18n_username|force_escape }}</label>
|
<label for="inputUsername">{{ i18n_username }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-floating form-stacked-bottom">
|
<div class="form-floating form-stacked-bottom">
|
||||||
<input type="email" name="{{ form.email.name }}" id="inputEmail" placeholder="{{ i18n_email|force_escape }}" class="form-control" autocorrect="off" autocapitalize="none" autofocus value="{{ form.email.value }}">
|
<input type="email" name="{{ form.email.name }}" id="inputEmail" placeholder="{{ i18n_email }}" class="form-control" autocorrect="off" autocapitalize="none" autofocus value="{{ form.email.value }}">
|
||||||
<label for="inputEmail">{{ i18n_email|force_escape }}</label>
|
<label for="inputEmail">{{ i18n_email }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% if redirect_field_value %}
|
{% if redirect_field_value %}
|
||||||
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
|
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ def sample_doc(
|
|||||||
|
|
||||||
return DocumentFactory(
|
return DocumentFactory(
|
||||||
title="test",
|
title="test",
|
||||||
checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
|
checksum="42995833e01aea9b3edee44bbfdd7ce1",
|
||||||
archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf",
|
archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b",
|
||||||
content="test content",
|
content="test content",
|
||||||
pk=1,
|
pk=1,
|
||||||
filename="0000001.pdf",
|
filename="0000001.pdf",
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class DocumentFactory(DjangoModelFactory):
|
|||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
title = factory.Faker("sentence", nb_words=4)
|
title = factory.Faker("sentence", nb_words=4)
|
||||||
checksum = factory.Faker("sha256")
|
checksum = factory.Faker("md5")
|
||||||
content = factory.Faker("paragraph")
|
content = factory.Faker("paragraph")
|
||||||
correspondent = None
|
correspondent = None
|
||||||
document_type = None
|
document_type = None
|
||||||
|
|||||||
@@ -261,14 +261,8 @@ class TestConsumer(
|
|||||||
|
|
||||||
self.assertIsFile(document.archive_path)
|
self.assertIsFile(document.archive_path)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(document.checksum, "42995833e01aea9b3edee44bbfdd7ce1")
|
||||||
document.checksum,
|
self.assertEqual(document.archive_checksum, "62acb0bcbfbcaa62ca6ad3668e4e404b")
|
||||||
"1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
document.archive_checksum,
|
|
||||||
"706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIsNotFile(filename)
|
self.assertIsNotFile(filename)
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ class TestExportImport(
|
|||||||
|
|
||||||
self.d1 = Document.objects.create(
|
self.d1 = Document.objects.create(
|
||||||
content="Content",
|
content="Content",
|
||||||
checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
|
checksum="42995833e01aea9b3edee44bbfdd7ce1",
|
||||||
archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf",
|
archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b",
|
||||||
title="wow1",
|
title="wow1",
|
||||||
filename="0000001.pdf",
|
filename="0000001.pdf",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
@@ -72,21 +72,21 @@ class TestExportImport(
|
|||||||
)
|
)
|
||||||
self.d2 = Document.objects.create(
|
self.d2 = Document.objects.create(
|
||||||
content="Content",
|
content="Content",
|
||||||
checksum="550d1bae0f746d4f7c6be07054eb20cc2f11988a58ef64ceae45e98f85e92a5b",
|
checksum="9c9691e51741c1f4f41a20896af31770",
|
||||||
title="wow2",
|
title="wow2",
|
||||||
filename="0000002.pdf",
|
filename="0000002.pdf",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
self.d3 = Document.objects.create(
|
self.d3 = Document.objects.create(
|
||||||
content="Content",
|
content="Content",
|
||||||
checksum="f1ba6b7ff8548214a75adec228f5468a14fe187f445bc0b9485cbf1c35b15915",
|
checksum="d38d7ed02e988e072caf924e0f3fcb76",
|
||||||
title="wow2",
|
title="wow2",
|
||||||
filename="0000003.pdf",
|
filename="0000003.pdf",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
self.d4 = Document.objects.create(
|
self.d4 = Document.objects.create(
|
||||||
content="Content",
|
content="Content",
|
||||||
checksum="a81b16b6b313cfd7e60eb7b12598d1343b58622b4030cfa19a2724a02e98db1b",
|
checksum="82186aaa94f0b98697d704b90fd1c072",
|
||||||
title="wow_dec",
|
title="wow_dec",
|
||||||
filename="0000004.pdf",
|
filename="0000004.pdf",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
@@ -239,7 +239,7 @@ class TestExportImport(
|
|||||||
)
|
)
|
||||||
|
|
||||||
with Path(fname).open("rb") as f:
|
with Path(fname).open("rb") as f:
|
||||||
checksum = hashlib.sha256(f.read()).hexdigest()
|
checksum = hashlib.md5(f.read()).hexdigest()
|
||||||
self.assertEqual(checksum, element["fields"]["checksum"])
|
self.assertEqual(checksum, element["fields"]["checksum"])
|
||||||
|
|
||||||
# Generated field "content_length" should not be exported,
|
# Generated field "content_length" should not be exported,
|
||||||
@@ -253,7 +253,7 @@ class TestExportImport(
|
|||||||
self.assertIsFile(fname)
|
self.assertIsFile(fname)
|
||||||
|
|
||||||
with Path(fname).open("rb") as f:
|
with Path(fname).open("rb") as f:
|
||||||
checksum = hashlib.sha256(f.read()).hexdigest()
|
checksum = hashlib.md5(f.read()).hexdigest()
|
||||||
self.assertEqual(checksum, element["fields"]["archive_checksum"])
|
self.assertEqual(checksum, element["fields"]["archive_checksum"])
|
||||||
|
|
||||||
elif element["model"] == "documents.note":
|
elif element["model"] == "documents.note":
|
||||||
|
|||||||
@@ -277,8 +277,8 @@ class TestCommandImport(
|
|||||||
|
|
||||||
Document.objects.create(
|
Document.objects.create(
|
||||||
content="Content",
|
content="Content",
|
||||||
checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
|
checksum="42995833e01aea9b3edee44bbfdd7ce1",
|
||||||
archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf",
|
archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b",
|
||||||
title="wow1",
|
title="wow1",
|
||||||
filename="0000001.pdf",
|
filename="0000001.pdf",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import connection
|
|
||||||
from django.test import override_settings
|
|
||||||
|
|
||||||
from documents.tests.utils import TestMigrations
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(data: bytes) -> str:
|
|
||||||
return hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSha256ChecksumDataMigration(TestMigrations):
|
|
||||||
"""recompute_checksums correctly updates document checksums from MD5 to SHA256."""
|
|
||||||
|
|
||||||
migrate_from = "0015_document_version_index_and_more"
|
|
||||||
migrate_to = "0016_sha256_checksums"
|
|
||||||
reset_sequences = True
|
|
||||||
|
|
||||||
ORIGINAL_CONTENT = b"original file content for sha256 migration test"
|
|
||||||
ARCHIVE_CONTENT = b"archive file content for sha256 migration test"
|
|
||||||
|
|
||||||
def setUpBeforeMigration(self, apps) -> None:
|
|
||||||
self._originals_dir = Path(tempfile.mkdtemp())
|
|
||||||
self._archive_dir = Path(tempfile.mkdtemp())
|
|
||||||
self._settings_override = override_settings(
|
|
||||||
ORIGINALS_DIR=self._originals_dir,
|
|
||||||
ARCHIVE_DIR=self._archive_dir,
|
|
||||||
)
|
|
||||||
self._settings_override.enable()
|
|
||||||
Document = apps.get_model("documents", "Document")
|
|
||||||
|
|
||||||
# doc1: original file present, no archive
|
|
||||||
(settings.ORIGINALS_DIR / "doc1.txt").write_bytes(self.ORIGINAL_CONTENT)
|
|
||||||
self.doc1_id = Document.objects.create(
|
|
||||||
title="Doc 1",
|
|
||||||
mime_type="text/plain",
|
|
||||||
filename="doc1.txt",
|
|
||||||
checksum="a" * 32,
|
|
||||||
).pk
|
|
||||||
|
|
||||||
# doc2: original and archive both present
|
|
||||||
(settings.ORIGINALS_DIR / "doc2.txt").write_bytes(self.ORIGINAL_CONTENT)
|
|
||||||
(settings.ARCHIVE_DIR / "doc2.pdf").write_bytes(self.ARCHIVE_CONTENT)
|
|
||||||
self.doc2_id = Document.objects.create(
|
|
||||||
title="Doc 2",
|
|
||||||
mime_type="text/plain",
|
|
||||||
filename="doc2.txt",
|
|
||||||
checksum="b" * 32,
|
|
||||||
archive_filename="doc2.pdf",
|
|
||||||
archive_checksum="c" * 32,
|
|
||||||
).pk
|
|
||||||
|
|
||||||
# doc3: original file missing — checksum must stay unchanged
|
|
||||||
self.doc3_id = Document.objects.create(
|
|
||||||
title="Doc 3",
|
|
||||||
mime_type="text/plain",
|
|
||||||
filename="missing_original.txt",
|
|
||||||
checksum="d" * 32,
|
|
||||||
).pk
|
|
||||||
|
|
||||||
# doc4: original present, archive_filename set but archive file missing
|
|
||||||
(settings.ORIGINALS_DIR / "doc4.txt").write_bytes(self.ORIGINAL_CONTENT)
|
|
||||||
self.doc4_id = Document.objects.create(
|
|
||||||
title="Doc 4",
|
|
||||||
mime_type="text/plain",
|
|
||||||
filename="doc4.txt",
|
|
||||||
checksum="e" * 32,
|
|
||||||
archive_filename="missing_archive.pdf",
|
|
||||||
archive_checksum="f" * 32,
|
|
||||||
).pk
|
|
||||||
|
|
||||||
# doc5: original present, archive_filename is None — archive_checksum must stay null
|
|
||||||
(settings.ORIGINALS_DIR / "doc5.txt").write_bytes(self.ORIGINAL_CONTENT)
|
|
||||||
self.doc5_id = Document.objects.create(
|
|
||||||
title="Doc 5",
|
|
||||||
mime_type="text/plain",
|
|
||||||
filename="doc5.txt",
|
|
||||||
checksum="0" * 32,
|
|
||||||
archive_filename=None,
|
|
||||||
archive_checksum=None,
|
|
||||||
).pk
|
|
||||||
|
|
||||||
def _fixture_teardown(self) -> None:
|
|
||||||
super()._fixture_teardown()
|
|
||||||
# Django's SQLite backend returns [] from sequence_reset_sql(), so
|
|
||||||
# reset_sequences=True flushes rows but never clears sqlite_sequence.
|
|
||||||
# Explicitly delete the entry so subsequent tests start from pk=1.
|
|
||||||
if connection.vendor == "sqlite":
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute(
|
|
||||||
"DELETE FROM sqlite_sequence WHERE name='documents_document'",
|
|
||||||
)
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
super().tearDown()
|
|
||||||
self._settings_override.disable()
|
|
||||||
shutil.rmtree(self._originals_dir, ignore_errors=True)
|
|
||||||
shutil.rmtree(self._archive_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
def test_original_checksum_updated_to_sha256_when_file_exists(self) -> None:
|
|
||||||
Document = self.apps.get_model("documents", "Document")
|
|
||||||
doc = Document.objects.get(pk=self.doc1_id)
|
|
||||||
self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT))
|
|
||||||
|
|
||||||
def test_both_checksums_updated_when_original_and_archive_exist(self) -> None:
|
|
||||||
Document = self.apps.get_model("documents", "Document")
|
|
||||||
doc = Document.objects.get(pk=self.doc2_id)
|
|
||||||
self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT))
|
|
||||||
self.assertEqual(doc.archive_checksum, _sha256(self.ARCHIVE_CONTENT))
|
|
||||||
|
|
||||||
def test_checksum_unchanged_when_original_file_missing(self) -> None:
|
|
||||||
Document = self.apps.get_model("documents", "Document")
|
|
||||||
doc = Document.objects.get(pk=self.doc3_id)
|
|
||||||
self.assertEqual(doc.checksum, "d" * 32)
|
|
||||||
|
|
||||||
def test_archive_checksum_unchanged_when_archive_file_missing(self) -> None:
|
|
||||||
Document = self.apps.get_model("documents", "Document")
|
|
||||||
doc = Document.objects.get(pk=self.doc4_id)
|
|
||||||
# Original was updated (file exists)
|
|
||||||
self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT))
|
|
||||||
# Archive was not updated (file missing)
|
|
||||||
self.assertEqual(doc.archive_checksum, "f" * 32)
|
|
||||||
|
|
||||||
def test_archive_checksum_stays_null_when_no_archive_filename(self) -> None:
|
|
||||||
Document = self.apps.get_model("documents", "Document")
|
|
||||||
doc = Document.objects.get(pk=self.doc5_id)
|
|
||||||
self.assertIsNone(doc.archive_checksum)
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
from os import utime
|
from os import utime
|
||||||
@@ -129,28 +128,3 @@ def get_boolean(boolstr: str) -> bool:
|
|||||||
Return a boolean value from a string representation.
|
Return a boolean value from a string representation.
|
||||||
"""
|
"""
|
||||||
return bool(boolstr.lower() in ("yes", "y", "1", "t", "true"))
|
return bool(boolstr.lower() in ("yes", "y", "1", "t", "true"))
|
||||||
|
|
||||||
|
|
||||||
def compute_checksum(path: Path, chunk_size: int = 65536) -> str:
|
|
||||||
"""
|
|
||||||
Compute the SHA-256 checksum of a file.
|
|
||||||
|
|
||||||
Reads the file in chunks to avoid loading the entire file into memory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (Path): Path to the file to hash.
|
|
||||||
chunk_size (int, optional): Number of bytes to read per chunk.
|
|
||||||
Defaults to 65536.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Hexadecimal SHA-256 digest of the file contents.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If the file does not exist.
|
|
||||||
OSError: If the file cannot be read.
|
|
||||||
"""
|
|
||||||
h = hashlib.sha256()
|
|
||||||
with path.open("rb") as f:
|
|
||||||
while chunk := f.read(chunk_size):
|
|
||||||
h.update(chunk)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|||||||
@@ -2027,10 +2027,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
except NotFound:
|
except NotFound:
|
||||||
raise
|
raise
|
||||||
except PermissionDenied as e:
|
except PermissionDenied as e:
|
||||||
invalid_more_like_id_message = _("Invalid more_like_id")
|
return HttpResponseForbidden(str(e.detail))
|
||||||
if str(e.detail) == str(invalid_more_like_id_message):
|
|
||||||
return HttpResponseForbidden(invalid_more_like_id_message)
|
|
||||||
return HttpResponseForbidden(_("Insufficient permissions."))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"An error occurred listing search results: {e!s}")
|
logger.warning(f"An error occurred listing search results: {e!s}")
|
||||||
return HttpResponseBadRequest(
|
return HttpResponseBadRequest(
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ def execute_password_removal_action(
|
|||||||
passwords = action.passwords
|
passwords = action.passwords
|
||||||
if not passwords:
|
if not passwords:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Workflow action %s has no configured unlock values",
|
"Password removal action %s has no passwords configured",
|
||||||
action.pk,
|
action.pk,
|
||||||
extra={"group": logging_group},
|
extra={"group": logging_group},
|
||||||
)
|
)
|
||||||
@@ -321,23 +321,22 @@ def execute_password_removal_action(
|
|||||||
user=document.owner,
|
user=document.owner,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Unlocked document %s using workflow action %s",
|
"Removed password from document %s using workflow action %s",
|
||||||
document.pk,
|
document.pk,
|
||||||
action.pk,
|
action.pk,
|
||||||
extra={"group": logging_group},
|
extra={"group": logging_group},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Workflow action %s could not unlock document %s with one configured value",
|
"Password removal failed for document %s with supplied password: %s",
|
||||||
action.pk,
|
|
||||||
document.pk,
|
document.pk,
|
||||||
|
e,
|
||||||
extra={"group": logging_group},
|
extra={"group": logging_group},
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"Workflow action %s could not unlock document %s with any configured value",
|
"Password removal failed for document %s after trying all provided passwords",
|
||||||
action.pk,
|
|
||||||
document.pk,
|
document.pk,
|
||||||
extra={"group": logging_group},
|
extra={"group": logging_group},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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-26 14:37+0000\n"
|
"POT-Creation-Date: 2026-03-22 13:54+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"
|
||||||
@@ -1301,7 +1301,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: documents/serialisers.py:463 documents/serialisers.py:815
|
#: documents/serialisers.py:463 documents/serialisers.py:815
|
||||||
#: documents/serialisers.py:2501 documents/views.py:1990
|
#: documents/serialisers.py:2501 documents/views.py:1990
|
||||||
#: documents/views.py:2033 paperless_mail/serialisers.py:143
|
#: paperless_mail/serialisers.py:143
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1341,7 +1341,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2587 documents/views.py:3599
|
#: documents/serialisers.py:2587 documents/views.py:3596
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1383,18 +1383,13 @@ msgid "Please sign in."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/account/login.html:12
|
#: documents/templates/account/login.html:12
|
||||||
msgid "Don't have an account yet?"
|
#, python-format
|
||||||
msgstr ""
|
msgid "Don't have an account yet? <a href=\"%(signup_url)s\">Sign up</a>"
|
||||||
|
|
||||||
#: documents/templates/account/login.html:12
|
|
||||||
#: documents/templates/account/signup.html:43
|
|
||||||
#: documents/templates/socialaccount/signup.html:29
|
|
||||||
msgid "Sign up"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/account/login.html:25
|
#: documents/templates/account/login.html:25
|
||||||
#: documents/templates/account/signup.html:22
|
#: documents/templates/account/signup.html:22
|
||||||
#: documents/templates/socialaccount/signup.html:15
|
#: documents/templates/socialaccount/signup.html:13
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1404,7 +1399,6 @@ msgid "Password"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/account/login.html:36
|
#: documents/templates/account/login.html:36
|
||||||
#: documents/templates/account/signup.html:11
|
|
||||||
#: documents/templates/mfa/authenticate.html:23
|
#: documents/templates/mfa/authenticate.html:23
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1483,11 +1477,10 @@ msgid "Password reset complete."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/account/password_reset_from_key_done.html:14
|
#: documents/templates/account/password_reset_from_key_done.html:14
|
||||||
msgid "Your new password has been set. You can now"
|
#, python-format
|
||||||
msgstr ""
|
msgid ""
|
||||||
|
"Your new password has been set. You can now <a href=\"%(login_url)s\">log "
|
||||||
#: documents/templates/account/password_reset_from_key_done.html:14
|
"in</a>"
|
||||||
msgid "log in"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/account/signup.html:5
|
#: documents/templates/account/signup.html:5
|
||||||
@@ -1495,7 +1488,8 @@ msgid "Paperless-ngx sign up"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/account/signup.html:11
|
#: documents/templates/account/signup.html:11
|
||||||
msgid "Already have an account?"
|
#, python-format
|
||||||
|
msgid "Already have an account? <a href=\"%(login_url)s\">Sign in</a>"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/account/signup.html:19
|
#: documents/templates/account/signup.html:19
|
||||||
@@ -1505,7 +1499,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/account/signup.html:23
|
#: documents/templates/account/signup.html:23
|
||||||
#: documents/templates/socialaccount/signup.html:16
|
#: documents/templates/socialaccount/signup.html:14
|
||||||
msgid "Email (optional)"
|
msgid "Email (optional)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1513,6 +1507,11 @@ msgstr ""
|
|||||||
msgid "Password (again)"
|
msgid "Password (again)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: documents/templates/account/signup.html:43
|
||||||
|
#: documents/templates/socialaccount/signup.html:27
|
||||||
|
msgid "Sign up"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/index.html:61
|
#: documents/templates/index.html:61
|
||||||
msgid "Paperless-ngx is loading..."
|
msgid "Paperless-ngx is loading..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1557,21 +1556,18 @@ msgid "Paperless-ngx social account sign in"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/socialaccount/authentication_error.html:10
|
#: documents/templates/socialaccount/authentication_error.html:10
|
||||||
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
"An error occurred while attempting to login via your social network account. "
|
"An error occurred while attempting to login via your social network account. "
|
||||||
"Back to the"
|
"Back to the <a href=\"%(login_url)s\">login page</a>"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/socialaccount/authentication_error.html:10
|
#: documents/templates/socialaccount/login.html:10
|
||||||
msgid "login page"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: documents/templates/socialaccount/login.html:11
|
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "You are about to connect a new third-party account from %(provider)s."
|
msgid "You are about to connect a new third-party account from %(provider)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/socialaccount/login.html:15
|
#: documents/templates/socialaccount/login.html:13
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1579,12 +1575,12 @@ msgstr ""
|
|||||||
msgid "Paperless-ngx social account sign up"
|
msgid "Paperless-ngx social account sign up"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/socialaccount/signup.html:11
|
#: documents/templates/socialaccount/signup.html:10
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "You are about to use your %(provider_name)s account to login."
|
msgid "You are about to use your %(provider_name)s account to login."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/templates/socialaccount/signup.html:13
|
#: documents/templates/socialaccount/signup.html:11
|
||||||
msgid "As a final step, please complete the following form:"
|
msgid "As a final step, please complete the following form:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1609,24 +1605,24 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1983 documents/views.py:2030
|
#: documents/views.py:1983
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:3611
|
#: documents/views.py:3608
|
||||||
#, 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:3654
|
#: documents/views.py:3651
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:3711
|
#: documents/views.py:3708
|
||||||
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:3721
|
#: documents/views.py:3718
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{% autoescape off %}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
@@ -12,34 +13,36 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="grid gap-x-2 bg-slate-200 p-4">
|
<div class="grid gap-x-2 bg-slate-200 p-4">
|
||||||
|
|
||||||
<div class="col-start-9 col-span-4 row-start-1 text-right">{{ date|safe }}</div>
|
<div class="col-start-9 col-span-4 row-start-1 text-right">{{ date }}</div>
|
||||||
|
|
||||||
<div class="col-start-1 row-start-1 text-slate-400 text-right">{{ from_label }}</div>
|
<div class="col-start-1 row-start-1 text-slate-400 text-right">{{ from_label }}</div>
|
||||||
<div class="col-start-2 col-span-7 row-start-1">{{ from|safe }}</div>
|
<div class="col-start-2 col-span-7 row-start-1">{{ from }}</div>
|
||||||
|
|
||||||
<div class="col-start-1 row-start-2 text-slate-400 text-right">{{ subject_label }}</div>
|
<div class="col-start-1 row-start-2 text-slate-400 text-right">{{ subject_label }}</div>
|
||||||
<div class=" col-start-2 col-span-10 row-start-2 font-bold">{{ subject|safe }}</div>
|
<div class=" col-start-2 col-span-10 row-start-2 font-bold">{{ subject }}</div>
|
||||||
|
|
||||||
<div class="col-start-1 row-start-3 text-slate-400 text-right">{{ to_label }}</div>
|
<div class="col-start-1 row-start-3 text-slate-400 text-right">{{ to_label }}</div>
|
||||||
<div class="col-start-2 col-span-10 row-start-3 text-sm my-0.5">{{ to|safe }}</div>
|
<div class="col-start-2 col-span-10 row-start-3 text-sm my-0.5">{{ to }}</div>
|
||||||
|
|
||||||
<div class="col-start-1 row-start-4 text-slate-400 text-right">{{ cc_label }}</div>
|
<div class="col-start-1 row-start-4 text-slate-400 text-right">{{ cc_label }}</div>
|
||||||
<div class="col-start-2 col-span-10 row-start-4 text-sm my-0.5">{{ cc|safe }}</div>
|
<div class="col-start-2 col-span-10 row-start-4 text-sm my-0.5">{{ cc }}</div>
|
||||||
|
|
||||||
<div class="col-start-1 row-start-5 text-slate-400 text-right">{{ bcc_label }}</div>
|
<div class="col-start-1 row-start-5 text-slate-400 text-right">{{ bcc_label }}</div>
|
||||||
<div class="col-start-2 col-span-10 row-start-5" text-sm my-0.5>{{ bcc|safe }}</div>
|
<div class="col-start-2 col-span-10 row-start-5" text-sm my-0.5>{{ bcc }}</div>
|
||||||
|
|
||||||
<div class="col-start-1 row-start-6 text-slate-400 text-right">{{ attachments_label }}</div>
|
<div class="col-start-1 row-start-6 text-slate-400 text-right">{{ attachments_label }}</div>
|
||||||
<div class="col-start-2 col-span-10 row-start-6">{{ attachments|safe }}</div>
|
<div class="col-start-2 col-span-10 row-start-6">{{ attachments }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Separator-->
|
<!-- Separator-->
|
||||||
<div class="border-t border-solid border-b w-full h-[1px] box-content border-black mb-5 bg-slate-200"></div>
|
<div class="border-t border-solid border-b w-full h-[1px] box-content border-black mb-5 bg-slate-200"></div>
|
||||||
|
|
||||||
<!-- Content-->
|
<!-- Content-->
|
||||||
<div class="w-full break-words">{{ content|safe }}</div>
|
<div class="w-full break-words">{{ content }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
{% endautoescape %}
|
||||||
|
|||||||
@@ -191,10 +191,7 @@ class TestMailOAuth(
|
|||||||
).exists(),
|
).exists(),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn("Error getting access token: test_error", cm.output[0])
|
||||||
"Error getting access token from OAuth provider",
|
|
||||||
cm.output[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_oauth_callback_view_insufficient_permissions(self) -> None:
|
def test_oauth_callback_view_insufficient_permissions(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -138,16 +138,13 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
|||||||
existing_account.refresh_from_db()
|
existing_account.refresh_from_db()
|
||||||
account.password = existing_account.password
|
account.password = existing_account.password
|
||||||
else:
|
else:
|
||||||
logger.error(
|
|
||||||
"Mail account connectivity test failed: Unable to refresh oauth token",
|
|
||||||
)
|
|
||||||
raise MailError("Unable to refresh oauth token")
|
raise MailError("Unable to refresh oauth token")
|
||||||
|
|
||||||
mailbox_login(M, account)
|
mailbox_login(M, account)
|
||||||
return Response({"success": True})
|
return Response({"success": True})
|
||||||
except MailError:
|
except MailError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Mail account connectivity test failed",
|
f"Mail account {account} test failed: {e}",
|
||||||
)
|
)
|
||||||
return HttpResponseBadRequest("Unable to connect to server")
|
return HttpResponseBadRequest("Unable to connect to server")
|
||||||
|
|
||||||
@@ -221,7 +218,7 @@ class OauthCallbackView(GenericAPIView):
|
|||||||
|
|
||||||
if code is None:
|
if code is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Invalid oauth callback request: missing code",
|
f"Invalid oauth callback request, code: {code}, scope: {scope}",
|
||||||
)
|
)
|
||||||
return HttpResponseBadRequest("Invalid request, see logs for more detail")
|
return HttpResponseBadRequest("Invalid request, see logs for more detail")
|
||||||
|
|
||||||
@@ -232,7 +229,7 @@ class OauthCallbackView(GenericAPIView):
|
|||||||
state = request.query_params.get("state", "")
|
state = request.query_params.get("state", "")
|
||||||
if not oauth_manager.validate_state(state):
|
if not oauth_manager.validate_state(state):
|
||||||
logger.error(
|
logger.error(
|
||||||
"Invalid oauth callback request: state validation failed",
|
f"Invalid oauth callback request received state: {state}, expected: {oauth_manager.state}",
|
||||||
)
|
)
|
||||||
return HttpResponseBadRequest("Invalid request, see logs for more detail")
|
return HttpResponseBadRequest("Invalid request, see logs for more detail")
|
||||||
|
|
||||||
@@ -279,8 +276,8 @@ class OauthCallbackView(GenericAPIView):
|
|||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
f"{oauth_manager.oauth_redirect_url}?oauth_success=1&account_id={account.pk}",
|
f"{oauth_manager.oauth_redirect_url}?oauth_success=1&account_id={account.pk}",
|
||||||
)
|
)
|
||||||
except GetAccessTokenError:
|
except GetAccessTokenError as e:
|
||||||
logger.error("Error getting access token from OAuth provider")
|
logger.error(f"Error getting access token: {e}")
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
f"{oauth_manager.oauth_redirect_url}?oauth_success=0",
|
f"{oauth_manager.oauth_redirect_url}?oauth_success=0",
|
||||||
)
|
)
|
||||||
|
|||||||
52
uv.lock
generated
52
uv.lock
generated
@@ -361,31 +361,31 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbor2"
|
name = "cbor2"
|
||||||
version = "5.9.0"
|
version = "5.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/aa/317c7118b8dda4c9563125c1a12c70c5b41e36677964a49c72b1aac061ec/cbor2-5.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0485d3372fc832c5e16d4eb45fa1a20fc53e806e6c29a1d2b0d3e176cedd52b9", size = 70578, upload-time = "2026-03-22T15:56:03.835Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/4b/623435ef9b98e86b6956a41863d39ff4fe4d67983948b5834f55499681dd/cbor2-5.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:18ac191640093e6c7fbcb174c006ffec4106c3d8ab788e70272c1c4d933cbe11", size = 69875, upload-time = "2025-12-30T18:43:35.888Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/43/fe29b1f897770011a5e7497f4523c2712282ee4a6cbf775ea6383fb7afb9/cbor2-5.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9d6e4e0f988b0e766509a8071975a8ee99f930e14a524620bf38083106158d2", size = 268738, upload-time = "2026-03-22T15:56:05.222Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/17/f664201080b2a7d0f57c16c8e9e5922013b92f202e294863ec7e75b7ff7f/cbor2-5.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fddee9103a17d7bed5753f0c7fc6663faa506eb953e50d8287804eccf7b048e6", size = 268316, upload-time = "2025-12-30T18:43:37.161Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/e494568f3d8aafbcdfe361df44c3bcf5cdab5183e25ea08e3d3f9fcf4075/cbor2-5.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5326336f633cc89dfe543c78829c16c3a6449c2c03277d1ddba99086c3323363", size = 262571, upload-time = "2026-03-22T15:56:06.411Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/e1/072745b4ff01afe9df2cd627f8fc51a1acedb5d3d1253765625d2929db91/cbor2-5.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d2ea26fad620aba5e88d7541be8b10c5034a55db9a23809b7cb49f36803f05b", size = 258874, upload-time = "2025-12-30T18:43:38.878Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/2e/92acd6f87382fd44a34d9d7e85cc45372e6ba664040b72d1d9df648b25d0/cbor2-5.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e702b02d42a5ace45425b595ffe70fe35aebaf9a3cdfdc2c758b6189c744422", size = 262356, upload-time = "2026-03-22T15:56:08.236Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/10/61c262b886d22b62c56e8aac6d10fa06d0953c997879ab882a31a624952b/cbor2-5.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:de68b4b310b072b082d317adc4c5e6910173a6d9455412e6183d72c778d1f54c", size = 261971, upload-time = "2025-12-30T18:43:40.401Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/68/52c039a28688baeeb78b0be7483855e6c66ea05884a937444deede0c87b8/cbor2-5.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2372d357d403e7912f104ff085950ffc82a5854d6d717f1ca1ce16a40a0ef5a7", size = 257604, upload-time = "2026-03-22T15:56:09.835Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/42/b7862f5e64364b10ad120ea53e87ec7e891fb268cb99c572348e647cf7e9/cbor2-5.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:418d2cf0e03e90160fa1474c05a40fe228bbb4a92d1628bdbbd13a48527cb34d", size = 254151, upload-time = "2025-12-30T18:43:41.938Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/39/72d8a5a4b06565561ec28f4fcb41aff7bb77f51705c01f00b8254a2aca4f/cbor2-5.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f223dffb1bcdd2764665f04c1152943d9daa4bc124a576cd8dee1cad4264313", size = 71223, upload-time = "2026-03-22T15:56:13.68Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/4f/3a16e3e8fd7e5fd86751a4f1aad218a8d19a96e75ec3989c3e95a8fe1d8f/cbor2-5.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3f91fa699a5ce22470e973601c62dd9d55dc3ca20ee446516ac075fcab27c9", size = 70270, upload-time = "2025-12-30T18:43:46.005Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/fd/7ddf3d3153b54c69c3be77172b8d9aa3a9d74f62a7fbde614d53eaeed9a4/cbor2-5.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6c706ac1d85a0b3cb3395308fd0c4d55e3202b4760773675957e93cdff45fc", size = 287865, upload-time = "2026-03-22T15:56:14.813Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/81/0d0cf0796fe8081492a61c45278f03def21a929535a492dd97c8438f5dbe/cbor2-5.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:518c118a5e00001854adb51f3164e647aa99b6a9877d2a733a28cb5c0a4d6857", size = 286242, upload-time = "2025-12-30T18:43:47.026Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/9d/7ede2cc42f9bb4260492e7d29d2aab781eacbbcfb09d983de1e695077199/cbor2-5.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cd43d8fc374b31643b2830910f28177a606a7bc84975a62675dd3f2e320fc7b", size = 288246, upload-time = "2026-03-22T15:56:16.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/a9/fdab6c10190cfb8d639e01f2b168f2406fc847a2a6bc00e7de78c3381d0a/cbor2-5.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cff2a1999e49cd51c23d1b6786a012127fd8f722c5946e82bd7ab3eb307443f3", size = 285412, upload-time = "2025-12-30T18:43:48.563Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/9d/588ebc7c5bc5843f609b05fe07be8575c7dec987735b0bbc908ac9c1264a/cbor2-5.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aa07b392cc3d76fb31c08a46a226b58c320d1c172ff3073e864409ced7bc50f", size = 280214, upload-time = "2026-03-22T15:56:17.519Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/59/746a8e630996217a3afd523f583fcf7e3d16640d63f9a03f0f4e4f74b5b1/cbor2-5.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c4492160212374973cdc14e46f0565f2462721ef922b40f7ea11e7d613dfb2a", size = 278041, upload-time = "2025-12-30T18:43:49.92Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/a1/6fc8f4b15c6a27e7fbb7966c30c2b4b18c274a3221fa2f5e6235502d34bc/cbor2-5.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:971d425b3a23b75953d8853d5f9911bdeefa09d759ee3b5e6b07b5ff3cbd9073", size = 282162, upload-time = "2026-03-22T15:56:18.975Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/a3/f3bbeb6dedd45c6e0cddd627ea790dea295eaf82c83f0e2159b733365ebd/cbor2-5.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:546c7c7c4c6bcdc54a59242e0e82cea8f332b17b4465ae628718fef1fce401ca", size = 278185, upload-time = "2025-12-30T18:43:51.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/c5/4901e21a8afe9448fd947b11e8f383903207cd6dd0800e5f5a386838de5b/cbor2-5.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbb06f34aa645b4deca66643bba3d400d20c15312d1fe88d429be60c1ab50f27", size = 71284, upload-time = "2026-03-22T15:56:22.836Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/10/df643a381aebc3f05486de4813662bc58accb640fc3275cb276a75e89694/cbor2-5.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac684fe195c39821fca70d18afbf748f728aefbfbf88456018d299e559b8cae0", size = 287682, upload-time = "2026-03-22T15:56:24.024Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/0c/8aa6b766059ae4a0ca1ec3ff96fe3823a69a7be880dba2e249f7fbe2700b/cbor2-5.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a54fbb32cb828c214f7f333a707e4aec61182e7efdc06ea5d9596d3ecee624a", size = 288009, upload-time = "2026-03-22T15:56:25.305Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/07/6236bc25c183a9cf7e8062e5dddf9eae9b0b14ebf14a58a69fe5a1e872c6/cbor2-5.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4753a6d1bc71054d9179557bc65740860f185095ccb401d46637fff028a5b3ec", size = 280437, upload-time = "2026-03-22T15:56:26.479Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/0a/84328d23c3c68874ac6497edb9b1900579a1028efa54734df3f1762bbc15/cbor2-5.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:380e534482b843e43442b87d8777a7bf9bed20cb7526f89b780c3400f617304b", size = 282247, upload-time = "2026-03-22T15:56:28.644Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/7d/9ccc36d10ef96e6038e48046ebe1ce35a1e7814da0e1e204d09e6ef09b8d/cbor2-5.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560", size = 71500, upload-time = "2026-03-22T15:56:31.876Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/e1/a6cca2cc72e13f00030c6a649f57ae703eb2c620806ab70c40db8eab33fa/cbor2-5.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e", size = 286953, upload-time = "2026-03-22T15:56:33.292Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/3c/24cd5ef488a957d90e016f200a3aad820e4c2f85edd61c9fe4523007a1ee/cbor2-5.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba", size = 285454, upload-time = "2026-03-22T15:56:34.703Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/35/dca96818494c0ba47cdd73e8d809b27fa91f8fa0ce32a068a09237687454/cbor2-5.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551", size = 279441, upload-time = "2026-03-22T15:56:35.888Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/44/d3362378b16e53cf7e535a3f5aed8476e2109068154e24e31981ef5bde9e/cbor2-5.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd", size = 279673, upload-time = "2026-03-22T15:56:37.08Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4211,7 +4211,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.33.0"
|
version = "2.32.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
@@ -4219,9 +4219,9 @@ dependencies = [
|
|||||||
{ name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user