mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-18 05:48:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
961f7fd672 | ||
|
|
de94657e17 | ||
|
|
0cb2cdbfdd | ||
|
|
1f3d451a3a | ||
|
|
183cfd2da4 |
No files matched your search
+11
-11
@@ -1824,7 +1824,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">35</context>
|
<context context-type="linenumber">34</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>
|
||||||
@@ -3581,7 +3581,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">47</context>
|
<context context-type="linenumber">46</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>
|
||||||
@@ -7731,21 +7731,21 @@
|
|||||||
<source>Show suggestions</source>
|
<source>Show suggestions</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">24,25</context>
|
<context context-type="linenumber">23,24</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3834115140127576673" datatype="html">
|
<trans-unit id="3834115140127576673" datatype="html">
|
||||||
<source>No novel suggestions</source>
|
<source>No novel suggestions</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">31,32</context>
|
<context context-type="linenumber">30,31</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4369111787961525769" datatype="html">
|
<trans-unit id="4369111787961525769" datatype="html">
|
||||||
<source>Document Types</source>
|
<source>Document Types</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">41</context>
|
<context context-type="linenumber">40</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>
|
||||||
@@ -7756,42 +7756,42 @@
|
|||||||
<source>{VAR_PLURAL, plural, =1 {1 suggestion available below} other {<x id="INTERPOLATION"/> suggestions available below}}</source>
|
<source>{VAR_PLURAL, plural, =1 {1 suggestion available below} other {<x id="INTERPOLATION"/> suggestions available below}}</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">54</context>
|
<context context-type="linenumber">53</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4169875112244042673" datatype="html">
|
<trans-unit id="4169875112244042673" datatype="html">
|
||||||
<source>Suggestion options</source>
|
<source>Suggestion options</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">65,66</context>
|
<context context-type="linenumber">63,64</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3145824396228856507" datatype="html">
|
<trans-unit id="3145824396228856507" datatype="html">
|
||||||
<source>Not using default</source>
|
<source>Not using default</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">69,70</context>
|
<context context-type="linenumber">67,68</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8102623853570619759" datatype="html">
|
<trans-unit id="8102623853570619759" datatype="html">
|
||||||
<source>Suggest using:</source>
|
<source>Suggest using:</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">74,75</context>
|
<context context-type="linenumber">72,73</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5799391967036896978" datatype="html">
|
<trans-unit id="5799391967036896978" datatype="html">
|
||||||
<source>ML</source>
|
<source>ML</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">77</context>
|
<context context-type="linenumber">75</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3484984238852181071" datatype="html">
|
<trans-unit id="3484984238852181071" datatype="html">
|
||||||
<source>AI</source>
|
<source>AI</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">81</context>
|
<context context-type="linenumber">79</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9180110319941008393" datatype="html">
|
<trans-unit id="9180110319941008393" datatype="html">
|
||||||
|
|||||||
+33
-35
@@ -1,6 +1,6 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading()" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
} @else if (noSuggestions) {
|
} @else if (noSuggestions) {
|
||||||
@@ -18,46 +18,44 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (aiEnabled()) {
|
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="list-group list-group-flush small pb-0">
|
||||||
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||||
|
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||||
|
@for (tag of suggestions().suggested_tags; track tag) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
}
|
||||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||||
@for (tag of suggestions().suggested_tags; track tag) {
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
@for (type of suggestions().suggested_document_types; track type) {
|
||||||
}
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
}
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||||
@for (type of suggestions().suggested_document_types; track type) {
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||||
}
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
}
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
@if (fieldSuggestions > 0) {
|
||||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
||||||
}
|
</div>
|
||||||
}
|
}
|
||||||
@if (fieldSuggestions > 0) {
|
|
||||||
<div class="list-group-item text-muted fst-italic">
|
|
||||||
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (aiEnabled()) {
|
@if (aiEnabled()) {
|
||||||
|
|||||||
+31
@@ -251,6 +251,37 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should open ML date suggestions with AI disabled', () => {
|
||||||
|
const getSuggestions = jest.spyOn(component.getSuggestions, 'emit')
|
||||||
|
fixture.componentRef.setInput('aiEnabled', false)
|
||||||
|
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
dates: ['2026-01-04', '2026-02-01'],
|
||||||
|
})
|
||||||
|
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const button = fixture.nativeElement.querySelector('button')
|
||||||
|
expect(button.disabled).toBeFalsy()
|
||||||
|
expect(button.querySelector('.badge').textContent.trim()).toBe('1')
|
||||||
|
|
||||||
|
button.click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'1 suggestion available below'
|
||||||
|
)
|
||||||
|
expect(getSuggestions).not.toHaveBeenCalled()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('button[title="Suggestion options"]')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
button.click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.dropdown.isOpen()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should fetch unfetched sources and show existing suggestions', () => {
|
it('should fetch unfetched sources and show existing suggestions', () => {
|
||||||
jest.spyOn(component.getSuggestions, 'emit')
|
jest.spyOn(component.getSuggestions, 'emit')
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
|||||||
+1
-5
@@ -53,11 +53,7 @@ export class SuggestionsDropdownComponent {
|
|||||||
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
||||||
|
|
||||||
public clickSuggest(): void {
|
public clickSuggest(): void {
|
||||||
if (
|
if (this.disabled() || this.loading()) {
|
||||||
this.disabled() ||
|
|
||||||
this.loading() ||
|
|
||||||
(this.suggestions() && !this.aiEnabled())
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
|
|||||||
cutoff so that uncertain predictions are discarded rather than assigned.
|
cutoff so that uncertain predictions are discarded rather than assigned.
|
||||||
"""
|
"""
|
||||||
probas = classifier.predict_proba(X)[0]
|
probas = classifier.predict_proba(X)[0]
|
||||||
|
if len(classifier.classes_) == 1:
|
||||||
|
# scikit-learn treats a single-class fit as binary, so predict_proba
|
||||||
|
# returns [1 - p, p] for one class. Column 0 is the real class.
|
||||||
|
probas = probas[:1]
|
||||||
best_idx = int(probas.argmax())
|
best_idx = int(probas.argmax())
|
||||||
best_class = int(classifier.classes_[best_idx])
|
best_class = int(classifier.classes_[best_idx])
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from itertools import islice
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.socialaccount.models import SocialAccount
|
from allauth.socialaccount.models import SocialAccount
|
||||||
@@ -70,6 +71,15 @@ from paperless.models import ApplicationConfiguration
|
|||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
|
||||||
|
# Derived names replace the original's extension with these suffixes
|
||||||
|
THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp"
|
||||||
|
ARCHIVE_SUFFIX: Final[str] = "-archive.pdf"
|
||||||
|
# Leave room for the longest derived suffix, which replaces the original's extension
|
||||||
|
MAX_BASE_NAME_BYTES: Final[int] = Document.MAX_PUBLIC_FILENAME_BYTES - max(
|
||||||
|
len(THUMBNAIL_SUFFIX),
|
||||||
|
len(ARCHIVE_SUFFIX),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def serialize_queryset_batched(
|
def serialize_queryset_batched(
|
||||||
queryset: "QuerySet[Any]",
|
queryset: "QuerySet[Any]",
|
||||||
@@ -512,7 +522,10 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
counter=filename_counter,
|
counter=filename_counter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
base_name = document.get_public_filename(counter=filename_counter)
|
base_name = document.get_public_filename(
|
||||||
|
counter=filename_counter,
|
||||||
|
max_bytes=MAX_BASE_NAME_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
# The thumbnail and archive names are derived from the stem, so
|
# The thumbnail and archive names are derived from the stem, so
|
||||||
# uniqueness must hold without the original's extension
|
# uniqueness must hold without the original's extension
|
||||||
@@ -541,7 +554,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
document_dict[EXPORTER_FILE_NAME] = original_arc
|
document_dict[EXPORTER_FILE_NAME] = original_arc
|
||||||
|
|
||||||
if not self.no_thumbnail:
|
if not self.no_thumbnail:
|
||||||
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
|
thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
thumbnail_name = Path("thumbnails") / thumbnail_name
|
thumbnail_name = Path("thumbnails") / thumbnail_name
|
||||||
thumbnail_arc = thumbnail_name.as_posix()
|
thumbnail_arc = thumbnail_name.as_posix()
|
||||||
@@ -550,7 +563,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
thumbnail_arc = None
|
thumbnail_arc = None
|
||||||
|
|
||||||
if not self.no_archive and document.has_archive_version:
|
if not self.no_archive and document.has_archive_version:
|
||||||
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
|
archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
archive_name = Path("archive") / archive_name
|
archive_name = Path("archive") / archive_name
|
||||||
archive_arc = archive_name.as_posix()
|
archive_arc = archive_name.as_posix()
|
||||||
|
|||||||
+23
-12
@@ -157,6 +157,8 @@ class StoragePath(MatchingModel):
|
|||||||
|
|
||||||
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
||||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||||
|
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
|
||||||
|
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
||||||
|
|
||||||
correspondent = models.ForeignKey(
|
correspondent = models.ForeignKey(
|
||||||
Correspondent,
|
Correspondent,
|
||||||
@@ -484,28 +486,37 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
def archive_file(self):
|
def archive_file(self):
|
||||||
return Path(self.archive_path).open("rb")
|
return Path(self.archive_path).open("rb")
|
||||||
|
|
||||||
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
|
def get_public_filename(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
archive: bool = False,
|
||||||
|
counter: int = 0,
|
||||||
|
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Returns a sanitized filename for the document, not including any paths.
|
Returns a sanitized filename for the document, not including any paths.
|
||||||
|
|
||||||
|
The name fits in max_bytes (UTF-8). A long title is shortened before
|
||||||
|
the counter and extension are appended, because sanitize_filename
|
||||||
|
truncates from the end. The tail is cut only when max_bytes is
|
||||||
|
smaller than the tail itself.
|
||||||
"""
|
"""
|
||||||
# Root owns metadata for all versions
|
# Root owns metadata for all versions
|
||||||
context_document = (
|
context_document = (
|
||||||
self.root_document if self.root_document_id is not None else self
|
self.root_document if self.root_document_id is not None else self
|
||||||
)
|
)
|
||||||
result = str(context_document)
|
|
||||||
|
|
||||||
if counter:
|
extension = ".pdf" if archive else self.file_type
|
||||||
result += f"_{counter:02}"
|
tail = f"_{counter:02}{extension}" if counter else extension
|
||||||
|
|
||||||
if suffix:
|
head_bytes = max(0, max_bytes - len(tail.encode()))
|
||||||
result += suffix
|
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
|
||||||
|
|
||||||
if archive:
|
return pathvalidate.sanitize_filename(
|
||||||
result += ".pdf"
|
head + tail,
|
||||||
else:
|
replacement_text="-",
|
||||||
result += self.file_type
|
max_len=max_bytes,
|
||||||
|
)
|
||||||
return pathvalidate.sanitize_filename(result, replacement_text="-")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_type(self):
|
def file_type(self):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from django.test import override_settings
|
|||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
from sklearn.neural_network import MLPClassifier
|
||||||
|
|
||||||
from documents.classifier import ClassifierModelCorruptError
|
from documents.classifier import ClassifierModelCorruptError
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
@@ -1036,6 +1037,49 @@ def test_classifier_match_threshold_default() -> None:
|
|||||||
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3
|
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3
|
||||||
|
|
||||||
|
|
||||||
|
class TestPredictWithThresholdSingleClass:
|
||||||
|
@pytest.fixture
|
||||||
|
def single_class_model(self) -> tuple[MLPClassifier, np.ndarray]:
|
||||||
|
X = np.random.default_rng(0).random((2, 5))
|
||||||
|
return MLPClassifier(tol=0.01, random_state=0).fit(X, [3, 3]), X[:1]
|
||||||
|
|
||||||
|
def test_returns_only_class(
|
||||||
|
self,
|
||||||
|
single_class_model: tuple[MLPClassifier, np.ndarray],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A classifier fitted on a single class (every document shares one label)
|
||||||
|
WHEN:
|
||||||
|
- A prediction is requested with the threshold disabled
|
||||||
|
THEN:
|
||||||
|
- No error is raised and the only class is returned
|
||||||
|
"""
|
||||||
|
classifier, X = single_class_model
|
||||||
|
|
||||||
|
assert _predict_with_threshold(classifier, X, threshold=0.0) == 3
|
||||||
|
|
||||||
|
def test_applies_threshold(
|
||||||
|
self,
|
||||||
|
single_class_model: tuple[MLPClassifier, np.ndarray],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A classifier fitted on a single class
|
||||||
|
WHEN:
|
||||||
|
- A prediction is requested
|
||||||
|
THEN:
|
||||||
|
- The class's confidence is still compared against the threshold
|
||||||
|
"""
|
||||||
|
classifier, X = single_class_model
|
||||||
|
confidence = classifier.predict_proba(X)[0][0]
|
||||||
|
|
||||||
|
assert _predict_with_threshold(classifier, X, threshold=confidence - 0.01) == 3
|
||||||
|
assert (
|
||||||
|
_predict_with_threshold(classifier, X, threshold=confidence + 0.01) is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestPreprocessContent:
|
class TestPreprocessContent:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def samples(self) -> Path:
|
def samples(self) -> Path:
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tasks import empty_trash
|
from documents.tasks import empty_trash
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
|
|
||||||
class TestDocument(TestCase):
|
class TestDocument(TestCase):
|
||||||
@@ -257,3 +260,72 @@ def test_suggestion_content() -> None:
|
|||||||
)
|
)
|
||||||
doc.content = content_over_limit
|
doc.content = content_over_limit
|
||||||
assert doc.suggestion_content == expected_cropped_content
|
assert doc.suggestion_content == expected_cropped_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicFilenameLength:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_parser_registry(self, mocker: MockerFixture) -> None:
|
||||||
|
# The parser registry reads ApplicationConfiguration from the database
|
||||||
|
extensions = {"application/pdf": ".pdf", "image/jpeg": ".jpg"}
|
||||||
|
mocker.patch(
|
||||||
|
"documents.models.get_default_file_extension",
|
||||||
|
side_effect=extensions.__getitem__,
|
||||||
|
)
|
||||||
|
|
||||||
|
LIMIT = Document.MAX_PUBLIC_FILENAME_BYTES
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("title", "archive", "counter", "max_bytes", "tail"),
|
||||||
|
[
|
||||||
|
pytest.param("a" * 300, False, 0, LIMIT, ".jpg", id="ascii"),
|
||||||
|
pytest.param("a" * 300, True, 0, LIMIT, ".pdf", id="archive"),
|
||||||
|
pytest.param("ä" * 200, False, 0, LIMIT, ".jpg", id="umlaut"),
|
||||||
|
pytest.param("ä" * 200, False, 1, LIMIT, "_01.jpg", id="count"),
|
||||||
|
pytest.param("a" * 300, False, 1, 100, "_01.jpg", id="max"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_long_title_keeps_tail_within_limit(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
archive: bool,
|
||||||
|
counter: int,
|
||||||
|
max_bytes: int,
|
||||||
|
tail: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document whose title alone is longer than the byte limit
|
||||||
|
WHEN:
|
||||||
|
- The public file name is generated
|
||||||
|
THEN:
|
||||||
|
- The name ends with the counter and extension
|
||||||
|
- The name fits in the limit
|
||||||
|
"""
|
||||||
|
name = DocumentFactory.build(
|
||||||
|
title=title,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
).get_public_filename(
|
||||||
|
archive=archive,
|
||||||
|
counter=counter,
|
||||||
|
max_bytes=max_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert name.endswith(tail)
|
||||||
|
assert len(name.encode()) <= max_bytes
|
||||||
|
|
||||||
|
def test_long_title_counters_give_distinct_names(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document whose title alone is longer than the byte limit
|
||||||
|
WHEN:
|
||||||
|
- Public file names are generated with increasing counters
|
||||||
|
THEN:
|
||||||
|
- Each counter produces a distinct name
|
||||||
|
"""
|
||||||
|
doc = DocumentFactory.build(title="ä" * 200, mime_type="application/pdf")
|
||||||
|
|
||||||
|
names = [doc.get_public_filename(counter=counter) for counter in range(3)]
|
||||||
|
|
||||||
|
assert len(set(names)) == 3
|
||||||
|
assert all(len(n.encode()) <= self.LIMIT for n in names)
|
||||||
@@ -1537,3 +1537,70 @@ class TestExportDerivedFileNameCollisions:
|
|||||||
assert (
|
assert (
|
||||||
target / record[EXPORTER_ARCHIVE_NAME]
|
target / record[EXPORTER_ARCHIVE_NAME]
|
||||||
).read_text() == f"archive {pk}"
|
).read_text() == f"archive {pk}"
|
||||||
|
|
||||||
|
def test_long_titles_export_within_file_name_limit(
|
||||||
|
self,
|
||||||
|
paperless_dirs: PaperlessDirs,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Two documents with the same created date and the same title
|
||||||
|
- The title alone is longer than 255 bytes and contains umlauts
|
||||||
|
WHEN:
|
||||||
|
- The exporter runs
|
||||||
|
THEN:
|
||||||
|
- The export finishes with distinct original, thumbnail and archive names
|
||||||
|
- Every exported file name fits in the byte limit
|
||||||
|
- Each exported file holds its own document's content
|
||||||
|
"""
|
||||||
|
for pk in (1, 2):
|
||||||
|
(paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes(
|
||||||
|
f"original {pk}".encode(),
|
||||||
|
)
|
||||||
|
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
|
||||||
|
f"archive {pk}".encode(),
|
||||||
|
)
|
||||||
|
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
|
||||||
|
f"thumbnail {pk}".encode(),
|
||||||
|
)
|
||||||
|
DocumentFactory(
|
||||||
|
pk=pk,
|
||||||
|
title="Bestellbestätigung " * 15,
|
||||||
|
created=date(2026, 10, 15),
|
||||||
|
filename=f"{pk:07}.pdf",
|
||||||
|
archive_filename=f"{pk:07}.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
target = tmp_path / "export"
|
||||||
|
target.mkdir()
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
str(target),
|
||||||
|
"--no-progress-bar",
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = json.loads((target / "manifest.json").read_text())
|
||||||
|
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
EXPORTER_FILE_NAME,
|
||||||
|
EXPORTER_THUMBNAIL_NAME,
|
||||||
|
EXPORTER_ARCHIVE_NAME,
|
||||||
|
):
|
||||||
|
assert records[1][key] != records[2][key]
|
||||||
|
|
||||||
|
for pk in (1, 2):
|
||||||
|
record = records[pk]
|
||||||
|
for key, content in (
|
||||||
|
(EXPORTER_FILE_NAME, f"original {pk}"),
|
||||||
|
(EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"),
|
||||||
|
(EXPORTER_ARCHIVE_NAME, f"archive {pk}"),
|
||||||
|
):
|
||||||
|
assert (
|
||||||
|
len(Path(record[key]).name.encode())
|
||||||
|
<= Document.MAX_PUBLIC_FILENAME_BYTES
|
||||||
|
)
|
||||||
|
assert (target / record[key]).read_text() == content
|
||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user