Compare commits

..

3 Commits

42 changed files with 919 additions and 1086 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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**

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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
} }

View File

@@ -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;

View File

@@ -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

View File

@@ -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)

View File

@@ -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/`

View File

@@ -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)
} }

View File

@@ -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),
) )

View File

@@ -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

View File

@@ -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),
]

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}",
) )

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 }}" />

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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":

View File

@@ -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",

View File

@@ -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)

View File

@@ -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()

View File

@@ -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(

View File

@@ -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},
) )

View File

@@ -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 ""

View File

@@ -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 %}

View File

@@ -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:
""" """

View File

@@ -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
View File

@@ -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]]