Compare commits

..
132 changed files with 41309 additions and 40626 deletions
+1
View File
@@ -227,6 +227,7 @@ Version-aware endpoints:
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided.
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
+2
View File
@@ -99,6 +99,8 @@ Think of versions as **file history** for a document.
- By default, search and document content use the latest version.
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
- From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own.
- From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document.
### Management Lists
+1
View File
@@ -38,6 +38,7 @@ dependencies = [
"django-soft-delete~=1.0.18",
"django-treenode>=0.24",
"djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.30",
"drf-spectacular-sidecar~=2026.7.1",
"drf-writable-nested~=0.7.1",
@@ -0,0 +1,48 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="form-group">
<span class="form-label d-inline-block" i18n>Versions:</span>
<ul class="list-group">
@for (documentID of versionDocumentIDs(); track documentID) {
@let document = getDocument(documentID);
@if (document) {
<li class="list-group-item d-flex align-items-center">
<div class="d-flex flex-column">
<div>
@if (document.correspondent) {
<b>{{document.correspondent | correspondentName | async}}: </b>
}{{document.title}}
</div>
<small class="text-muted">
{{document.created | customDate:'mediumDate'}}
@if (document.page_count) {
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
}
</small>
</div>
</li>
}
}
</ul>
</div>
<div class="form-group mt-4">
<label class="form-label" for="rootDocumentID" i18n>Root document:</label>
<select id="rootDocumentID" class="form-select" [ngModel]="rootDocumentID()" (ngModelChange)="rootDocumentID.set($event)">
@for (document of documents(); track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -0,0 +1,56 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
describe('MergeAsVersionsConfirmDialogComponent', () => {
let component: MergeAsVersionsConfirmDialogComponent
let fixture: ComponentFixture<MergeAsVersionsConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MergeAsVersionsConfirmDialogComponent],
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(MergeAsVersionsConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should fetch selected documents', () => {
const documents = [
{ id: 1, title: 'Document 1' },
{ id: 2, title: 'Document 2' },
]
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: [1, 2],
count: 2,
results: documents,
})
)
component.documentIDs.set([1, 2])
component.ngOnInit()
expect(component.documents()).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith([1, 2])
})
it('should exclude the root from the draggable documents', () => {
component.documentIDs.set([1, 2, 3])
component.rootDocumentID.set(2)
expect(component.versionDocumentIDs()).toEqual([1, 3])
})
})
@@ -0,0 +1,41 @@
import { AsyncPipe } from '@angular/common'
import { Component, OnInit, computed, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-merge-as-versions-confirm-dialog',
templateUrl: './merge-as-versions-confirm-dialog.component.html',
imports: [AsyncPipe, CorrespondentNamePipe, CustomDatePipe, FormsModule],
})
export class MergeAsVersionsConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
private readonly documentService = inject(DocumentService)
readonly documentIDs = signal<number[]>([])
readonly documents = signal<Document[]>([])
readonly rootDocumentID = signal(-1)
readonly versionDocumentIDs = computed(() =>
this.documentIDs().filter(
(documentID) => documentID !== this.rootDocumentID()
)
)
ngOnInit() {
this.documentService
.getFew(this.documentIDs())
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((response) => this.documents.set(response.results))
}
getDocument(documentID: number): Document {
return this.documents().find((document) => document.id === documentID)
}
}
@@ -36,7 +36,7 @@
</div>
<div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
<select id="metadataDocumentID" class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
@for (document of documents(); track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
@@ -151,13 +151,6 @@
inset: 0;
pointer-events: none;
& section {
position: absolute;
text-align: initial;
box-sizing: border-box;
transform-origin: 0 0;
}
& .annotationTextContent {
opacity: 0;
}
@@ -13,7 +13,6 @@ import {
ViewChild,
} from '@angular/core'
import {
AnnotationMode,
getDocument,
GlobalWorkerOptions,
PDFDocumentLoadingTask,
@@ -222,7 +221,6 @@ export class PngxPdfViewerComponent
linkService: this.linkService,
findController: this.findController,
textLayerMode,
annotationMode: AnnotationMode.ENABLE,
enableSelectionRendering: false,
removePageBorders: true,
}
@@ -0,0 +1,18 @@
<div class="modal-header">
<h4 class="modal-title" i18n>Add existing document as version</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
<pngx-input-document-link
[(ngModel)]="selectedDocumentIDs"
[parentDocumentID]="rootDocumentID"
[minimal]="true"
placeholder="Search for a document"
i18n-placeholder
></pngx-input-document-link>
<div class="form-text mt-2" i18n>Select one document to add as a version.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled || selectedDocumentIDs.length !== 1" i18n>Add version</button>
</div>
@@ -0,0 +1,56 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog.component'
describe('AddExistingDocumentVersionDialogComponent', () => {
let component: AddExistingDocumentVersionDialogComponent
let fixture: ComponentFixture<AddExistingDocumentVersionDialogComponent>
let activeModal: jest.Mocked<Pick<NgbActiveModal, 'dismiss'>>
beforeEach(async () => {
activeModal = { dismiss: jest.fn() }
await TestBed.configureTestingModule({
imports: [AddExistingDocumentVersionDialogComponent],
providers: [
{
provide: NgbActiveModal,
useValue: activeModal,
},
{
provide: DocumentService,
useValue: {},
},
],
}).compileComponents()
fixture = TestBed.createComponent(AddExistingDocumentVersionDialogComponent)
component = fixture.componentInstance
component.rootDocumentID = 3
fixture.detectChanges()
})
it('should emit the single selected document', () => {
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
component.selectedDocumentIDs = [20]
component.confirm()
expect(emitSpy).toHaveBeenCalledWith(20)
})
it('should require exactly one selected document', () => {
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
component.selectedDocumentIDs = [20, 21]
component.confirm()
expect(emitSpy).not.toHaveBeenCalled()
})
it('should dismiss on cancel', () => {
component.cancel()
expect(activeModal.dismiss).toHaveBeenCalled()
})
})
@@ -0,0 +1,28 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
@Component({
selector: 'pngx-add-existing-document-version-dialog',
templateUrl: './add-existing-document-version-dialog.component.html',
imports: [DocumentLinkComponent, FormsModule],
})
export class AddExistingDocumentVersionDialogComponent {
private readonly activeModal = inject(NgbActiveModal)
@Input() rootDocumentID: number
@Output() confirmClicked = new EventEmitter<number>()
selectedDocumentIDs: number[] = []
buttonsEnabled = true
confirm(): void {
if (this.selectedDocumentIDs.length !== 1) return
this.confirmClicked.emit(this.selectedDocumentIDs[0])
}
cancel(): void {
this.activeModal.dismiss()
}
}
@@ -24,13 +24,26 @@
class="visually-hidden"
(change)="onVersionFileSelected($event)"
/>
<button
class="btn btn-sm btn-outline-secondary w-100"
(click)="versionFileInput.click()"
[disabled]="!userIsOwner || !userCanEdit"
>
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button>
<div class="btn-group btn-group-sm w-100">
<button
class="btn btn-sm btn-outline-secondary w-100"
(click)="versionFileInput.click()"
[disabled]="!userIsOwner || !userCanEdit"
title="Upload a new version"
i18n-title
>
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Upload</span>
</button>
<button
class="btn btn-sm btn-outline-secondary w-100"
(click)="addExistingDocumentAsVersion()"
[disabled]="!userIsOwner || !userCanEdit"
title="Use an existing document"
i18n-title
>
<i-bs name="file-earmark"></i-bs><span class="ps-1" i18n>Existing</span>
</button>
</div>
} @else {
@switch (versionUploadState()) {
@case (UploadState.Uploading) {
@@ -1,6 +1,7 @@
import { DatePipe } from '@angular/common'
import { SimpleChange } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
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'
@@ -19,12 +20,17 @@ describe('DocumentVersionDropdownComponent', () => {
let documentService: jest.Mocked<
Pick<
DocumentService,
'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
| 'deleteVersion'
| 'getVersions'
| 'mergeDocumentsAsVersions'
| 'uploadVersion'
| 'updateVersionLabel'
>
>
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
beforeEach(async () => {
finished$ = new Subject<{ taskId: string }>()
@@ -32,9 +38,11 @@ describe('DocumentVersionDropdownComponent', () => {
documentService = {
deleteVersion: jest.fn(),
getVersions: jest.fn(),
mergeDocumentsAsVersions: jest.fn(),
uploadVersion: jest.fn(),
updateVersionLabel: jest.fn(),
}
modalService = { open: jest.fn() }
toastService = {
showError: jest.fn(),
showInfo: jest.fn(),
@@ -61,6 +69,10 @@ describe('DocumentVersionDropdownComponent', () => {
provide: ToastService,
useValue: toastService,
},
{
provide: NgbModal,
useValue: modalService,
},
{
provide: WebsocketStatusService,
useValue: {
@@ -323,4 +335,43 @@ describe('DocumentVersionDropdownComponent', () => {
expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('')
})
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
const confirmClicked = new Subject<number>()
const modal = {
componentInstance: {
rootDocumentID: null,
buttonsEnabled: true,
confirmClicked,
},
close: jest.fn(),
}
modalService.open.mockReturnValue(modal as any)
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
const versions: DocumentVersionInfo[] = [
{ id: 3, is_root: true, checksum: 'aaaa' },
{ id: 20, is_root: false, checksum: 'cccc' },
]
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
component.newVersionLabel = ' Imported '
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
component.addExistingDocumentAsVersion()
expect(modal.componentInstance.rootDocumentID).toEqual(3)
confirmClicked.next(20)
expect(documentService.mergeDocumentsAsVersions).toHaveBeenCalledWith(
[3, 20],
3,
'Imported'
)
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
expect(documentService.getVersions).toHaveBeenCalledWith(3)
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalled()
})
})
@@ -11,7 +11,7 @@ import {
SimpleChanges,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { merge, of, Subject } from 'rxjs'
import {
@@ -33,6 +33,7 @@ import {
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
@Component({
selector: 'pngx-document-version-dropdown',
@@ -69,6 +70,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
private readonly documentsService = inject(DocumentService)
private readonly toastService = inject(ToastService)
private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly modalService = inject(NgbModal)
private readonly destroy$ = new Subject<void>()
private readonly documentChange$ = new Subject<void>()
@@ -278,6 +280,56 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
})
}
addExistingDocumentAsVersion(): void {
const modal = this.modalService.open(
AddExistingDocumentVersionDialogComponent,
{ backdrop: 'static' }
)
const dialog =
modal.componentInstance as AddExistingDocumentVersionDialogComponent
dialog.rootDocumentID = this.documentId
dialog.confirmClicked
.pipe(takeUntil(this.destroy$), takeUntil(this.documentChange$))
.subscribe((existingDocumentID) => {
dialog.buttonsEnabled = false
const versionLabel = this.newVersionLabel?.trim()
this.documentsService
.mergeDocumentsAsVersions(
[this.documentId, existingDocumentID],
this.documentId,
versionLabel
)
.pipe(
switchMap(() => this.documentsService.getVersions(this.documentId)),
first(),
finalize(() => (dialog.buttonsEnabled = true)),
takeUntil(this.destroy$),
takeUntil(this.documentChange$)
)
.subscribe({
next: (document) => {
if (document?.versions) {
this.versionsUpdated.emit(document.versions)
this.versionSelected.emit(
Math.max(...document.versions.map((version) => version.id))
)
}
this.newVersionLabel = ''
modal.close()
this.toastService.showInfo(
$localize`Existing document added as a version.`
)
},
error: (error) => {
this.toastService.showError(
$localize`Error adding existing document as a version`,
error
)
},
})
})
}
clearVersionUploadStatus(): void {
this.versionUploadState.set(UploadState.Idle)
this.versionUploadError.set(null)
@@ -95,6 +95,9 @@
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || list.allSelected || list.selectedCount < 2">
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
</button>
</div>
</div>
</div>
@@ -1248,6 +1248,52 @@ describe('BulkEditorComponent', () => {
expect(documentListViewService.selected.size).toEqual(0)
})
it('should support merging documents as versions', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: [3, 4],
count: 2,
results: [
{ id: 3, title: 'Document 3' },
{ id: 4, title: 'Document 4' },
],
})
)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
jest
.spyOn(permissionsService, 'currentUserOwnsObject')
.mockReturnValue(true)
const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true))
fixture.detectChanges()
component.mergeSelectedAsVersions()
expect(modal).not.toBeUndefined()
modal.componentInstance.rootDocumentID.set(4)
modal.componentInstance.confirm()
expect(mergeAsVersionsSpy).toHaveBeenCalledWith([3, 4], 4)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
)
expect(documentListViewService.selected.size).toEqual(0)
})
it('should support bulk download with archive, originals or both and file formatting', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
@@ -50,6 +50,7 @@ import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -1002,6 +1003,34 @@ export class BulkEditorComponent
})
}
mergeSelectedAsVersions() {
let modal = this.modalService.open(MergeAsVersionsConfirmDialogComponent, {
backdrop: 'static',
})
const mergeDialog =
modal.componentInstance as MergeAsVersionsConfirmDialogComponent
const documentIDs = Array.from(this.list.selected)
mergeDialog.title = $localize`Merge as versions`
mergeDialog.message = $localize`The selected documents will become versions of the root document.`
mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs.set(documentIDs)
mergeDialog.rootDocumentID.set(documentIDs[0])
mergeDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
mergeDialog.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.mergeDocumentsAsVersions(
mergeDialog.documentIDs(),
mergeDialog.rootDocumentID()
),
{ deleteOriginals: true }
)
this.toastService.showInfo($localize`Documents merged as versions.`)
})
}
public setCustomFieldValues(changedCustomFields: ChangedItems) {
const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
backdrop: 'static',
@@ -88,7 +88,7 @@
@if (depth > 0) {
<div class="indicator"></div>
}
<button class="btn btn-link ms-0 ps-0 text-start" style="user-select: text;" [disabled]="!userCanEdit(object)" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
<button class="btn btn-link ms-0 ps-0 text-start" style="user-select: text;" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
</td>
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td>{{ getDocumentCount(object) }}</td>
@@ -316,6 +316,34 @@ describe(`DocumentService`, () => {
})
})
it('should call appropriate api endpoint for merging documents as versions', () => {
const ids = [1, 2, 3]
subscription = service.mergeDocumentsAsVersions(ids, 2).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/merge_as_versions/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
root_document_id: 2,
})
})
it('should include an optional label when merging one document as a version', () => {
const ids = [1, 2]
subscription = service
.mergeDocumentsAsVersions(ids, 2, 'Imported')
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/merge_as_versions/`
)
expect(req.request.body).toEqual({
documents: ids,
root_document_id: 2,
version_label: 'Imported',
})
})
it('should call appropriate api endpoint for edit pdf', () => {
const ids = [1]
const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] }
@@ -374,6 +374,18 @@ export class DocumentService extends AbstractPaperlessService<Document> {
})
}
mergeDocumentsAsVersions(
ids: number[],
rootDocumentId: number,
versionLabel?: string
) {
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
documents: ids,
root_document_id: rootDocumentId,
...(versionLabel ? { version_label: versionLabel } : {}),
})
}
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
documents: ids,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -101,6 +101,7 @@ import {
house,
infoCircle,
journals,
journalBookmarkFill,
link,
listNested,
listTask,
@@ -323,6 +324,7 @@ const icons = {
hddStack,
house,
infoCircle,
journalBookmarkFill,
journals,
link,
listNested,
@@ -19,13 +19,6 @@ export const GlobalWorkerOptions = {
workerSrc: '',
}
export const AnnotationMode = {
DISABLE: 0,
ENABLE: 1,
ENABLE_FORMS: 2,
ENABLE_STORAGE: 3,
}
export const getDocument = (_src: unknown): PDFDocumentLoadingTask => {
return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy()))
}
+73
View File
@@ -12,6 +12,7 @@ from celery import group
from celery import shared_task
from django.conf import settings
from django.db import transaction
from django.db.models import Max
from django.db.models import Q
from django.utils import timezone
@@ -30,6 +31,7 @@ from documents.permissions import set_permissions_for_object
from documents.plugins.helpers import DocumentsStatusManager
from documents.tasks import bulk_update_documents
from documents.tasks import consume_file
from documents.tasks import remove_document_from_index
from documents.tasks import update_document_content_maybe_archive_file
from documents.versioning import get_latest_version_for_root
from documents.versioning import get_root_document
@@ -612,6 +614,77 @@ def merge(
return "OK"
def merge_as_versions(
doc_ids: list[int],
*,
root_document_id: int,
version_label: str | None = None,
) -> Literal["OK"]:
with transaction.atomic():
documents = list(
Document.objects.select_for_update().filter(id__in=doc_ids),
)
documents_by_id = {document.id: document for document in documents}
if len(documents) != len(doc_ids):
raise ValueError("Some documents do not exist or were specified twice.")
if root_document_id not in documents_by_id:
raise ValueError("The root document must be selected.")
if any(document.root_document_id is not None for document in documents):
raise ValueError("Only top-level documents can be merged as versions.")
source_ids = sorted(doc_id for doc_id in doc_ids if doc_id != root_document_id)
if version_label is not None and len(source_ids) != 1:
raise ValueError(
"A version label can only be set when merging one source document.",
)
if Document.objects.filter(root_document_id__in=source_ids).exists():
raise ValueError(
"Documents with existing versions cannot be merged into another document.",
)
root_document = documents_by_id[root_document_id]
next_version_index = (
Document.global_objects.filter(
root_document_id=root_document_id,
).aggregate(max_index=Max("version_index"))["max_index"]
or 0
)
for source_id in source_ids:
source_document = documents_by_id[source_id]
next_version_index += 1
source_document.root_document = root_document
source_document.version_index = next_version_index
update_fields = [
"root_document",
"version_index",
"archive_serial_number",
]
if version_label is not None:
source_document.version_label = version_label
update_fields.append("version_label")
source_document.archive_serial_number = None
source_document.save(update_fields=update_fields)
root_document.modified = timezone.now()
root_document.save(update_fields=["modified"])
for source_id in source_ids:
remove_document_from_index.apply_async(args=[source_id])
bulk_update_documents.apply_async(
kwargs={"document_ids": [root_document_id]},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
# And as far as the frontend is concerned, they're deleted
status_mgr = DocumentsStatusManager()
status_mgr.send_documents_deleted(source_ids)
return "OK"
def split(
doc_ids: list[int],
pages: list[list[int]],
+49 -24
View File
@@ -39,6 +39,7 @@ from guardian.utils import get_user_obj_perms_model
from rest_framework import serializers
from rest_framework.filters import BaseFilterBackend
from rest_framework.filters import OrderingFilter
from rest_framework_guardian.filters import ObjectPermissionsFilter
from documents.models import Correspondent
from documents.models import CustomField
@@ -50,7 +51,7 @@ from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_object_ids
from documents.permissions import permitted_document_ids
if TYPE_CHECKING:
from collections.abc import Callable
@@ -1027,35 +1028,59 @@ class PaperlessTaskFilterSet(FilterSet):
return queryset.exclude(status__in=PaperlessTask.COMPLETE_STATUSES)
class PermittedObjectsFilter(BaseFilterBackend):
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
"""
Filters a queryset down to objects the requesting user owns, are
unowned, or (when ``include_granted`` is True) has an explicit
user/group guardian permission on. Backed by ``permitted_object_ids``
-- a single ``id__in`` subquery, not a join -- so it can't produce
duplicate rows even when the base queryset already carries independent
joins (e.g. multi-value ``tags__id__all`` filtering), and stays
index-friendly at scale instead of falling back to guardian's
varchar-cast join.
Set ``include_granted = False`` on a subclass for endpoints that
intentionally only show owned/unowned objects regardless of explicit
shares (e.g. ``TrashView``).
A filter backend that limits results to those where the requesting user
has read object level permissions, owns the objects, or objects without
an owner (for backwards compat)
"""
include_granted: bool = True
perm_codename: str | None = None
def filter_queryset(self, request, queryset, view):
if request.user.is_superuser:
return queryset
if not self.include_granted:
return queryset.filter(Q(owner=request.user) | Q(owner__isnull=True))
model = queryset.model
perm = self.perm_codename or f"view_{model._meta.model_name}"
return queryset.filter(
id__in=permitted_object_ids(request.user, model, perm),
)
objects_with_perms = super().filter_queryset(request, queryset, view)
objects_owned = queryset.filter(owner=request.user)
objects_unowned = queryset.filter(owner__isnull=True)
return objects_with_perms | objects_owned | objects_unowned
class DocumentPermissionsFilter(BaseFilterBackend):
"""
A filter backend limiting Document results to those the requesting user
owns, are unowned, or has explicit (user- or group-level) view
permission on.
Unlike ``ObjectOwnedOrGrantedPermissionsFilter``, this does not build an
``objects_with_perms | objects_owned | objects_unowned`` union of
querysets derived from the same base queryset. When that base queryset
already carries independent joins on a multi-valued relation (e.g. two
separate joins from ``tags__id__all`` filtering on two tags), each
OR-ed branch can end up pairing those joins' aliases differently,
letting more than one row out of the join's cross product satisfy the
combined WHERE -- returning the same document more than once. Filtering
via a single ``id__in`` against ``permitted_document_ids`` (a plain
subquery, not a join) sidesteps that entirely and is also cheaper than
guardian's join-based permission check.
"""
def filter_queryset(self, request, queryset, view):
if request.user.is_superuser:
return queryset
return queryset.filter(id__in=permitted_document_ids(request.user))
class ObjectOwnedPermissionsFilter(ObjectPermissionsFilter):
"""
A filter backend that limits results to those where the requesting user
owns the objects or objects without an owner (for backwards compat)
"""
def filter_queryset(self, request, queryset, view):
if request.user.is_superuser:
return queryset
objects_owned = queryset.filter(owner=request.user)
objects_unowned = queryset.filter(owner__isnull=True)
return objects_owned | objects_unowned
class DocumentsOrderingFilter(OrderingFilter):
+14 -10
View File
@@ -19,7 +19,7 @@ from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowTrigger
from documents.permissions import permitted_object_ids
from documents.permissions import get_objects_for_user_owner_aware
from documents.regex import safe_regex_search
if TYPE_CHECKING:
@@ -55,8 +55,10 @@ def match_correspondents(document: Document, classifier: DocumentClassifier, use
user = document.owner
if user is not None:
correspondents = Correspondent.objects.filter(
id__in=permitted_object_ids(user, Correspondent, "view_correspondent"),
correspondents = get_objects_for_user_owner_aware(
user,
"documents.view_correspondent",
Correspondent,
)
else:
correspondents = Correspondent.objects.all()
@@ -84,8 +86,10 @@ def match_document_types(document: Document, classifier: DocumentClassifier, use
user = document.owner
if user is not None:
document_types = DocumentType.objects.filter(
id__in=permitted_object_ids(user, DocumentType, "view_documenttype"),
document_types = get_objects_for_user_owner_aware(
user,
"documents.view_documenttype",
DocumentType,
)
else:
document_types = DocumentType.objects.all()
@@ -112,9 +116,7 @@ def match_tags(document: Document, classifier: DocumentClassifier, user=None):
user = document.owner
if user is not None:
tags = Tag.objects.filter(
id__in=permitted_object_ids(user, Tag, "view_tag"),
)
tags = get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
else:
tags = Tag.objects.all()
@@ -143,8 +145,10 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
user = document.owner
if user is not None:
storage_paths = StoragePath.objects.filter(
id__in=permitted_object_ids(user, StoragePath, "view_storagepath"),
storage_paths = get_objects_for_user_owner_aware(
user,
"documents.view_storagepath",
StoragePath,
)
else:
storage_paths = StoragePath.objects.all()
+25 -59
View File
@@ -7,7 +7,6 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
from django.db.models import Count
from django.db.models import IntegerField
from django.db.models import Model
from django.db.models import Q
from django.db.models import QuerySet
from django.db.models import Value
@@ -164,32 +163,30 @@ def set_permissions_for_object(
)
def permitted_object_ids(
user: User | None,
model: type[Model],
perm: str,
def permitted_document_ids(
user,
*,
perm: str = "view_document",
include_deleted: bool = False,
) -> QuerySet[int]:
):
"""
Generic version of ``permitted_document_ids`` for any model with an
``owner`` field and guardian object-level permissions. ``include_deleted``
only has an effect for models exposing a ``global_objects``/``deleted_at``
soft-delete pattern (currently only ``Document``); for every other model
it is accepted but has no effect, since those models have no soft-delete
concept.
Return a queryset of document IDs the user has ``perm`` on (default
``"view_document"``). By default limited to non-deleted documents; pass
``include_deleted=True`` for callers that need to check permission on
soft-deleted documents (e.g. trash restore). This intentionally avoids
``get_objects_for_user`` to keep the subquery small and index-friendly.
"""
has_soft_delete = hasattr(model, "global_objects")
manager = (
model.global_objects if include_deleted and has_soft_delete else model.objects
)
base_qs = manager.all().only("id", "owner")
manager = Document.global_objects if include_deleted else Document.objects
base_docs = manager.all()
base_docs = base_docs.only("id", "owner")
if user is None or not getattr(user, "is_authenticated", False):
return base_qs.filter(owner__isnull=True).values_list("id", flat=True)
# Just Anonymous user e.g. for drf-spectacular
return base_docs.filter(owner__isnull=True).values_list("id", flat=True)
if getattr(user, "is_superuser", False):
return base_qs.values_list("id", flat=True)
return base_docs.values_list("id", flat=True)
# Guardian's UserObjectPermission/GroupObjectPermission always store a bare
# codename, but has_perm()-style callers commonly pass the qualified
@@ -197,46 +194,31 @@ def permitted_object_ids(
# codename, so just drop any prefix rather than silently under-permitting.
perm = perm.rsplit(".", 1)[-1]
content_type = ContentType.objects.get_for_model(model)
document_ct = ContentType.objects.get_for_model(Document)
perm_filter = {
"permission__codename": perm,
"permission__content_type": content_type,
"permission__content_type": document_ct,
}
user_perm_ids = (
user_perm_docs = (
UserObjectPermission.objects.filter(user=user, **perm_filter)
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
.values_list("object_pk_int", flat=True)
)
group_perm_ids = (
group_perm_docs = (
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
.values_list("object_pk_int", flat=True)
)
permitted_ids = user_perm_ids.union(group_perm_ids)
return base_qs.filter(
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_ids),
permitted_documents = user_perm_docs.union(group_perm_docs)
return base_docs.filter(
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_documents),
).values_list("id", flat=True)
def permitted_document_ids(
user: User | None,
*,
perm: str = "view_document",
include_deleted: bool = False,
) -> QuerySet[int]:
"""
Document-specific convenience wrapper around ``permitted_object_ids``.
Return a queryset of document IDs the user has ``perm`` on (default
``"view_document"``). By default limited to non-deleted documents; pass
``include_deleted=True`` for callers that need to check permission on
soft-deleted documents (e.g. trash restore). This intentionally avoids
``get_objects_for_user`` to keep the subquery small and index-friendly.
"""
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
def get_document_count_filter_for_user(user, related_name: str = "documents"):
"""
Return the Q object used to filter document counts for the given user.
@@ -359,13 +341,6 @@ def get_objects_for_user_owner_aware(
"""
Returns objects the user owns, are unowned, or has explicit perms.
When include_deleted is True, soft-deleted items are also included.
Legacy slow path (guardian-backed, O(n) style permission resolution).
Most queryset-filtering call sites have migrated onto
``PermittedObjectsFilter``/``permitted_object_ids()``, but this function
is kept because production callers still remain. Several callers remain
across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` --
grep for this function name before removing it.
"""
manager = (
Model.global_objects
@@ -385,15 +360,6 @@ def get_objects_for_user_owner_aware(
def has_perms_owner_aware(user, perms, obj):
"""
Legacy slow path (guardian-backed) single-object permission check.
The queryset-filtering side of this migrated onto
``PermittedObjectsFilter``/``permitted_object_ids()``, but this
single-object check still has many production callers. Several callers
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
-- grep for this function name before removing it.
"""
checker = ObjectPermissionChecker(user)
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
+46
View File
@@ -1675,6 +1675,52 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
from_webui = serializers.BooleanField(required=False, default=False)
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
root_document_id = serializers.IntegerField(required=True)
version_label = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
max_length=64,
)
def validate_version_label(self, value):
if value is None:
return None
normalized = value.strip()
return normalized or None
def validate(self, attrs):
documents = attrs["documents"]
if len(documents) < 2:
raise serializers.ValidationError(
"At least two documents are required.",
)
if "version_label" in attrs and len(documents) != 2:
raise serializers.ValidationError(
"version_label can only be used when merging one source document.",
)
if attrs["root_document_id"] not in documents:
raise serializers.ValidationError(
"root_document_id must be one of the selected documents.",
)
selected_documents = Document.objects.filter(id__in=documents)
if selected_documents.filter(root_document__isnull=False).exists():
raise serializers.ValidationError(
"Only top-level documents can be merged as versions.",
)
source_document_ids = set(documents) - {attrs["root_document_id"]}
if Document.objects.filter(
root_document_id__in=source_document_ids,
).exists():
raise serializers.ValidationError(
"Documents with existing versions cannot be merged into another document.",
)
return attrs
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
operations = serializers.ListField(required=True)
delete_original = serializers.BooleanField(required=False, default=False)
+1
View File
@@ -48,6 +48,7 @@ class TestApiSchema(APITestCase):
self.assertIn("/api/documents/reprocess/", paths)
self.assertIn("/api/documents/rotate/", paths)
self.assertIn("/api/documents/merge/", paths)
self.assertIn("/api/documents/merge_as_versions/", paths)
self.assertIn("/api/documents/edit_pdf/", paths)
self.assertIn("/api/documents/remove_password/", paths)
@@ -0,0 +1,402 @@
import json
from unittest import mock
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APITestCase
from documents.bulk_edit import merge_as_versions
from documents.models import Document
from documents.serialisers import MergeDocumentsAsVersionsSerializer
class TestMergeDocumentsAsVersionsSerializer(TestCase):
def setUp(self) -> None:
self.doc1 = Document.objects.create(checksum="A", title="A")
self.doc2 = Document.objects.create(checksum="B", title="B")
self.doc3 = Document.objects.create(checksum="C", title="C")
def test_accepts_selected_root_document(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc2.id,
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertEqual(
serializer.validated_data,
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc2.id,
},
)
def test_requires_at_least_two_documents(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id],
"root_document_id": self.doc1.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"At least two documents are required.",
)
def test_accepts_version_label_for_one_source_document(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc1.id,
"version_label": " Imported ",
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertEqual(serializer.validated_data["version_label"], "Imported")
def test_rejects_version_label_for_multiple_source_documents(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc1.id,
"version_label": "Imported",
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"version_label can only be used when merging one source document.",
)
def test_requires_root_document_to_be_selected(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc3.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"root_document_id must be one of the selected documents.",
)
def test_rejects_duplicate_documents(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc1.id],
"root_document_id": self.doc1.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertIn("documents", serializer.errors)
def test_rejects_selected_version(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [version.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Only top-level documents can be merged as versions.",
)
def test_rejects_source_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Documents with existing versions cannot be merged into another document.",
)
def test_allows_root_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc1.id,
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
class TestMergeDocumentsAsVersions(TestCase):
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_edit.remove_document_from_index.apply_async")
def test_merges_documents_in_creation_order(
self,
remove_from_index_mock,
bulk_update_mock,
status_manager_mock,
) -> None:
root = Document.objects.create(checksum="A", title="Root")
existing_version = Document.objects.create(
checksum="B",
title="Existing version",
root_document=root,
version_index=3,
)
source1 = Document.objects.create(
checksum="C",
title="Source 1",
archive_serial_number=1,
)
source2 = Document.objects.create(
checksum="D",
title="Source 2",
archive_serial_number=2,
)
original_modified = root.modified
result = merge_as_versions(
[source2.id, root.id, source1.id],
root_document_id=root.id,
)
self.assertEqual(result, "OK")
source1.refresh_from_db()
source2.refresh_from_db()
root.refresh_from_db()
self.assertEqual(source2.root_document_id, root.id)
self.assertEqual(source2.version_index, 5)
self.assertEqual(source1.root_document_id, root.id)
self.assertEqual(source1.version_index, 4)
self.assertIsNone(source1.archive_serial_number)
self.assertIsNone(source2.archive_serial_number)
self.assertGreater(root.modified, original_modified)
self.assertEqual(existing_version.root_document_id, root.id)
self.assertEqual(
[call.kwargs["args"] for call in remove_from_index_mock.call_args_list],
[[source1.id], [source2.id]],
)
bulk_update_mock.assert_called_once_with(
kwargs={"document_ids": [root.id]},
headers={"trigger_source": "system"},
)
status_manager_mock.return_value.send_documents_deleted.assert_called_once_with(
[source1.id, source2.id],
)
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_edit.remove_document_from_index.apply_async")
def test_sets_version_label_for_one_source_document(
self,
_remove_from_index_mock,
_bulk_update_mock,
_status_manager_mock,
) -> None:
root = Document.objects.create(checksum="A", title="Root")
source = Document.objects.create(checksum="B", title="Source")
merge_as_versions(
[root.id, source.id],
root_document_id=root.id,
version_label="Imported",
)
source.refresh_from_db()
self.assertEqual(source.version_label, "Imported")
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_edit.remove_document_from_index.apply_async")
def test_rejects_source_document_with_versions(
self,
remove_from_index_mock,
bulk_update_mock,
status_manager_mock,
) -> None:
source = Document.objects.create(checksum="A", title="Source")
Document.objects.create(
checksum="B",
title="Source version",
root_document=source,
version_index=1,
)
root = Document.objects.create(checksum="C", title="Root")
with self.assertRaisesRegex(ValueError, "existing versions"):
merge_as_versions(
[source.id, root.id],
root_document_id=root.id,
)
source.refresh_from_db()
self.assertIsNone(source.root_document_id)
remove_from_index_mock.assert_not_called()
bulk_update_mock.assert_not_called()
status_manager_mock.assert_not_called()
class TestMergeDocumentsAsVersionsAPI(APITestCase):
def setUp(self) -> None:
self.user = User.objects.create_user(username="user")
self.user.user_permissions.add(
Permission.objects.get(codename="change_document"),
Permission.objects.get(codename="view_document"),
)
self.doc1 = Document.objects.create(
checksum="A",
title="A",
owner=self.user,
)
self.doc2 = Document.objects.create(
checksum="B",
title="B",
owner=self.user,
)
self.client.force_authenticate(user=self.user)
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_merges_documents_as_versions(self, merge_mock) -> None:
merge_mock.return_value = "OK"
merge_mock.__name__ = "merge_as_versions"
response = self.client.post(
"/api/documents/merge_as_versions/",
json.dumps(
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
"version_label": "Imported",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {"result": "OK"})
merge_mock.assert_called_once_with(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id,
version_label="Imported",
)
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_requires_change_permission(self, merge_mock) -> None:
merge_mock.__name__ = "merge_as_versions"
user = User.objects.create_user(username="no-change")
self.doc1.owner = user
self.doc1.save()
self.doc2.owner = user
self.doc2.save()
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc1.id,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
merge_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_rejects_unselected_root(self, merge_mock) -> None:
doc3 = Document.objects.create(
checksum="C",
title="C",
owner=self.user,
)
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": doc3.id,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
merge_mock.assert_not_called()
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_edit.remove_document_from_index.apply_async")
def test_merges_and_returns_documents_as_versions(
self,
remove_from_index_mock,
bulk_update_mock,
status_manager_mock,
) -> None:
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
"version_label": "Imported",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.doc1.refresh_from_db()
self.assertEqual(self.doc1.root_document_id, self.doc2.id)
self.assertEqual(self.doc1.version_label, "Imported")
detail_response = self.client.get(
f"/api/documents/{self.doc2.id}/?fields=id,versions",
)
self.assertEqual(detail_response.status_code, status.HTTP_200_OK)
versions = detail_response.data["versions"]
self.assertEqual(
{version["id"] for version in versions},
{self.doc1.id, self.doc2.id},
)
self.assertEqual(
[version["id"] for version in versions if version["is_root"]],
[self.doc2.id],
)
remove_from_index_mock.assert_called_once_with(args=[self.doc1.id])
bulk_update_mock.assert_called_once_with(
kwargs={"document_ids": [self.doc2.id]},
headers={"trigger_source": "system"},
)
status_manager_mock.return_value.send_documents_deleted.assert_called_once_with(
[self.doc1.id],
)
@@ -12,22 +12,9 @@ from django.test import override_settings
from guardian.shortcuts import assign_perm
from rest_framework.test import APIClient
from documents.matching import match_correspondents
from documents.matching import match_document_types
from documents.matching import match_storage_paths
from documents.matching import match_tags
from documents.models import Correspondent
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids
from documents.serialisers import _get_viewable_duplicates
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentFactory
from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden):
@@ -444,320 +431,3 @@ class TestTrashRestorePermissionBoundary:
format="json",
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.django_db
class TestTrashViewExcludesExplicitlyGrantedDocuments:
"""
Regression test pinning TrashView's use of
``_TrashPermittedObjectsFilter`` (``include_granted = False``). If that
flag were ever flipped to the default ``True``, or the subclass removed
in favor of the base ``PermittedObjectsFilter``, a trashed document
would leak into ``/api/trash/`` results for any user holding an
explicit guardian grant on it, even though they are neither the owner
nor a superuser.
"""
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
owner = User.objects.create_user(username="trash_owner")
grantee = User.objects.create_user(username="trash_grantee")
doc = DocumentFactory(owner=owner)
doc.delete() # soft delete
assign_perm("view_document", grantee, doc)
rest_api_client.force_authenticate(user=grantee)
response = rest_api_client.get("/api/trash/")
assert response.status_code == HTTPStatus.OK
result_ids = {result["id"] for result in response.data["results"]}
assert doc.pk not in result_ids
@pytest.mark.django_db
@pytest.mark.parametrize(
("model", "factory", "perm"),
[
(Tag, TagFactory, "view_tag"),
(Correspondent, CorrespondentFactory, "view_correspondent"),
(DocumentType, DocumentTypeFactory, "view_documenttype"),
(StoragePath, StoragePathFactory, "view_storagepath"),
],
)
class TestPermittedObjectIdsGenericModels:
def test_owner_sees_own_object(self, model, factory, perm):
owner = User.objects.create_user(username=f"owner_{model.__name__}")
stranger = User.objects.create_user(username=f"stranger_{model.__name__}")
owned = factory(owner=owner)
strangers = factory(owner=stranger)
assert_visible_document_ids(
permitted_object_ids(owner, model, perm),
expected_visible=[owned.pk],
expected_hidden=[strangers.pk],
)
def test_unowned_object_visible_to_everyone(self, model, factory, perm):
user = User.objects.create_user(username=f"user_{model.__name__}")
unowned = factory(owner=None)
assert_visible_document_ids(
permitted_object_ids(user, model, perm),
expected_visible=[unowned.pk],
expected_hidden=[],
)
def test_explicit_permission_grants_visibility(self, model, factory, perm):
owner = User.objects.create_user(username=f"owner2_{model.__name__}")
grantee = User.objects.create_user(username=f"grantee_{model.__name__}")
stranger = User.objects.create_user(username=f"stranger2_{model.__name__}")
shared = factory(owner=owner)
not_shared = factory(owner=owner)
assign_perm(perm, grantee, shared)
assert_visible_document_ids(
permitted_object_ids(grantee, model, perm),
expected_visible=[shared.pk],
expected_hidden=[not_shared.pk],
)
assert_visible_document_ids(
permitted_object_ids(stranger, model, perm),
expected_visible=[],
expected_hidden=[shared.pk, not_shared.pk],
)
def test_group_permission_grants_visibility_to_members_only(
self,
model,
factory,
perm,
):
owner = User.objects.create_user(username=f"owner3_{model.__name__}")
member = User.objects.create_user(username=f"member_{model.__name__}")
non_member = User.objects.create_user(username=f"nonmember_{model.__name__}")
group = Group.objects.create(name=f"group_{model.__name__}")
member.groups.add(group)
shared = factory(owner=owner)
assign_perm(perm, group, shared)
assert_visible_document_ids(
permitted_object_ids(member, model, perm),
expected_visible=[shared.pk],
expected_hidden=[],
)
assert_visible_document_ids(
permitted_object_ids(non_member, model, perm),
expected_visible=[],
expected_hidden=[shared.pk],
)
def test_superuser_sees_everything(self, model, factory, perm):
superuser = User.objects.create_superuser(username=f"root_{model.__name__}")
owner = User.objects.create_user(username=f"owner4_{model.__name__}")
obj = factory(owner=owner)
assert_visible_document_ids(
permitted_object_ids(superuser, model, perm),
expected_visible=[obj.pk],
expected_hidden=[],
)
@pytest.mark.django_db
class TestMatchingRespectsObjectPermissions:
def test_match_tags_only_considers_tags_visible_to_user(self):
owner = User.objects.create_user(username="tag_owner")
classifying_user = User.objects.create_user(username="classifier_user")
visible_tag = TagFactory(
owner=owner,
match="invoice",
matching_algorithm=Tag.MATCH_LITERAL,
)
hidden_tag = TagFactory(
owner=owner,
match="invoice",
matching_algorithm=Tag.MATCH_LITERAL,
)
assign_perm("view_tag", classifying_user, visible_tag)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_tags(doc, classifier=None, user=classifying_user)
matched_ids = {t.pk for t in matched}
assert visible_tag.pk in matched_ids
assert hidden_tag.pk not in matched_ids
def test_match_correspondents_only_considers_correspondents_visible_to_user(self):
owner = User.objects.create_user(username="correspondent_owner")
classifying_user = User.objects.create_user(username="classifier_user2")
visible_correspondent = CorrespondentFactory(
owner=owner,
match="invoice",
matching_algorithm=Correspondent.MATCH_LITERAL,
)
hidden_correspondent = CorrespondentFactory(
owner=owner,
match="invoice",
matching_algorithm=Correspondent.MATCH_LITERAL,
)
assign_perm("view_correspondent", classifying_user, visible_correspondent)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_correspondents(doc, classifier=None, user=classifying_user)
matched_ids = {c.pk for c in matched}
assert visible_correspondent.pk in matched_ids
assert hidden_correspondent.pk not in matched_ids
def test_match_document_types_only_considers_document_types_visible_to_user(self):
owner = User.objects.create_user(username="document_type_owner")
classifying_user = User.objects.create_user(username="classifier_user3")
visible_document_type = DocumentTypeFactory(
owner=owner,
match="invoice",
matching_algorithm=DocumentType.MATCH_LITERAL,
)
hidden_document_type = DocumentTypeFactory(
owner=owner,
match="invoice",
matching_algorithm=DocumentType.MATCH_LITERAL,
)
assign_perm("view_documenttype", classifying_user, visible_document_type)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_document_types(doc, classifier=None, user=classifying_user)
matched_ids = {dt.pk for dt in matched}
assert visible_document_type.pk in matched_ids
assert hidden_document_type.pk not in matched_ids
def test_match_storage_paths_only_considers_storage_paths_visible_to_user(self):
owner = User.objects.create_user(username="storage_path_owner")
classifying_user = User.objects.create_user(username="classifier_user4")
visible_storage_path = StoragePathFactory(
owner=owner,
match="invoice",
matching_algorithm=StoragePath.MATCH_LITERAL,
)
hidden_storage_path = StoragePathFactory(
owner=owner,
match="invoice",
matching_algorithm=StoragePath.MATCH_LITERAL,
)
assign_perm("view_storagepath", classifying_user, visible_storage_path)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_storage_paths(doc, classifier=None, user=classifying_user)
matched_ids = {sp.pk for sp in matched}
assert visible_storage_path.pk in matched_ids
assert hidden_storage_path.pk not in matched_ids
@pytest.mark.django_db
class TestBulkEditObjectsApplyToAllPermissionBoundary:
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
owner = User.objects.create_user(username="tags_owner")
requester = User.objects.create_user(username="tags_requester")
# grant the global change_tag permission so the object-level
# filtering (not the global has_perm check) is what's under test
requester.user_permissions.add(
Permission.objects.get(codename="change_tag"),
)
rest_api_client.force_authenticate(user=requester)
visible = TagFactory(owner=owner)
hidden = TagFactory(owner=owner)
assign_perm("view_tag", requester, visible)
assign_perm("change_tag", requester, visible)
response = rest_api_client.post(
"/api/bulk_edit_objects/",
{
"object_type": "tags",
"operation": "set_permissions",
"all": True,
"filters": {},
"owner": requester.pk,
},
format="json",
)
assert response.status_code == HTTPStatus.OK
# The apply_to_all dispatch must resolve permitted objects up front:
# the visible tag (object-level change_tag granted) gets its owner
# reassigned, while the hidden tag (no object-level grant) is
# excluded entirely and keeps its original owner.
visible.refresh_from_db()
hidden.refresh_from_db()
assert visible.owner == requester
assert hidden.owner == owner
@pytest.mark.django_db
class TestBulkEditObjectsTagDescendantPartialPermission:
def test_apply_to_all_descendant_expansion_respects_per_object_permissions(
self,
rest_api_client,
):
"""
GIVEN:
- A tag hierarchy (parent -> permitted_child, unpermitted_child)
- A non-superuser requester with object-level change_tag granted
on the parent and on only ONE of the two children
WHEN:
- bulk_edit_objects is called with all=True and a filter that
matches only the root (parent) tag, engaging the
tag-descendant-expansion logic in BulkEditObjectsView.post
THEN:
- The descendant expansion only pulls in descendants the
requester actually has permission on: the permitted child's
owner is reassigned alongside the parent's, while the
unpermitted child keeps its original owner. This pins that the
expansion checks per-object permissions (editable_ids), not
merely "is a descendant of a filter match".
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
``delete`` as the operation, because Tag.tn_parent (django-treenode)
cascades deletes to descendants at the database/ORM level regardless
of which tags the view resolved into ``objs`` -- a delete-based test
would pass/fail based on FK cascade behavior, not on whether the
descendant-expansion logic itself respected per-object permissions.
"""
owner = User.objects.create_user(username="tag_hierarchy_owner")
requester = User.objects.create_user(username="tag_hierarchy_requester")
# global change_tag permission so the has_perm() gate passes and the
# object-level permitted_object_ids filtering is what's under test
requester.user_permissions.add(
Permission.objects.get(codename="change_tag"),
)
rest_api_client.force_authenticate(user=requester)
parent = TagFactory(owner=owner, name="parent-tag")
permitted_child = TagFactory(
owner=owner,
name="permitted-child-tag",
tn_parent=parent,
)
unpermitted_child = TagFactory(
owner=owner,
name="unpermitted-child-tag",
tn_parent=parent,
)
assign_perm("change_tag", requester, parent)
assign_perm("change_tag", requester, permitted_child)
# unpermitted_child is intentionally NOT granted change_tag
response = rest_api_client.post(
"/api/bulk_edit_objects/",
{
"object_type": "tags",
"operation": "set_permissions",
"all": True,
"filters": {"is_root": True},
"owner": requester.pk,
},
format="json",
)
assert response.status_code == HTTPStatus.OK
parent.refresh_from_db()
permitted_child.refresh_from_db()
unpermitted_child.refresh_from_db()
assert parent.owner == requester
assert permitted_child.owner == requester
assert unpermitted_child.owner == owner
@@ -1,70 +0,0 @@
import pytest
from django.contrib.auth.models import User
from guardian.shortcuts import assign_perm
from rest_framework.test import APIRequestFactory
from documents.filters import PermittedObjectsFilter
from documents.models import Tag
from documents.tests.factories import TagFactory
class _DummyView:
queryset = Tag.objects.all()
@pytest.mark.django_db
class TestPermittedObjectsFilter:
def test_superuser_bypasses_filtering_entirely(self):
superuser = User.objects.create_superuser(username="root")
owner = User.objects.create_user(username="owner")
TagFactory(owner=owner)
request = APIRequestFactory().get("/")
request.user = superuser
result = PermittedObjectsFilter().filter_queryset(
request,
Tag.objects.all(),
_DummyView(),
)
assert result.count() == Tag.objects.count()
def test_non_superuser_sees_only_owned_unowned_and_granted(self):
owner = User.objects.create_user(username="owner")
grantee = User.objects.create_user(username="grantee")
owned = TagFactory(owner=grantee)
unowned = TagFactory(owner=None)
granted = TagFactory(owner=owner)
hidden = TagFactory(owner=owner)
assign_perm("view_tag", grantee, granted)
request = APIRequestFactory().get("/")
request.user = grantee
result = PermittedObjectsFilter().filter_queryset(
request,
Tag.objects.all(),
_DummyView(),
)
visible_ids = set(result.values_list("id", flat=True))
assert visible_ids == {owned.pk, unowned.pk, granted.pk}
assert hidden.pk not in visible_ids
def test_include_granted_false_excludes_explicitly_shared_objects(self):
owner = User.objects.create_user(username="owner2")
grantee = User.objects.create_user(username="grantee2")
owned = TagFactory(owner=grantee)
granted = TagFactory(owner=owner)
assign_perm("view_tag", grantee, granted)
request = APIRequestFactory().get("/")
request.user = grantee
class _OwnerOnlyFilter(PermittedObjectsFilter):
include_granted = False
result = _OwnerOnlyFilter().filter_queryset(
request,
Tag.objects.all(),
_DummyView(),
)
visible_ids = set(result.values_list("id", flat=True))
assert visible_ids == {owned.pk}
assert granted.pk not in visible_ids
+47 -22
View File
@@ -133,10 +133,12 @@ from documents.file_handling import format_filename
from documents.filters import CorrespondentFilterSet
from documents.filters import CustomFieldFilterSet
from documents.filters import DocumentFilterSet
from documents.filters import DocumentPermissionsFilter
from documents.filters import DocumentsOrderingFilter
from documents.filters import DocumentTypeFilterSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.filters import ObjectOwnedPermissionsFilter
from documents.filters import PaperlessTaskFilterSet
from documents.filters import PermittedObjectsFilter
from documents.filters import ShareLinkBundleFilterSet
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
@@ -176,7 +178,6 @@ from documents.permissions import has_global_statistics_permission
from documents.permissions import has_perms_owner_aware
from documents.permissions import has_system_status_permission
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids
from documents.permissions import set_permissions_for_object
from documents.plugins.date_parsing import get_date_parser
from documents.schema import generate_object_with_permissions_schema
@@ -195,6 +196,7 @@ from documents.serialisers import DocumentVersionLabelSerializer
from documents.serialisers import DocumentVersionSerializer
from documents.serialisers import EditPdfDocumentsSerializer
from documents.serialisers import EmailSerializer
from documents.serialisers import MergeDocumentsAsVersionsSerializer
from documents.serialisers import MergeDocumentsSerializer
from documents.serialisers import NotesSerializer
from documents.serialisers import PostDocumentSerializer
@@ -549,7 +551,7 @@ class CorrespondentViewSet(
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = CorrespondentFilterSet
ordering_fields = (
@@ -590,7 +592,7 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = TagFilterSet
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
@@ -682,7 +684,7 @@ class DocumentTypeViewSet(
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = DocumentTypeFilterSet
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
@@ -986,7 +988,7 @@ class DocumentViewSet(
DjangoFilterBackend,
SearchFilter,
DocumentsOrderingFilter,
PermittedObjectsFilter,
DocumentPermissionsFilter,
)
filterset_class = DocumentFilterSet
search_fields = ("title", "correspondent__name", "effective_content")
@@ -2672,7 +2674,7 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet[SavedVie
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
OrderingFilter,
PermittedObjectsFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
ordering_fields = ("name",)
@@ -2807,6 +2809,7 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
bulk_edit.rotate,
bulk_edit.delete_pages,
bulk_edit.edit_pdf,
bulk_edit.merge_as_versions,
bulk_edit.remove_password,
]
)
@@ -3093,6 +3096,33 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_merge_as_versions",
description="Merge selected documents as versions of a chosen root document",
responses={
200: inline_serializer(
name="MergeDocumentsAsVersionsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
serializer_class = MergeDocumentsAsVersionsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.merge_as_versions,
validated_data=serializer.validated_data,
operation_label="document merge as versions",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_delete",
@@ -3919,7 +3949,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Storag
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = StoragePathFilterSet
ordering_fields = ("name", "path", "matching_algorithm", "match", "document_count")
@@ -4450,7 +4480,7 @@ class ShareLinkViewSet(
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = ShareLinkFilterSet
ordering_fields = ("created", "expiration", "document")
@@ -4480,7 +4510,7 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = ShareLinkBundleFilterSet
ordering_fields = ("created", "expiration", "status")
@@ -4763,8 +4793,10 @@ class BulkEditObjectsView(PassUserMixin):
"document_types": DocumentTypeFilterSet,
"storage_paths": StoragePathFilterSet,
}[object_type]
user_permitted_objects = object_class.objects.filter(
id__in=permitted_object_ids(user, object_class, perm_codename),
user_permitted_objects = get_objects_for_user_owner_aware(
user,
perm_codename,
object_class,
)
objs = filterset_class(
data=filters,
@@ -4789,11 +4821,8 @@ class BulkEditObjectsView(PassUserMixin):
if not user.is_superuser:
perm = f"documents.{perm_codename}"
has_perms = (
user.has_perm(perm)
and not objs.exclude(
pk__in=permitted_object_ids(user, object_class, perm_codename),
).exists()
has_perms = user.has_perm(perm) and all(
has_perms_owner_aware(user, perm_codename, obj) for obj in objs
)
if not has_perms:
@@ -5294,11 +5323,7 @@ class SystemStatusView(PassUserMixin):
class TrashView(ListModelMixin, PassUserMixin):
permission_classes = (IsAuthenticated,)
serializer_class = TrashSerializer
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
include_granted = False
filter_backends = (_TrashPermittedObjectsFilter,)
filter_backends = (ObjectOwnedPermissionsFilter,)
pagination_class = StandardPagination
model = Document
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Afrikaans\n"
"Language: af_ZA\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Dokumente"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Waarde moet geldige JSON wees."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Ongeldige gepasmaakte veldnavraaguitdrukking"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Ongeldige uitdrukking lys. Moet nie leeg wees nie."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Ongeldige logiese uitdrukking {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr ""
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr ""
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr ""
@@ -1338,48 +1338,48 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Ongeldige kleur."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Lêertipe %(type)s word nie ondersteun nie"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Ongeldige veranderlike bespeur."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1634,36 +1634,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Amharic\n"
"Language: am_ET\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "መዝገባት"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "የሚሰራው እሴት \"JSON\" መሆን አለበት"
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "ልክ ያልሆነ የተወሰነ የቦታ መጠይቅ አገላለጽ"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "ልክ ያልሆነ የመግለጫ ዝርዝር። ባዶ መሆን የለበትም።"
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "ልክ ያልሆነ የሎጂክ ኦፕሬተር {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "ከፍተኛው የጥያቄ ሁኔታዎች/መጠን ብዛት አልፏል።"
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} ይሄ ታዐማኒነት ያለው ልማድ አይደለም።"
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "ጥያቄን አይደግፍም expr {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "ከፍተኛው የጥገኝነት ጥልቀት አልፏል።"
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "ይህ ልማድ አልተገኘም"
@@ -1338,48 +1338,48 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr ""
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1634,36 +1634,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"Language: ar_SA\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "المستندات"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "يجب أن تكون القيمة JSON."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "تعبير استعلام غير صالح للحقول المخصصة"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "قائمة عبارة خاطئة."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "تجاوز الحد الأقصى لعدد شروط الاستعلام."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} حقل مخصص غير صالح."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} لا يدعم تعبير الاستعلام {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "لم يتم العثور على حقل مخصص"
@@ -1338,48 +1338,48 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "لون خاطئ."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "نوع الملف %(type)s غير مدعوم"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "اكتشاف متغير خاطئ."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1635,36 +1635,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Belarusian\n"
"Language: be_BY\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Дакументы"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr ""
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr ""
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr ""
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr ""
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr ""
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr ""
@@ -1338,48 +1338,48 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Няправільны колер."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Тып файла %(type)s не падтрымліваецца"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Выяўлена няправільная зменная."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1634,36 +1634,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Bulgarian\n"
"Language: bg_BG\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Документи"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Стойността трябва да е валидна JSON."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Невалидна заявка на персонализираното полето"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Списък с невалиден израз. Не може да е празно."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Невалиден логически оператор {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Надвишен е максимален брой за заявки."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} не е валидно персонализирано поле."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} не поддържа заявка expr {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Надвишена е максималната дълбочина на вмъкване."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Персонализирано поле не е намерено"
@@ -1338,48 +1338,48 @@ msgstr "стартиране на работния процес"
msgid "workflow runs"
msgstr "стартиране на работните процеси"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Невалиден цвят."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Файловия тип %(type)s не се поддържа"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Засечена е невалидна променлива."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1635,36 +1635,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Catalan\n"
"Language: ca_ES\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Documents "
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Valor ha de ser un JSON valid."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Expressió de camp de consulta invàlid"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Expressió de llista invàlida. No ha d'estar buida."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Invàlid operand lògic {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Condicions de consulta excedits."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} no és un camp personalitzat vàlid."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} no suporta expressió de consulta {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Màxima profunditat anidada excedida."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Camp personalitzat no trobat"
@@ -1338,48 +1338,48 @@ msgstr "data del flux"
msgid "workflow runs"
msgstr "flux corrents"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr "Permisos insuficients."
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Color Invàlid."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Tipus arxiu %(type)s no suportat"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr "ID de camp personalizat ha de ser enter: %(id)s"
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr "Camp personalitzat amb ID %(id)s no existeix"
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "Camps personalitzats han de ser una llista d'enters o un objecte que mapegi els identificadors amb els valors."
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr "Alguns camps personalitzats no existeixen o s'han especificat dues vegades."
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Variable detectada invàlida."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr "Duplicat d'identificadors de documents no permès."
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr "Documents no trobats: %(ids)s"
@@ -1635,36 +1635,36 @@ msgstr "L'esquema d'URI '{parts.scheme}' no està permès. Esquemes permesos: {'
msgid "Unable to parse URI {value}"
msgstr "No s'ha pogut analitzar l'URI {value}"
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr "Invalid more_like_id"
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr "Configuració AI invàlida."
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "Especifica només un dels següents valors: text, title_search, query o more_like_id."
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr "Permisos insuficients per compartir document %(id)s."
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr "Paquet ja s'està processant."
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "El paquet de link encarà s'està preparant. Prova de nou més tard."
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr "El paquet d'enllaç no està disponible."
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Czech\n"
"Language: cs_CZ\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Dokumenty"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Hodnota musí být platný JSON."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Neplatný výraz dotazu na vlastní pole"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Neplatný seznam výrazů. Nesmí být prázdný."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Neplatný logický operátor {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Překročen maximální počet podmínek dotazu."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} není platné vlastní pole."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} nepodporuje výraz dotazu {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Překročena maximální hloubka větvení."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Vlastní pole nebylo nalezeno"
@@ -1338,48 +1338,48 @@ msgstr "spuštění pracovního postupu"
msgid "workflow runs"
msgstr "spuštění pracovních postupů"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr "Nedostatečná oprávnění."
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Neplatná barva."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Typ souboru %(type)s není podporován"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr "Vlastní ID pole musí být celé číslo: %(id)s"
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr "Vlastní pole s ID %(id)s neexistuje"
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "Vlastní pole musí být seznam celých čísel nebo ID pro mapování objektů na hodnoty."
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr "Některá vlastní pole neexistují nebo byla zadána dvakrát."
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Zjištěna neplatná proměnná."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1636,36 +1636,36 @@ msgstr "URI schéma '{parts.scheme}' není povoleno. Povolená schémata: {',\n"
msgid "Unable to parse URI {value}"
msgstr "Nelze zpracovat URI {value}"
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr "Nedostatečná oprávnění ke sdílení dokumentu %(id)s."
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Danish\n"
"Language: da_DK\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Dokumenter"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Værdien skal være gyldig JSON."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Ugyldigt tilpasset feltforespørgselsudtryk"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Ugyldig udtryksliste. Må ikke være tom."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Ugyldig logisk operatør {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Maksimalt antal forespørgselsbetingelser overskredet."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} er ikke et gyldigt tilpasset felt."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} understøtter ikke forespørgsel expr {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Maksimal indlejringsdybde overskredet."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Tilpasset felt ikke fundet"
@@ -1338,48 +1338,48 @@ msgstr "workflow-kørsel"
msgid "workflow runs"
msgstr "workflow-kørsler"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Ugyldig farve."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Filtype %(type)s understøttes ikke"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Ugyldig variabel fundet."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1635,36 +1635,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: German, Switzerland\n"
"Language: de_CH\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Dokumente"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Wert muss gültiges JSON sein."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Ungültiger benutzerdefinierter Feldabfrageausdruck"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Ungültige Ausdrucksliste. Darf nicht leer sein."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Ungültiger logischer Operator {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Maximale Anzahl an Abfragebedingungen überschritten."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} ist kein gültiges Zusatzfeld."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} unterstützt den Abfrageausdruck {expr!r} nicht."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Maximale Verschachtelungstiefe überschritten."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Benutzerdefiniertes Feld nicht gefunden"
@@ -1338,48 +1338,48 @@ msgstr "Arbeitsablauf-Ausführung"
msgid "workflow runs"
msgstr "Arbeitsablauf wird ausgeführt"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr "Unzureichende Berechtigungen."
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Ungültige Farbe."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Dateityp %(type)s nicht unterstützt"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr "Feld-ID eines benutzerdefinierten Felds muss eine Ganzzahl sein: %(id)s"
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr "Benutzerdefiniertes Feld mit ID %(id)s existiert nicht"
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "Benutzerdefinierte Felder müssen eine Liste von Ganzzahlen oder ein Objekt mit Zuordnung von IDs zu Werten sein."
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr "Einige benutzerdefinierte Felder existieren nicht oder wurden zweimal angegeben."
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Ungültige Variable erkannt."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr "Doppelte Dokumentbezeichner sind nicht erlaubt."
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr "Dokumente nicht gefunden: %(ids)s"
@@ -1635,36 +1635,36 @@ msgstr "URI-Schema „{parts.scheme}“ ist nicht erlaubt. Erlaubte Schemata: {'
msgid "Unable to parse URI {value}"
msgstr "URI {value} kann nicht gelesen werden"
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr "Ungültige more_like_id"
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr "Ungültige KI-Konfiguration."
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "Geben Sie nur einen von text, title_search, query, oder more_like_id an."
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr "Unzureichende Berechtigungen, um Dokument %(id)s zu teilen."
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr "Paket wird bereits verarbeitet."
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Das Freigabelink-Paket wird noch vorbereitet. Bitte versuchen Sie es später erneut."
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr "Das Freigabelink-Paket ist nicht verfügbar."
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Dokumente"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Wert muss gültiges JSON sein."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Ungültiger Zusatzfeld-Abfrageausdruck"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Ungültige Ausdrucksliste. Darf nicht leer sein."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Ungültiger logischer Operator {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Maximale Anzahl an Abfragebedingungen überschritten."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} ist kein gültiges Zusatzfeld."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} unterstützt den Abfrageausdruck {expr!r} nicht."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Maximale Verschachtelungstiefe überschritten."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Zusatzfeld nicht gefunden"
@@ -1338,48 +1338,48 @@ msgstr "Arbeitsablauf-Ausführung"
msgid "workflow runs"
msgstr "Arbeitsablauf wird ausgeführt"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr "Unzureichende Berechtigungen."
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Ungültige Farbe."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Dateityp %(type)s nicht unterstützt"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr "Zusatzfeld-ID muss eine Ganzzahl sein: %(id)s"
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr "Zusatzfeld mit ID %(id)s existiert nicht"
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "Zusatzfelder müssen eine Liste von Ganzzahlen oder ein Objekt mit Zuordnung von IDs zu Werten sein."
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr "Einige Zusatzfelder existieren nicht oder wurden zweimal angegeben."
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Ungültige Variable erkannt."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr "Doppelte Dokumentbezeichner sind nicht erlaubt."
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr "Dokumente nicht gefunden: %(ids)s"
@@ -1635,36 +1635,36 @@ msgstr "URI-Schema „{parts.scheme}“ ist nicht erlaubt. Erlaubte Schemata: {'
msgid "Unable to parse URI {value}"
msgstr "URI {value} kann nicht gelesen werden"
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr "Ungültige more_like_id"
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr "Ungültige KI-Konfiguration."
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr "Zeitüberschreitung bei der KI-Backendanfrage."
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "Geben Sie nur einen von text, title_search, query, oder more_like_id an."
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr "Unzureichende Berechtigungen, um Dokument %(id)s zu teilen."
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr "Paket wird bereits verarbeitet."
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Das Freigabelink-Paket wird noch vorbereitet. Bitte versuchen Sie es später erneut."
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr "Das Freigabelink-Paket ist nicht verfügbar."
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Greek\n"
"Language: el_GR\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Έγγραφα"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Η τιμή πρέπει να είναι σε έγκυρη μορφή JSON."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Μη έγκυρη έκφραση προσαρμοσμένου ερωτήματος πεδίου"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Μη έγκυρη λίστα έκφρασης. Πρέπει να είναι μη κενή."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Μη έγκυρος λογικός τελεστής {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Υπέρβαση μέγιστου αριθμού συνθηκών ερωτήματος."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "Το προσαρμοσμένο πεδίο {name!r} δεν είναι ένα έγκυρο."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "Το {data_type} δεν υποστηρίζει το ερώτημα expr {expr!r}s."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Υπέρβαση μέγιστου βάθους εμφώλευσης."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Το προσαρμοσμένο πεδίο δε βρέθηκε"
@@ -1338,48 +1338,48 @@ msgstr "εκτέλεση ροής εργασίας"
msgid "workflow runs"
msgstr "εκτελέσεις ροής εργασίας"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Άκυρο χρώμα."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Ο τύπος αρχείου %(type)s δεν υποστηρίζεται"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Εντοπίστηκε μη έγκυρη μεταβλητή."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1635,36 +1635,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+20 -20
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"POT-Creation-Date: 2026-08-07 20:00+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr ""
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr ""
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr ""
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr ""
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr ""
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr ""
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:756 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1073
#: documents/filters.py:1098
msgid "Custom field not found"
msgstr ""
@@ -1352,7 +1352,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:2767 documents/views.py:300 documents/views.py:2556
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
@@ -1393,7 +1393,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2853 documents/views.py:4510
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1661,36 +1661,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:293 documents/views.py:2553
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2378 documents/views.py:2699
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4523
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4569
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4630
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4640
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Documentos"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "El valor debe ser un JSON válido."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Expresión de consulta de campo personalizado no válida"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Lista de expresiones no válida. No debe estar vacía."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Operador lógico inválido {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Se ha superado el número máximo de condiciones de consulta."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{nombre!r} no es un campo personalizado válido."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} no admite la consulta expr {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Profundidad máxima de nidificación superada."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Campo personalizado no encontrado"
@@ -1338,48 +1338,48 @@ msgstr "ejecución del flujo de trabajo"
msgid "workflow runs"
msgstr "ejecuciones de flujo de trabajo"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr "Permisos insuficientes."
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Color inválido."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Tipo de fichero %(type)s no suportado"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr "El id del campo personalizado debe ser un entero: %(id)s"
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr "El campo personalizado con identificador %(id)s no existe"
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "Los campos personalizados deben ser una lista de enteros o un identificador de mapeo de objetos a valores."
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr "Algunos campos personalizados no existen o fueron especificados dos veces."
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Variable inválida."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr "No se permiten identificadores de documento duplicados."
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr "Documentos no encontrados: %(ids)s"
@@ -1635,36 +1635,36 @@ msgstr "El esquema URI '{parts.scheme}' no está permitido. Esquemas permitidos:
msgid "Unable to parse URI {value}"
msgstr "No se puede analizar la URI {value}"
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr "Configuración de IA inválida."
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "Especifique solo uno entre text, title_search, query, o more_like_id."
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr "Permisos insuficientes para compartir el documento %(id)s."
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr "El paquete ya está siendo procesado."
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "El paquete de enlace compartido aún está siendo preparado. Por favor, inténtalo de nuevo más tarde."
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr "El paquete de enlace compartido no está disponible."
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Estonian\n"
"Language: et_EE\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Dokumendid"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Väärtus peab olema lubatav JSON."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Vigane kohandatud välja päringu avaldis"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Vigane avaldiste loend. Peab olema mittetühi."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Vigane loogikaoperaator {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Päringutingimuste suurim hulk on ületatud."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} ei ole lubatud kohandatud väli."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} ei toeta päringu avaldist {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Suurim pesastamis sügavus ületatud."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Kohandatud välja ei leitud"
@@ -1338,48 +1338,48 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr ""
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1634,36 +1634,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Persian\n"
"Language: fa_IR\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "اسناد و مدارک"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "مقدار باید JSON معتبر باشد."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Invalid custom field query expression"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "لیست عبارت‌ها نامعتبر است. نباید خالی باشد."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "حداکثر تعداد شرایط پرس و جو از آن فراتر رفته است."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{نام! R} یک زمینه سفارشی معتبر نیست."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "حداکثر عمق تودرتویی بیش از حد مجاز است."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "زمینه سفارشی یافت نشد"
@@ -1338,48 +1338,48 @@ msgstr "گردش کار"
msgid "workflow runs"
msgstr "گردش کار اجرا می شود"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "رنگ نامعتبر"
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "متغیر نامعتبر شناسایی شده است."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1634,36 +1634,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Finnish\n"
"Language: fi_FI\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Asiakirjat"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "Arvon on oltava kelvollista JSON:ia."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr ""
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr ""
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr ""
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr ""
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr ""
@@ -1338,48 +1338,48 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Virheellinen väri."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Tiedostotyyppiä %(type)s ei tueta"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Virheellinen muuttuja havaittu."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1635,36 +1635,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "Documents"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "La valeur doit être un JSON valide."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "Requête de champ personnalisé invalide"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "Liste d'expressions invalide. Doit être non vide."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "Opérateur logique {op!r} invalide"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "Nombre maximum de conditions dans la requête dépassé."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} n'est pas un champ personnalisé valide."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} ne supporte pas l'expression {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "Profondeur de récursion maximale dépassée."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "Champ personnalisé non trouvé"
@@ -1338,48 +1338,48 @@ msgstr "exécution du workflow"
msgid "workflow runs"
msgstr "le flux de travail s'exécute"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr "Droits insuffisants."
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "Couleur incorrecte."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "Type de fichier %(type)s non pris en charge"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr "L'id du champ personnalisé doit être un entier : %(id)s"
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr "Le champ personnalisé avec l'id %(id)s n'existe pas"
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "Les champs personnalisés doivent être une liste d'entiers ou un mappage d'identifiants à des valeurs."
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr "Certains champs personnalisés n'existent pas ou ont été spécifiés deux fois."
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "Variable invalide détectée."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr "Les identificateurs de document en double ne sont pas autorisés."
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr "Documents introuvables : %(ids)s"
@@ -1634,36 +1634,36 @@ msgstr "Le schéma d'URI « {parts.scheme} » n'est pas autorisé. Schémas aut
msgid "Unable to parse URI {value}"
msgstr "Impossible d'analyser l'URI {value}"
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr "More_like_id invalide"
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr "Configuration IA invalide."
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr "La requête d'arrière-plan IA a expiré."
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "Spécifiez seulement un texte, titre, recherche ou more_like_id."
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr "Droits d'accès insuffisant pour partager %(id)s document."
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr "Le paquet est déjà en cours de traitement."
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Le lot de liens de partage est en cours de préparation. Veuillez réessayer plus tard."
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr "Le lot de liens de partage n'est pas disponible."
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Hebrew\n"
"Language: he_IL\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "מסמכים"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "ערך חייב להיות JSON תקין."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "ביטוי שאילתה לא חוקי של שדה מותאם אישית"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "רשימת ביטויים לא חוקית. חייב לכלול ערך."
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "סימן פעולה לוגית לא חוקי {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "חריגה ממספר תנאי השאילתה המרבי."
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} הוא לא שדה מותאם אישית חוקי."
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} לא תומך בביטוי שאילתה {expr!r}."
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "חריגה מעומק הקינון המרבי."
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "שדה מותאם אישית לא נמצא"
@@ -1339,48 +1339,48 @@ msgstr "הרצת זרימת עבודה"
msgid "workflow runs"
msgstr "הרצות זרימת עבודה"
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr "הרשאות אינן מספיקות."
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr "צבע לא חוקי."
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr "סוג קובץ %(type)s לא נתמך"
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr "שדה מותאם אישית id חייב להיות מספרי: %(id)s"
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr "שדה מותאם אישית עם מזהה %(id)s איננו קיים"
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "שדות מותאמים אישית חייבים להיות רשימה של מספרים שלמים או אובייקט הממפה מזהים לערכים."
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr "חלק מהשדות המותאמים אישית אינם קיימים או שהוגדרו פעמיים."
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr "משתנה לא חוקי זוהה."
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr "מזהי מסמכים כפולים אינם מורשים."
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr "מסמכים לא נמצאו: %(ids)s"
@@ -1636,36 +1636,36 @@ msgstr "פרוטוקול ה-URI '{parts.scheme}' אינו מורשה. הפר
msgid "Unable to parse URI {value}"
msgstr "לא ניתן לפענח את ה URI {value}"
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr "מזהה more_like_id אינו תקין"
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr "הגדרות בינה מלאכותית שגויות."
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "יש לציין רק אחד מהבאים: text, title_search, query או more_like_id."
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr "הרשאות לא מספיקות לשיתוף מסמך %(id)s."
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr "החבילה (Bundle) כבר נמצאת בתהליך עיבוד."
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "חבילת קישור השיתוף עדיין בהכנה. נא לנסות שוב מאוחר יותר."
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr "חבילת קישור השיתוף אינה זמינה."
+30 -30
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2026-08-08 14:29\n"
"POT-Creation-Date: 2026-07-31 16:14+0000\n"
"PO-Revision-Date: 2026-07-31 16:15\n"
"Last-Translator: \n"
"Language-Team: Hindi\n"
"Language: hi_IN\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr "दस्तावेज़"
#: documents/filters.py:471
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr "मान वैध JSON होना चाहिए."
#: documents/filters.py:490
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr "अमान्य कस्टम फ़ील्ड क्वेरी एक्सप्रेशन"
#: documents/filters.py:500
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr "अमान्य एक्सप्रेशन सूची। खाली नहीं होनी चाहिए।"
#: documents/filters.py:521
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr "अमान्य लॉजिकल ऑपरेटर {op!r}"
#: documents/filters.py:535
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr "क्वेरी शर्तों की अधिकतम संख्या पार हो गई है।"
#: documents/filters.py:599
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr "{name!r} यह एक वैध कस्टम फ़ील्ड नहीं है।"
#: documents/filters.py:636
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr "{data_type} क्वेरी एक्सप्रेशन {expr!r} का समर्थन नहीं करता है।"
#: documents/filters.py:755 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr "अधिकतम नेस्टिंग डेप्थ पार हो गई है।"
#: documents/filters.py:1073
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr "कस्टम फ़ील्ड नहीं मिला"
@@ -1338,48 +1338,48 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:299 documents/views.py:2555
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2776 documents/views.py:299 documents/views.py:2558
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:709
#: documents/serialisers.py:710
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:2244
#: documents/serialisers.py:2248
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:2288
#: documents/serialisers.py:2292
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2295
#: documents/serialisers.py:2299
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2312 documents/serialisers.py:2322
#: documents/serialisers.py:2316 documents/serialisers.py:2326
msgid "Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2317
#: documents/serialisers.py:2321
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2464
#: documents/serialisers.py:2468
msgid "Invalid variable detected."
msgstr ""
#: documents/serialisers.py:2823
#: documents/serialisers.py:2832
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4509
#: documents/serialisers.py:2862 documents/views.py:4517
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1634,36 +1634,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:292 documents/views.py:2552
#: documents/views.py:292 documents/views.py:2555
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1566
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1575
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2377 documents/views.py:2698
#: documents/views.py:2380 documents/views.py:2701
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4522
#: documents/views.py:4529
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4568
#: documents/views.py:4575
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4636
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4646
msgid "The share link bundle is unavailable."
msgstr ""

Some files were not shown because too many files have changed in this diff Show More