Compare commits

...
Author SHA1 Message Date
shamoon 01a0880e6f prevent overwriting index
Nothing actually passed in something different for the effective_content args, so drop them!
2026-08-19 16:50:17 -07:00
shamoon ac5a39a3d4 Fix: always index the root for a version 2026-08-19 16:40:31 -07:00
GitHub Actions a424dace43 Auto translate strings 2026-08-19 18:20:04 +00:00
shamoonandGitHub 751299895e Fix: hide version delete button without global perms (#13735) 2026-08-19 11:17:19 -07:00
10 changed files with 215 additions and 80 deletions
+10 -10
View File
@@ -8488,67 +8488,67 @@
<source>Delete version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">145,146</context>
<context context-type="linenumber">146,147</context>
</context-group>
</trans-unit>
<trans-unit id="9111284310237278846" datatype="html">
<source>Error deleting version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">158</context>
<context context-type="linenumber">164</context>
</context-group>
</trans-unit>
<trans-unit id="1450293275912542759" datatype="html">
<source>Error updating version label</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">191</context>
<context context-type="linenumber">197</context>
</context-group>
</trans-unit>
<trans-unit id="3567158019134759286" datatype="html">
<source>Uploading new version. Processing will happen in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">213</context>
<context context-type="linenumber">219</context>
</context-group>
</trans-unit>
<trans-unit id="1549684689040001385" datatype="html">
<source>Missing task ID.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">226</context>
<context context-type="linenumber">232</context>
</context-group>
</trans-unit>
<trans-unit id="205445381296663832" datatype="html">
<source>Upload failed.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">248</context>
<context context-type="linenumber">254</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">272</context>
<context context-type="linenumber">278</context>
</context-group>
</trans-unit>
<trans-unit id="8350861980330256599" datatype="html">
<source>Error uploading new version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">275</context>
<context context-type="linenumber">281</context>
</context-group>
</trans-unit>
<trans-unit id="4050545381573552340" datatype="html">
<source>Existing document added as a version.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">318</context>
<context context-type="linenumber">324</context>
</context-group>
</trans-unit>
<trans-unit id="8552490241798412439" datatype="html">
<source>Error adding existing document as a version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">323</context>
<context context-type="linenumber">329</context>
</context-group>
</trans-unit>
<trans-unit id="6299008920007331381" datatype="html">
@@ -141,6 +141,7 @@
i18n-confirmMessage
[disabled]="!userIsOwner || !userCanEdit"
(confirm)="deleteVersion(version.id)"
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
>
<span class="visually-hidden" i18n>Delete version</span>
</pngx-confirm-button>
@@ -1,10 +1,16 @@
import { DatePipe } from '@angular/common'
import { SimpleChange, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
import { DocumentVersionInfo } from 'src/app/data/document'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -31,6 +37,9 @@ describe('DocumentVersionDropdownComponent', () => {
let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
let permissionsService: jest.Mocked<
Pick<PermissionsService, 'currentUserCan'>
>
beforeEach(async () => {
finished$ = new Subject<{ taskId: string }>()
@@ -47,6 +56,9 @@ describe('DocumentVersionDropdownComponent', () => {
showError: jest.fn(),
showInfo: jest.fn(),
}
permissionsService = {
currentUserCan: jest.fn().mockReturnValue(true),
}
await TestBed.configureTestingModule({
imports: [
@@ -73,6 +85,10 @@ describe('DocumentVersionDropdownComponent', () => {
provide: NgbModal,
useValue: modalService,
},
{
provide: PermissionsService,
useValue: permissionsService,
},
{
provide: WebsocketStatusService,
useValue: {
@@ -143,6 +159,31 @@ describe('DocumentVersionDropdownComponent', () => {
)
})
it('should not show version delete buttons without document delete permission', () => {
fixture.destroy()
permissionsService.currentUserCan.mockReturnValue(false)
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
component = fixture.componentInstance
component.documentId = 3
component.selectedVersionId = 3
component.userIsOwner = true
component.userCanEdit = true
component.versions = [
{ id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb' },
]
fixture.detectChanges()
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
PermissionAction.Delete,
PermissionType.Document
)
expect(
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
).toHaveLength(0)
})
it('beginEditingVersion should set active row and draft label', () => {
component.userCanEdit = true
component.userIsOwner = true
@@ -25,6 +25,7 @@ import {
tap,
} from 'rxjs/operators'
import { DocumentVersionInfo } from 'src/app/data/document'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -33,6 +34,7 @@ import {
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
@Component({
@@ -44,11 +46,15 @@ import { AddExistingDocumentVersionDialogComponent } from './add-existing-docume
NgbDropdownModule,
NgxBootstrapIconsModule,
ConfirmButtonComponent,
IfPermissionsDirective,
SlicePipe,
CustomDatePipe,
],
})
export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
export class DocumentVersionDropdownComponent
extends ComponentWithPermissions
implements OnChanges, OnDestroy
{
UploadState = UploadState
@Input() documentId: number
+9 -22
View File
@@ -266,11 +266,7 @@ class WriteBatch:
if self._lock is not None:
self._lock.release()
def add_or_update(
self,
document: Document,
effective_content: str | None = None,
) -> None:
def add_or_update(self, document: Document) -> None:
"""
Add or update a document in the batch.
@@ -280,11 +276,9 @@ class WriteBatch:
Args:
document: Django Document instance to index
effective_content: Override document.content for indexing (used when
re-indexing with newer OCR text from document versions)
"""
self.remove(document.pk)
doc = self._backend._build_tantivy_doc(document, effective_content)
doc = self._backend._build_tantivy_doc(document)
self._writer.add_document(doc)
def remove(self, doc_id: int) -> None:
@@ -425,18 +419,17 @@ class TantivyBackend:
def _build_tantivy_doc(
self,
document: Document,
effective_content: str | None = None,
viewer_ids: list[int] | None = None,
viewer_group_ids: list[int] | None = None,
) -> tantivy.Document:
"""Build a tantivy Document from a Django Document instance.
``effective_content`` overrides ``document.content`` for indexing —
used when re-indexing a root document with a newer version's OCR text.
A root document is indexed with its effective content, i.e. the newest
version's OCR text, so it is never indexed with its own outdated text.
Annotate the queryset with ``annotate_effective_content`` when indexing
more than a couple of documents, to resolve that without a query each.
"""
content = (
effective_content if effective_content is not None else document.content
)
content = document.get_effective_content() or ""
doc = tantivy.Document()
@@ -584,11 +577,7 @@ class TantivyBackend:
return doc
def add_or_update(
self,
document: Document,
effective_content: str | None = None,
) -> None:
def add_or_update(self, document: Document) -> None:
"""
Add or update a single document with file locking.
@@ -601,12 +590,11 @@ class TantivyBackend:
Args:
document: Django Document instance to index
effective_content: Override document.content for indexing
"""
self._ensure_open()
try:
with self.batch_update(lock_timeout=_LOCK_TIMEOUT_SECONDS) as batch:
batch.add_or_update(document, effective_content)
batch.add_or_update(document)
except SearchIndexLockError:
logger.error(
"Search index lock exhausted for document %d after %d attempts; "
@@ -1027,7 +1015,6 @@ class TantivyBackend:
):
doc = self._build_tantivy_doc(
document,
document.get_effective_content(),
viewer_ids=viewer_ids,
viewer_group_ids=viewer_group_ids,
)
+6 -4
View File
@@ -794,10 +794,12 @@ def cleanup_user_deletion(sender, instance: User | Group, **kwargs) -> None:
def add_to_index(sender, document, **kwargs) -> None:
from documents.search import get_backend
get_backend().add_or_update(
document,
effective_content=document.get_effective_content(),
)
# A newly consumed version is not searchable on its own, its content
# becomes the effective_content of the root document
if document.root_document_id:
document = document.root_document
get_backend().add_or_update(document)
def run_workflows_added(
+6 -5
View File
@@ -64,6 +64,7 @@ from documents.signals.handlers import send_websocket_document_updated
from documents.utils import IterWrapper
from documents.utils import compute_checksum
from documents.utils import identity
from documents.versioning import annotate_effective_content
from documents.workflows.utils import get_workflows_for_trigger
from paperless.config import AIConfig
from paperless.logging import consume_task_id
@@ -114,10 +115,7 @@ def index_document(self, document_id: int) -> None:
)
return
with get_backend().batch_update() as batch:
batch.add_or_update(
document,
effective_content=document.get_effective_content(),
)
batch.add_or_update(document)
@shared_task(
@@ -312,7 +310,10 @@ def bulk_update_documents(document_ids) -> None:
from documents.search import get_backend
document_ids = list(document_ids)
documents = Document.objects.filter(id__in=document_ids)
# Annotated so indexing below doesn't query the versions of each document
documents = annotate_effective_content(
Document.objects.filter(id__in=document_ids),
)
for doc in documents:
clear_document_caches(doc.pk)
@@ -16,6 +16,7 @@ from documents.search._backend import TantivyBackend
from documents.search._backend import WriteBatch
from documents.search._backend import get_backend
from documents.search._backend import reset_backend
from documents.signals.handlers import add_to_index
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentFactory
from documents.tests.factories import DocumentTypeFactory
@@ -1030,6 +1031,81 @@ class TestHighlightHits:
assert len(hits) == 0
class TestVersionIndexing:
"""
GIVEN:
- A root document whose new version has just been consumed, e.g. by
the password removal workflow action
WHEN:
- The consumption finished signal is handled
THEN:
- The root document is indexed with the new version's content, since
versions are not searchable on their own
"""
def test_consumed_version_updates_root_entry(
self,
backend: TantivyBackend,
mocker: MockerFixture,
) -> None:
root = Document.objects.create(
title="Statement",
content="",
checksum="VER1",
pk=90,
)
backend.add_or_update(root)
version = Document.objects.create(
title="Statement",
content="unprotected statement text",
checksum="VER2",
pk=91,
root_document=root,
version_index=1,
)
mocker.patch("documents.search.get_backend", return_value=backend)
add_to_index(sender=None, document=version)
assert backend.search_ids("unprotected", user=None) == [root.pk]
class TestEffectiveContentIndexing:
"""
GIVEN:
- A root document with a newer version
WHEN:
- The root document is indexed
THEN:
- The newest version's content is indexed, never the root's own
outdated text
"""
def test_root_is_indexed_with_latest_version_content(
self,
backend: TantivyBackend,
) -> None:
root = Document.objects.create(
title="Statement",
content="stale original text",
checksum="EFF1",
pk=95,
)
Document.objects.create(
title="Statement",
content="latest version text",
checksum="EFF2",
pk=96,
root_document=root,
version_index=1,
)
backend.add_or_update(root)
assert backend.search_ids("latest", user=None) == [root.pk]
assert backend.search_ids("stale", user=None) == []
class TestIndexDirectoryGarbageCollection:
"""Regression tests for Tantivy segment files leaking on disk when
multiple long-lived worker processes (Granian/Celery) take turns writing
+21
View File
@@ -6,7 +6,10 @@ from typing import TYPE_CHECKING
from typing import Any
from django.db.models import F
from django.db.models import OuterRef
from django.db.models import QuerySet
from django.db.models import Subquery
from django.db.models.functions import Coalesce
from documents.models import Document
@@ -22,6 +25,24 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
return documents.order_by(F("version_index").desc(nulls_last=True), "-id")
def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
"""
Annotates documents with the content of their newest version, falling back
to their own, so get_effective_content() can answer from the row rather
than querying for the versions of each document
"""
return documents.annotate(
effective_content=Coalesce(
Subquery(
versions_newest_first(
Document.objects.filter(root_document=OuterRef("pk")),
).values("content")[:1],
),
F("content"),
),
)
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
"""
Same sorting as versions_newest_first()
+38 -38
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-19 14:23+0000\n"
"POT-Creation-Date: 2026-08-19 18:18+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -2170,151 +2170,151 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings/__init__.py:556
#: paperless/settings/__init__.py:562
msgid "English (US)"
msgstr ""
#: paperless/settings/__init__.py:557
#: paperless/settings/__init__.py:563
msgid "Arabic"
msgstr ""
#: paperless/settings/__init__.py:558
#: paperless/settings/__init__.py:564
msgid "Afrikaans"
msgstr ""
#: paperless/settings/__init__.py:559
#: paperless/settings/__init__.py:565
msgid "Belarusian"
msgstr ""
#: paperless/settings/__init__.py:560
#: paperless/settings/__init__.py:566
msgid "Bulgarian"
msgstr ""
#: paperless/settings/__init__.py:561
#: paperless/settings/__init__.py:567
msgid "Catalan"
msgstr ""
#: paperless/settings/__init__.py:562
#: paperless/settings/__init__.py:568
msgid "Czech"
msgstr ""
#: paperless/settings/__init__.py:563
#: paperless/settings/__init__.py:569
msgid "Danish"
msgstr ""
#: paperless/settings/__init__.py:564
#: paperless/settings/__init__.py:570
msgid "German"
msgstr ""
#: paperless/settings/__init__.py:565
#: paperless/settings/__init__.py:571
msgid "Greek"
msgstr ""
#: paperless/settings/__init__.py:566
#: paperless/settings/__init__.py:572
msgid "English (GB)"
msgstr ""
#: paperless/settings/__init__.py:567
#: paperless/settings/__init__.py:573
msgid "Spanish"
msgstr ""
#: paperless/settings/__init__.py:568
#: paperless/settings/__init__.py:574
msgid "Persian"
msgstr ""
#: paperless/settings/__init__.py:569
#: paperless/settings/__init__.py:575
msgid "Finnish"
msgstr ""
#: paperless/settings/__init__.py:570
#: paperless/settings/__init__.py:576
msgid "French"
msgstr ""
#: paperless/settings/__init__.py:571
#: paperless/settings/__init__.py:577
msgid "Hungarian"
msgstr ""
#: paperless/settings/__init__.py:572
#: paperless/settings/__init__.py:578
msgid "Indonesian"
msgstr ""
#: paperless/settings/__init__.py:573
#: paperless/settings/__init__.py:579
msgid "Italian"
msgstr ""
#: paperless/settings/__init__.py:574
#: paperless/settings/__init__.py:580
msgid "Japanese"
msgstr ""
#: paperless/settings/__init__.py:575
#: paperless/settings/__init__.py:581
msgid "Korean"
msgstr ""
#: paperless/settings/__init__.py:576
#: paperless/settings/__init__.py:582
msgid "Luxembourgish"
msgstr ""
#: paperless/settings/__init__.py:577
#: paperless/settings/__init__.py:583
msgid "Norwegian"
msgstr ""
#: paperless/settings/__init__.py:578
#: paperless/settings/__init__.py:584
msgid "Dutch"
msgstr ""
#: paperless/settings/__init__.py:579
#: paperless/settings/__init__.py:585
msgid "Polish"
msgstr ""
#: paperless/settings/__init__.py:580
#: paperless/settings/__init__.py:586
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings/__init__.py:581
#: paperless/settings/__init__.py:587
msgid "Portuguese"
msgstr ""
#: paperless/settings/__init__.py:582
#: paperless/settings/__init__.py:588
msgid "Romanian"
msgstr ""
#: paperless/settings/__init__.py:583
#: paperless/settings/__init__.py:589
msgid "Russian"
msgstr ""
#: paperless/settings/__init__.py:584
#: paperless/settings/__init__.py:590
msgid "Slovak"
msgstr ""
#: paperless/settings/__init__.py:585
#: paperless/settings/__init__.py:591
msgid "Slovenian"
msgstr ""
#: paperless/settings/__init__.py:586
#: paperless/settings/__init__.py:592
msgid "Serbian"
msgstr ""
#: paperless/settings/__init__.py:587
#: paperless/settings/__init__.py:593
msgid "Swedish"
msgstr ""
#: paperless/settings/__init__.py:588
#: paperless/settings/__init__.py:594
msgid "Turkish"
msgstr ""
#: paperless/settings/__init__.py:589
#: paperless/settings/__init__.py:595
msgid "Ukrainian"
msgstr ""
#: paperless/settings/__init__.py:590
#: paperless/settings/__init__.py:596
msgid "Vietnamese"
msgstr ""
#: paperless/settings/__init__.py:591
#: paperless/settings/__init__.py:597
msgid "Chinese Simplified"
msgstr ""
#: paperless/settings/__init__.py:592
#: paperless/settings/__init__.py:598
msgid "Chinese Traditional"
msgstr ""