Compare commits

...

8 Commits

Author SHA1 Message Date
shamoon ab7a546366 Performance: optimize dropdown filtering 2026-07-07 11:20:54 -07:00
shamoon e145fe8cc7 Fix: clamp mailrule max age before migration (#13093) 2026-07-07 10:56:45 -07:00
shamoon fce55a1609 Fix: use latest document version for bulk download (#13103) 2026-07-07 10:55:40 -07:00
shamoon 1dabd2601d Fix: handle relative date with comma query (#13087) 2026-07-03 12:44:33 -07:00
shamoon 88f69841b3 Merge branch 'dev' into beta 2026-07-02 15:06:19 -07:00
shamoon 5b8fbdcec7 Merge branch 'dev' into beta 2026-06-27 15:56:20 -07:00
shamoon beb048b94a Merge branch 'dev' into beta 2026-06-27 06:53:09 -07:00
shamoon b33d11778a Fix: change tantivy token limit to title field max length (#13046) 2026-06-23 08:02:26 -07:00
11 changed files with 175 additions and 17 deletions
+8
View File
@@ -326,3 +326,11 @@ behind a reverse proxy may need to set
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES), [`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER), [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
or both, to avoid `403 Forbidden` errors on login. or both, to avoid `403 Forbidden` errors on login.
## Database Migrations
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
### Action Required
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
@@ -61,12 +61,15 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
writeValue(newValue: number[]): void { writeValue(newValue: number[]): void {
this.value = newValue this.value = newValue
} }
registerOnChange(fn: any): void { registerOnChange(fn: any): void {
this.onChange = fn this.onChange = fn
} }
registerOnTouched(fn: any): void { registerOnTouched(fn: any): void {
this.onTouched = fn this.onTouched = fn
} }
setDisabledState?(isDisabled: boolean): void { setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled this.disabled = isDisabled
} }
@@ -74,6 +77,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
ngOnInit(): void { ngOnInit(): void {
this.tagService.listAll().subscribe((result) => { this.tagService.listAll().subscribe((result) => {
this.tags = result.results this.tags = result.results
this.tagSearchTextCache.clear()
}) })
} }
@@ -113,15 +117,12 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
tags: Tag[] = [] tags: Tag[] = []
private readonly tagSearchTextCache = new Map<number, string>()
public createTagRef: (name) => void public createTagRef: (name) => void
public searchFn = (term: string, tag: Tag): boolean => public searchFn = (term: string, tag: Tag): boolean =>
matchesSearchText( matchesSearchText(this.getTagSearchText(tag), term)
[this.getParentChain(tag?.id).map((parent) => parent.name), tag?.name]
.flat()
.join(' '),
term
)
getTag(id: number) { getTag(id: number) {
if (this.tags) { if (this.tags) {
@@ -152,8 +153,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
private removeChildren(tagIDs: number[], tag: Tag) { private removeChildren(tagIDs: number[], tag: Tag) {
if (tag.children?.length) { if (tag.children?.length) {
const childIDs = tag.children.map((child) => child.id) const childIDs = new Set(tag.children.map((child) => child.id))
tagIDs = tagIDs.filter((id) => !childIDs.includes(id)) tagIDs = tagIDs.filter((id) => !childIDs.has(id))
for (const child of tag.children) { for (const child of tag.children) {
tagIDs = this.removeChildren(tagIDs, child) tagIDs = this.removeChildren(tagIDs, child)
} }
@@ -188,6 +189,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
tap((newTag) => { tap((newTag) => {
this.tagService.listAll().subscribe((tags) => { this.tagService.listAll().subscribe((tags) => {
this.tags = tags.results this.tags = tags.results
this.tagSearchTextCache.clear()
add && this.addTag(newTag.id) add && this.addTag(newTag.id)
}) })
}) })
@@ -238,4 +240,22 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
} }
return chain return chain
} }
private getTagSearchText(tag: Tag): string {
if (!tag) {
return ''
}
const cachedSearchText = this.tagSearchTextCache.get(tag.id)
if (cachedSearchText !== undefined) {
return cachedSearchText
}
const searchText = [
...this.getParentChain(tag.id).map((parent) => parent.name),
tag.name,
].join(' ')
this.tagSearchTextCache.set(tag.id, searchText)
return searchText
}
} }
+19
View File
@@ -46,4 +46,23 @@ describe('FilterPipe', () => {
itemsReturned = pipe.transform(items, 'slug', 'slug') itemsReturned = pipe.transform(items, 'slug', 'slug')
expect(itemsReturned).toEqual([items[0]]) expect(itemsReturned).toEqual([items[0]])
}) })
it('should filter matchingmodel items by normalized independent terms', () => {
const pipe = new FilterPipe()
const items: MatchingModel[] = [
{
id: 1,
name: 'Financ\u00e9 Taxes 2026',
slug: 'finance-taxes',
},
{
id: 2,
name: 'Receipt Archive 2026',
slug: 'receipt-archive',
},
]
expect(pipe.transform(items, 'finance 26')).toEqual([items[0]])
expect(pipe.transform(items, 'finance receipt')).toEqual([])
})
}) })
+33 -2
View File
@@ -8,8 +8,39 @@ export type SearchTextValue =
| null | null
| undefined | undefined
const normalizedStringCache = new Map<string, string>()
const searchTermsCache = new Map<string, string[]>()
function normalizeString(value: string): string {
const cachedValue = normalizedStringCache.get(value)
if (cachedValue !== undefined) {
return cachedValue
}
const normalizedValue = normalizeSync(value).toLocaleLowerCase()
normalizedStringCache.set(value, normalizedValue)
return normalizedValue
}
export function normalizeSearchText(value: SearchTextValue): string { export function normalizeSearchText(value: SearchTextValue): string {
return normalizeSync(String(value ?? '')).toLocaleLowerCase() return normalizeString(String(value ?? ''))
}
function getSearchTerms(searchText: SearchTextValue): string[] {
const normalizedSearchText = normalizeSearchText(searchText).trim()
if (!normalizedSearchText) {
return []
}
const cachedTerms = searchTermsCache.get(normalizedSearchText)
if (cachedTerms !== undefined) {
return cachedTerms
}
const searchTerms = normalizedSearchText.split(/\s+/)
searchTermsCache.set(normalizedSearchText, searchTerms)
return searchTerms
} }
export function matchesSearchText( export function matchesSearchText(
@@ -17,7 +48,7 @@ export function matchesSearchText(
searchText: SearchTextValue searchText: SearchTextValue
): boolean { ): boolean {
const normalizedValue = normalizeSearchText(value) const normalizedValue = normalizeSearchText(value)
const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/) const searchTerms = getSearchTerms(searchText)
return searchTerms.every((term) => normalizedValue.includes(term)) return searchTerms.every((term) => normalizedValue.includes(term))
} }
+7 -4
View File
@@ -48,6 +48,9 @@ _LANGUAGE_MAP: dict[str, str] = {
} }
SUPPORTED_LANGUAGES: frozenset[str] = frozenset(_LANGUAGE_MAP) SUPPORTED_LANGUAGES: frozenset[str] = frozenset(_LANGUAGE_MAP)
# Document.title is max_length=128, so use 129 as the limit for
# Tantivy's remove_long filter
_TOKEN_REMOVE_LONG_LIMIT: Final[int] = 129
def register_tokenizers(index: tantivy.Index, language: str | None) -> None: def register_tokenizers(index: tantivy.Index, language: str | None) -> None:
@@ -77,10 +80,10 @@ def register_tokenizers(index: tantivy.Index, language: str | None) -> None:
def _paperless_text(language: str | None) -> tantivy.TextAnalyzer: def _paperless_text(language: str | None) -> tantivy.TextAnalyzer:
"""Main full-text tokenizer for content, title, etc: simple -> remove_long(65) -> lowercase -> ascii_fold [-> stemmer]""" """Main full-text tokenizer for content, title, etc: simple -> remove_long(129) -> lowercase -> ascii_fold [-> stemmer]"""
builder = ( builder = (
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.simple()) tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.simple())
.filter(tantivy.Filter.remove_long(65)) .filter(tantivy.Filter.remove_long(_TOKEN_REMOVE_LONG_LIMIT))
.filter(tantivy.Filter.lowercase()) .filter(tantivy.Filter.lowercase())
.filter(tantivy.Filter.ascii_fold()) .filter(tantivy.Filter.ascii_fold())
) )
@@ -119,12 +122,12 @@ def _bigram_analyzer() -> tantivy.TextAnalyzer:
def _simple_search_analyzer() -> tantivy.TextAnalyzer: def _simple_search_analyzer() -> tantivy.TextAnalyzer:
"""Tokenizer for simple substring search fields: non-whitespace chunks -> remove_long(65) -> lowercase -> ascii_fold.""" """Tokenizer for simple substring search fields: non-whitespace chunks -> remove_long(129) -> lowercase -> ascii_fold."""
return ( return (
tantivy.TextAnalyzerBuilder( tantivy.TextAnalyzerBuilder(
tantivy.Tokenizer.regex(r"\S+"), tantivy.Tokenizer.regex(r"\S+"),
) )
.filter(tantivy.Filter.remove_long(65)) .filter(tantivy.Filter.remove_long(_TOKEN_REMOVE_LONG_LIMIT))
.filter(tantivy.Filter.lowercase()) .filter(tantivy.Filter.lowercase())
.filter(tantivy.Filter.ascii_fold()) .filter(tantivy.Filter.ascii_fold())
.build() .build()
+6 -1
View File
@@ -163,7 +163,12 @@ def scan(query: str) -> list[Token]:
i += 1 i += 1
continue continue
token, i = matched token, i = matched
_flush(buf, tokens) if buf and buf[-1] == ",":
buf.pop()
_flush(buf, tokens)
tokens.append(Comma())
else:
_flush(buf, tokens)
tokens.append(token) tokens.append(token)
i = _maybe_comma(query, i, tokens) i = _maybe_comma(query, i, tokens)
_flush(buf, tokens) _flush(buf, tokens)
@@ -261,6 +261,36 @@ class TestSearch:
== 1 == 1
) )
@pytest.mark.parametrize(
("search_mode", "query"),
[
pytest.param(SearchMode.TITLE, "12345", id="title_search"),
pytest.param(SearchMode.TEXT, "12345", id="text_search"),
pytest.param(SearchMode.QUERY, None, id="query_title_exact"),
],
)
def test_search_modes_match_model_limit_title_tokens(
self,
backend: TantivyBackend,
search_mode: SearchMode,
query: str | None,
) -> None:
"""Search must keep filename-like title tokens up to the model limit."""
long_title = "1234567890" * 12 + "12345678"
doc = Document.objects.create(
title=long_title,
content="ordinary content",
checksum="TXT12",
pk=18,
)
backend.add_or_update(doc)
assert backend.search_ids(
query or f"title:{long_title}",
user=None,
search_mode=search_mode,
) == [doc.pk]
@pytest.mark.parametrize( @pytest.mark.parametrize(
("mode", "title", "content", "hits", "misses"), ("mode", "title", "content", "hits", "misses"),
[ [
@@ -99,6 +99,25 @@ class TestTokenizers:
) )
assert simple_search_index.searcher().search(q, limit=5).count == 1 assert simple_search_index.searcher().search(q, limit=5).count == 1
def test_simple_search_analyzer_supports_model_limit_token_substrings(
self,
simple_search_index: tantivy.Index,
) -> None:
"""Simple substring search keeps tokens up to Document.title's model limit."""
long_token = "abcdefghij" * 12 + "abcdefgh"
writer = simple_search_index.writer()
doc = tantivy.Document()
doc.add_text("simple_content", long_token)
writer.add_document(doc)
writer.commit()
simple_search_index.reload()
q = tantivy.Query.regex_query(
simple_search_index.schema,
"simple_content",
".*cdefg.*",
)
assert simple_search_index.searcher().search(q, limit=5).count == 1
def test_unsupported_language_logs_warning(self, caplog: LogCaptureFixture) -> None: def test_unsupported_language_logs_warning(self, caplog: LogCaptureFixture) -> None:
"""Unsupported language codes should log a warning and disable stemming gracefully.""" """Unsupported language codes should log a warning and disable stemming gracefully."""
sb = tantivy.SchemaBuilder() sb = tantivy.SchemaBuilder()
@@ -716,6 +716,12 @@ class TestISODatetimeBounds:
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]" "added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
) )
def test_translate_query_text_before_comma_separated_date_clause(self) -> None:
result = translate_query("schäfersee,created:previous year", UTC)
assert result == (
"schäfersee AND created:[2025-01-01T00:00:00Z TO 2026-01-01T00:00:00Z]"
)
def test_invalid_iso_datetime_raises(self) -> None: def test_invalid_iso_datetime_raises(self) -> None:
# A token with "T" that is not valid ISO datetime -> raise. # A token with "T" that is not valid ISO datetime -> raise.
with pytest.raises(InvalidDateQuery) as exc_info: with pytest.raises(InvalidDateQuery) as exc_info:
+9 -2
View File
@@ -3769,13 +3769,20 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
validated_data=serializer.validated_data, validated_data=serializer.validated_data,
) )
documents = Document.objects.filter(pk__in=ids) documents = Document.objects.filter(pk__in=ids)
versioned_documents = []
compression = serializer.validated_data.get("compression") compression = serializer.validated_data.get("compression")
content = serializer.validated_data.get("content") content = serializer.validated_data.get("content")
follow_filename_format = serializer.validated_data.get("follow_formatting") follow_filename_format = serializer.validated_data.get("follow_formatting")
for document in documents: for document in documents:
if not has_perms_owner_aware(request.user, "change_document", document): root_doc = get_root_document(document)
if not has_perms_owner_aware(request.user, "view_document", root_doc):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
versioned_documents.append(
get_latest_version_for_root(
root_doc,
),
)
if content == "both": if content == "both":
strategy_class = OriginalAndArchiveStrategy strategy_class = OriginalAndArchiveStrategy
@@ -3798,7 +3805,7 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
zipf, zipf,
follow_formatting=follow_filename_format, follow_formatting=follow_filename_format,
) )
for document in documents: for document in versioned_documents:
strategy.add_document(document) strategy.add_document(document)
f = temp_path.open("rb") f = temp_path.open("rb")
@@ -4,6 +4,12 @@ from django.db import migrations
from django.db import models from django.db import models
def clamp_mailrule_maximum_age(apps, schema_editor):
# Clamp the maximum_age field of MailRule because of PositiveIntegerField --> PositiveSmallIntegerField
MailRule = apps.get_model("paperless_mail", "MailRule")
MailRule.objects.filter(maximum_age__gt=32767).update(maximum_age=32767)
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("paperless_mail", "0001_squashed"), ("paperless_mail", "0001_squashed"),
@@ -112,6 +118,10 @@ class Migration(migrations.Migration):
verbose_name="consumption scope", verbose_name="consumption scope",
), ),
), ),
migrations.RunPython(
clamp_mailrule_maximum_age,
migrations.RunPython.noop,
),
migrations.AlterField( migrations.AlterField(
model_name="mailrule", model_name="mailrule",
name="maximum_age", name="maximum_age",