mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-11-01 04:06:16 -05:00
Compare commits
1 Commits
feature-pw
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4918793d0a |
@@ -294,9 +294,6 @@ The following methods are supported:
|
|||||||
- `"delete_original": true` to delete the original documents after editing.
|
- `"delete_original": true` to delete the original documents after editing.
|
||||||
- `"update_document": true` to update the existing document with the edited PDF.
|
- `"update_document": true` to update the existing document with the edited PDF.
|
||||||
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
||||||
- `remove_password`
|
|
||||||
- Requires `parameters`:
|
|
||||||
- `"password": "PASSWORD_STRING"` The password to remove from the PDF documents.
|
|
||||||
- `merge`
|
- `merge`
|
||||||
- No additional `parameters` required.
|
- No additional `parameters` required.
|
||||||
- The ordering of the merged document is determined by the list of IDs.
|
- The ordering of the merged document is determined by the list of IDs.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ dependencies = [
|
|||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"djangorestframework-guardian~=0.4.0",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular~=0.28",
|
||||||
"drf-spectacular-sidecar~=2025.9.1",
|
"drf-spectacular-sidecar~=2025.10.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.20.0",
|
"filelock~=3.20.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
|
|||||||
@@ -691,7 +691,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
||||||
<context context-type="linenumber">39</context>
|
<context context-type="linenumber">36</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
@@ -7259,25 +7259,25 @@
|
|||||||
<source>Print failed.</source>
|
<source>Print failed.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1460</context>
|
<context context-type="linenumber">1455</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6457245677384603573" datatype="html">
|
<trans-unit id="6457245677384603573" datatype="html">
|
||||||
<source>Error loading document for printing.</source>
|
<source>Error loading document for printing.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1472</context>
|
<context context-type="linenumber">1463</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6085793215710522488" datatype="html">
|
<trans-unit id="6085793215710522488" datatype="html">
|
||||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1537</context>
|
<context context-type="linenumber">1528</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1541</context>
|
<context context-type="linenumber">1532</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4958946940233632319" datatype="html">
|
<trans-unit id="4958946940233632319" datatype="html">
|
||||||
|
|||||||
@@ -29,19 +29,14 @@
|
|||||||
|
|
||||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||||
|
|
||||||
<cdk-virtual-scroll-viewport
|
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
||||||
itemSize="20"
|
|
||||||
class="bg-dark p-3 text-light font-monospace log-container"
|
|
||||||
#logContainer>
|
|
||||||
@if (loading && logFiles.length) {
|
@if (loading && logFiles.length) {
|
||||||
<div>
|
<div>
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<p *cdkVirtualFor="let log of logs"
|
@for (log of logs; track $index) {
|
||||||
class="m-0 p-0"
|
<p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p>
|
||||||
[ngClass]="'log-entry-' + log.level">
|
}
|
||||||
{{log.message}}
|
</div>
|
||||||
</p>
|
|
||||||
</cdk-virtual-scroll-viewport>
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
.log-container {
|
.log-container {
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
height: calc(100vh - 200px);
|
height: calc(100vh - 200px);
|
||||||
top: 0;
|
top: 70px;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import {
|
|
||||||
CdkVirtualScrollViewport,
|
|
||||||
ScrollingModule,
|
|
||||||
} from '@angular/cdk/scrolling'
|
|
||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
@@ -43,9 +38,6 @@ describe('LogsComponent', () => {
|
|||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
LogsComponent,
|
LogsComponent,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
CommonModule,
|
|
||||||
CdkVirtualScrollViewport,
|
|
||||||
ScrollingModule,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
@@ -62,6 +54,7 @@ describe('LogsComponent', () => {
|
|||||||
fixture = TestBed.createComponent(LogsComponent)
|
fixture = TestBed.createComponent(LogsComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
reloadSpy = jest.spyOn(component, 'reloadLogs')
|
reloadSpy = jest.spyOn(component, 'reloadLogs')
|
||||||
|
window.HTMLElement.prototype.scroll = function () {} // mock scroll
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
@@ -90,10 +83,6 @@ describe('LogsComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should auto refresh, allow toggle', () => {
|
it('should auto refresh, allow toggle', () => {
|
||||||
jest
|
|
||||||
.spyOn(CdkVirtualScrollViewport.prototype, 'scrollToIndex')
|
|
||||||
.mockImplementation(() => undefined)
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(6000)
|
jest.advanceTimersByTime(6000)
|
||||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import {
|
|
||||||
CdkVirtualScrollViewport,
|
|
||||||
ScrollingModule,
|
|
||||||
} from '@angular/cdk/scrolling'
|
|
||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import {
|
import {
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
|
ElementRef,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
@@ -25,11 +21,8 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
imports: [
|
imports: [
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
CdkVirtualScrollViewport,
|
|
||||||
ScrollingModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class LogsComponent
|
export class LogsComponent
|
||||||
@@ -39,7 +32,7 @@ export class LogsComponent
|
|||||||
private logService = inject(LogService)
|
private logService = inject(LogService)
|
||||||
private changedetectorRef = inject(ChangeDetectorRef)
|
private changedetectorRef = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
public logs: Array<{ message: string; level: number }> = []
|
public logs: string[] = []
|
||||||
|
|
||||||
public logFiles: string[] = []
|
public logFiles: string[] = []
|
||||||
|
|
||||||
@@ -47,7 +40,7 @@ export class LogsComponent
|
|||||||
|
|
||||||
public autoRefreshEnabled: boolean = true
|
public autoRefreshEnabled: boolean = true
|
||||||
|
|
||||||
@ViewChild('logContainer') logContainer: CdkVirtualScrollViewport
|
@ViewChild('logContainer') logContainer: ElementRef
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.logService
|
this.logService
|
||||||
@@ -82,7 +75,7 @@ export class LogsComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.logs = this.parseLogsWithLevel(result)
|
this.logs = result
|
||||||
this.loading = false
|
this.loading = false
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
@@ -107,19 +100,12 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseLogsWithLevel(
|
|
||||||
logs: string[]
|
|
||||||
): Array<{ message: string; level: number }> {
|
|
||||||
return logs.map((log) => ({
|
|
||||||
message: log,
|
|
||||||
level: this.getLogLevel(log),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToBottom(): void {
|
scrollToBottom(): void {
|
||||||
this.changedetectorRef.detectChanges()
|
this.changedetectorRef.detectChanges()
|
||||||
if (this.logContainer) {
|
this.logContainer?.nativeElement.scroll({
|
||||||
this.logContainer.scrollToIndex(this.logs.length - 1)
|
top: this.logContainer.nativeElement.scrollHeight,
|
||||||
}
|
left: 0,
|
||||||
|
behavior: 'auto',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,12 +65,6 @@
|
|||||||
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||||
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (requiresPassword || password) {
|
|
||||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!userIsOwner || !password">
|
|
||||||
<i-bs name="unlock"></i-bs> <ng-container i18n>Remove Password</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1489,8 +1489,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
mockContentWindow.onafterprint(new Event('afterprint'))
|
mockContentWindow.onafterprint(new Event('afterprint'))
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(500)
|
|
||||||
|
|
||||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
|
||||||
@@ -1514,97 +1512,65 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const iframePrintErrorCases: Array<{
|
it('should show error toast if printing throws inside iframe', fakeAsync(() => {
|
||||||
description: string
|
initNormally()
|
||||||
thrownError: Error
|
|
||||||
expectToast: boolean
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
description: 'should show error toast if printing throws inside iframe',
|
|
||||||
thrownError: new Error('focus failed'),
|
|
||||||
expectToast: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'should suppress toast if cross-origin afterprint error occurs',
|
|
||||||
thrownError: new DOMException(
|
|
||||||
'Accessing onafterprint triggered a cross-origin violation',
|
|
||||||
'SecurityError'
|
|
||||||
),
|
|
||||||
expectToast: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => {
|
const appendChildSpy = jest
|
||||||
it(
|
.spyOn(document.body, 'appendChild')
|
||||||
description,
|
.mockImplementation((node: Node) => node)
|
||||||
fakeAsync(() => {
|
const removeChildSpy = jest
|
||||||
initNormally()
|
.spyOn(document.body, 'removeChild')
|
||||||
|
.mockImplementation((node: Node) => node)
|
||||||
|
const createObjectURLSpy = jest
|
||||||
|
.spyOn(URL, 'createObjectURL')
|
||||||
|
.mockReturnValue('blob:mock-url')
|
||||||
|
const revokeObjectURLSpy = jest
|
||||||
|
.spyOn(URL, 'revokeObjectURL')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
const appendChildSpy = jest
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
.spyOn(document.body, 'appendChild')
|
|
||||||
.mockImplementation((node: Node) => node)
|
|
||||||
const removeChildSpy = jest
|
|
||||||
.spyOn(document.body, 'removeChild')
|
|
||||||
.mockImplementation((node: Node) => node)
|
|
||||||
const createObjectURLSpy = jest
|
|
||||||
.spyOn(URL, 'createObjectURL')
|
|
||||||
.mockReturnValue('blob:mock-url')
|
|
||||||
const revokeObjectURLSpy = jest
|
|
||||||
.spyOn(URL, 'revokeObjectURL')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const mockContentWindow = {
|
||||||
|
focus: jest.fn().mockImplementation(() => {
|
||||||
|
throw new Error('focus failed')
|
||||||
|
}),
|
||||||
|
print: jest.fn(),
|
||||||
|
onafterprint: null,
|
||||||
|
}
|
||||||
|
|
||||||
const mockContentWindow = {
|
const mockIframe: any = {
|
||||||
focus: jest.fn().mockImplementation(() => {
|
style: {},
|
||||||
throw thrownError
|
src: '',
|
||||||
}),
|
onload: null,
|
||||||
print: jest.fn(),
|
contentWindow: mockContentWindow,
|
||||||
onafterprint: null,
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const mockIframe: any = {
|
const createElementSpy = jest
|
||||||
style: {},
|
.spyOn(document, 'createElement')
|
||||||
src: '',
|
.mockReturnValue(mockIframe as any)
|
||||||
onload: null,
|
|
||||||
contentWindow: mockContentWindow,
|
|
||||||
}
|
|
||||||
|
|
||||||
const createElementSpy = jest
|
const blob = new Blob(['test'], { type: 'application/pdf' })
|
||||||
.spyOn(document, 'createElement')
|
component.printDocument()
|
||||||
.mockReturnValue(mockIframe as any)
|
|
||||||
|
|
||||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
const req = httpTestingController.expectOne(
|
||||||
component.printDocument()
|
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
||||||
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
|
||||||
)
|
|
||||||
req.flush(blob)
|
|
||||||
|
|
||||||
tick()
|
|
||||||
|
|
||||||
if (mockIframe.onload) {
|
|
||||||
mockIframe.onload(new Event('load'))
|
|
||||||
}
|
|
||||||
|
|
||||||
tick(200)
|
|
||||||
|
|
||||||
if (expectToast) {
|
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
|
||||||
} else {
|
|
||||||
expect(toastSpy).not.toHaveBeenCalled()
|
|
||||||
}
|
|
||||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
|
||||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
|
||||||
|
|
||||||
createElementSpy.mockRestore()
|
|
||||||
appendChildSpy.mockRestore()
|
|
||||||
removeChildSpy.mockRestore()
|
|
||||||
createObjectURLSpy.mockRestore()
|
|
||||||
revokeObjectURLSpy.mockRestore()
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
})
|
req.flush(blob)
|
||||||
|
|
||||||
|
tick()
|
||||||
|
|
||||||
|
if (mockIframe.onload) {
|
||||||
|
mockIframe.onload(new Event('load'))
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
|
||||||
|
createElementSpy.mockRestore()
|
||||||
|
appendChildSpy.mockRestore()
|
||||||
|
removeChildSpy.mockRestore()
|
||||||
|
createObjectURLSpy.mockRestore()
|
||||||
|
revokeObjectURLSpy.mockRestore()
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
|||||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||||
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -1428,37 +1428,6 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
removePassword() {
|
|
||||||
if (this.requiresPassword || !this.password) {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Please enter the current password before attempting to remove it.`
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.networkActive = true
|
|
||||||
this.documentsService
|
|
||||||
.bulkEdit([this.document.id], 'remove_password', {
|
|
||||||
password: this.password,
|
|
||||||
})
|
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.toastService.showInfo(
|
|
||||||
$localize`Password removal operation for "${this.document.title}" will begin in the background.`
|
|
||||||
)
|
|
||||||
this.networkActive = false
|
|
||||||
this.openDocumentService.refreshDocument(this.documentId)
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
this.networkActive = false
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error executing password removal operation`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
printDocument() {
|
printDocument() {
|
||||||
const printUrl = this.documentsService.getDownloadUrl(
|
const printUrl = this.documentsService.getDownloadUrl(
|
||||||
this.document.id,
|
this.document.id,
|
||||||
@@ -1483,18 +1452,9 @@ export class DocumentDetailComponent
|
|||||||
URL.revokeObjectURL(blobUrl)
|
URL.revokeObjectURL(blobUrl)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// FF throws cross-origin error on onafterprint
|
this.toastService.showError($localize`Print failed.`, err)
|
||||||
const isCrossOriginAfterPrintError =
|
document.body.removeChild(iframe)
|
||||||
err instanceof DOMException &&
|
URL.revokeObjectURL(blobUrl)
|
||||||
err.message.includes('onafterprint')
|
|
||||||
if (!isCrossOriginAfterPrintError) {
|
|
||||||
this.toastService.showError($localize`Print failed.`, err)
|
|
||||||
}
|
|
||||||
timer(100).subscribe(() => {
|
|
||||||
// delay to avoid FF print failure
|
|
||||||
document.body.removeChild(iframe)
|
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,14 +73,9 @@ describe('TagListComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should omit matching children from top level when their parent is present', () => {
|
it('should filter out child tags if name filter is empty, otherwise show all', () => {
|
||||||
const tags = [
|
const tags = [
|
||||||
{
|
{ id: 1, name: 'Tag1', parent: null },
|
||||||
id: 1,
|
|
||||||
name: 'Tag1',
|
|
||||||
parent: null,
|
|
||||||
children: [{ id: 2, name: 'Tag2', parent: 1 }],
|
|
||||||
},
|
|
||||||
{ id: 2, name: 'Tag2', parent: 1 },
|
{ id: 2, name: 'Tag2', parent: 1 },
|
||||||
{ id: 3, name: 'Tag3', parent: null },
|
{ id: 3, name: 'Tag3', parent: null },
|
||||||
]
|
]
|
||||||
@@ -91,13 +86,7 @@ describe('TagListComponent', () => {
|
|||||||
|
|
||||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
||||||
const filteredWithName = component.filterData(tags as any)
|
const filteredWithName = component.filterData(tags as any)
|
||||||
expect(filteredWithName.length).toBe(2)
|
expect(filteredWithName.length).toBe(3)
|
||||||
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
|
|
||||||
expect(
|
|
||||||
filteredWithName
|
|
||||||
.find((t) => t.id === 1)
|
|
||||||
?.children?.some((c) => c.id === 2)
|
|
||||||
).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should request only parent tags when no name filter is applied', () => {
|
it('should request only parent tags when no name filter is applied', () => {
|
||||||
|
|||||||
@@ -69,13 +69,9 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filterData(data: Tag[]) {
|
filterData(data: Tag[]) {
|
||||||
if (!this.nameFilter?.length) {
|
return this.nameFilter?.length
|
||||||
return data.filter((tag) => !tag.parent)
|
? [...data]
|
||||||
}
|
: data.filter((tag) => !tag.parent)
|
||||||
|
|
||||||
// When filtering by name, exclude children if their parent is also present
|
|
||||||
const availableIds = new Set(data.map((tag) => tag.id))
|
|
||||||
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override getSelectableIDs(tags: Tag[]): number[] {
|
protected override getSelectableIDs(tags: Tag[]): number[] {
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ import {
|
|||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
unlock,
|
|
||||||
upcScan,
|
upcScan,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
@@ -347,7 +346,6 @@ const icons = {
|
|||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
unlock,
|
|
||||||
upcScan,
|
upcScan,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
|
|||||||
@@ -644,77 +644,6 @@ def edit_pdf(
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def remove_password(
|
|
||||||
doc_ids: list[int],
|
|
||||||
password: str,
|
|
||||||
*,
|
|
||||||
delete_original: bool = False,
|
|
||||||
update_document: bool = False,
|
|
||||||
include_metadata: bool = True,
|
|
||||||
user: User | None = None,
|
|
||||||
) -> Literal["OK"]:
|
|
||||||
"""
|
|
||||||
Remove password protection from PDF documents.
|
|
||||||
"""
|
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
for doc_id in doc_ids:
|
|
||||||
doc = Document.objects.get(id=doc_id)
|
|
||||||
try:
|
|
||||||
logger.info(
|
|
||||||
f"Attempting password removal from document {doc_ids[0]}",
|
|
||||||
)
|
|
||||||
with pikepdf.open(doc.source_path, password=password) as pdf:
|
|
||||||
temp_path = doc.source_path.with_suffix(".tmp.pdf")
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(temp_path)
|
|
||||||
|
|
||||||
if update_document:
|
|
||||||
# replace the original document with the unprotected one
|
|
||||||
temp_path.replace(doc.source_path)
|
|
||||||
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
|
|
||||||
doc.page_count = len(pdf.pages)
|
|
||||||
doc.save()
|
|
||||||
update_document_content_maybe_archive_file.delay(document_id=doc.id)
|
|
||||||
else:
|
|
||||||
consume_tasks = []
|
|
||||||
overrides = (
|
|
||||||
DocumentMetadataOverrides().from_document(doc)
|
|
||||||
if include_metadata
|
|
||||||
else DocumentMetadataOverrides()
|
|
||||||
)
|
|
||||||
if user is not None:
|
|
||||||
overrides.owner_id = user.id
|
|
||||||
|
|
||||||
filepath: Path = (
|
|
||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
|
||||||
/ f"{doc.id}_unprotected.pdf"
|
|
||||||
)
|
|
||||||
temp_path.replace(filepath)
|
|
||||||
consume_tasks.append(
|
|
||||||
consume_file.s(
|
|
||||||
ConsumableDocument(
|
|
||||||
source=DocumentSource.ConsumeFolder,
|
|
||||||
original_file=filepath,
|
|
||||||
),
|
|
||||||
overrides,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if delete_original:
|
|
||||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
|
||||||
else:
|
|
||||||
group(consume_tasks).delay()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Error removing password from document {doc.id}: {e}")
|
|
||||||
raise ValueError(
|
|
||||||
f"An error occurred while removing the password: {e}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def reflect_doclinks(
|
def reflect_doclinks(
|
||||||
document: Document,
|
document: Document,
|
||||||
field: CustomField,
|
field: CustomField,
|
||||||
|
|||||||
@@ -1400,7 +1400,6 @@ class BulkEditSerializer(
|
|||||||
"split",
|
"split",
|
||||||
"delete_pages",
|
"delete_pages",
|
||||||
"edit_pdf",
|
"edit_pdf",
|
||||||
"remove_password",
|
|
||||||
],
|
],
|
||||||
label="Method",
|
label="Method",
|
||||||
write_only=True,
|
write_only=True,
|
||||||
@@ -1476,8 +1475,6 @@ class BulkEditSerializer(
|
|||||||
return bulk_edit.delete_pages
|
return bulk_edit.delete_pages
|
||||||
elif method == "edit_pdf":
|
elif method == "edit_pdf":
|
||||||
return bulk_edit.edit_pdf
|
return bulk_edit.edit_pdf
|
||||||
elif method == "remove_password":
|
|
||||||
return bulk_edit.remove_password
|
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
# This will never happen as it is handled by the ChoiceField
|
# This will never happen as it is handled by the ChoiceField
|
||||||
raise serializers.ValidationError("Unsupported method.")
|
raise serializers.ValidationError("Unsupported method.")
|
||||||
@@ -1674,12 +1671,6 @@ class BulkEditSerializer(
|
|||||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_parameters_remove_password(self, parameters):
|
|
||||||
if "password" not in parameters:
|
|
||||||
raise serializers.ValidationError("password not specified")
|
|
||||||
if not isinstance(parameters["password"], str):
|
|
||||||
raise serializers.ValidationError("password must be a string")
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
method = attrs["method"]
|
method = attrs["method"]
|
||||||
parameters = attrs["parameters"]
|
parameters = attrs["parameters"]
|
||||||
@@ -1720,8 +1711,6 @@ class BulkEditSerializer(
|
|||||||
"Edit PDF method only supports one document",
|
"Edit PDF method only supports one document",
|
||||||
)
|
)
|
||||||
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
||||||
elif method == bulk_edit.remove_password:
|
|
||||||
self.validate_parameters_remove_password(parameters)
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from django.core.cache import cache
|
|
||||||
from pytest_httpx import HTTPXMock
|
from pytest_httpx import HTTPXMock
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
@@ -9,9 +8,6 @@ from paperless import version
|
|||||||
class TestApiRemoteVersion:
|
class TestApiRemoteVersion:
|
||||||
ENDPOINT = "/api/remote_version/"
|
ENDPOINT = "/api/remote_version/"
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
def test_remote_version_enabled_no_update_prefix(
|
def test_remote_version_enabled_no_update_prefix(
|
||||||
self,
|
self,
|
||||||
rest_api_client: APIClient,
|
rest_api_client: APIClient,
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ from django.utils.timezone import make_aware
|
|||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.cache import cache_control
|
from django.views.decorators.cache import cache_control
|
||||||
from django.views.decorators.cache import cache_page
|
|
||||||
from django.views.decorators.http import condition
|
from django.views.decorators.http import condition
|
||||||
from django.views.decorators.http import last_modified
|
from django.views.decorators.http import last_modified
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
@@ -1463,7 +1462,6 @@ class BulkEditView(PassUserMixin):
|
|||||||
"merge": None,
|
"merge": None,
|
||||||
"edit_pdf": "checksum",
|
"edit_pdf": "checksum",
|
||||||
"reprocess": "checksum",
|
"reprocess": "checksum",
|
||||||
"remove_password": "checksum",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
@@ -1482,7 +1480,6 @@ class BulkEditView(PassUserMixin):
|
|||||||
bulk_edit.split,
|
bulk_edit.split,
|
||||||
bulk_edit.merge,
|
bulk_edit.merge,
|
||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
bulk_edit.remove_password,
|
|
||||||
]:
|
]:
|
||||||
parameters["user"] = user
|
parameters["user"] = user
|
||||||
|
|
||||||
@@ -1511,7 +1508,6 @@ class BulkEditView(PassUserMixin):
|
|||||||
bulk_edit.rotate,
|
bulk_edit.rotate,
|
||||||
bulk_edit.delete_pages,
|
bulk_edit.delete_pages,
|
||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
bulk_edit.remove_password,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
@@ -1528,7 +1524,7 @@ class BulkEditView(PassUserMixin):
|
|||||||
and (
|
and (
|
||||||
method in [bulk_edit.split, bulk_edit.merge]
|
method in [bulk_edit.split, bulk_edit.merge]
|
||||||
or (
|
or (
|
||||||
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
method == bulk_edit.edit_pdf
|
||||||
and not parameters["update_document"]
|
and not parameters["update_document"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -2406,7 +2402,6 @@ class UiSettingsView(GenericAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(cache_page(60 * 15), name="dispatch")
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
get=extend_schema(
|
get=extend_schema(
|
||||||
description="Get the current version of the Paperless-NGX server",
|
description="Get the current version of the Paperless-NGX server",
|
||||||
|
|||||||
8
uv.lock
generated
8
uv.lock
generated
@@ -959,14 +959,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "drf-spectacular-sidecar"
|
name = "drf-spectacular-sidecar"
|
||||||
version = "2025.9.1"
|
version = "2025.10.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/51/e2/85a0b8dbed8631165a6b49b2aee57636da8e4e710c444566636ffd972a7b/drf_spectacular_sidecar-2025.9.1.tar.gz", hash = "sha256:da2aa45da48fff76de7a1e357b84d1eb0b9df40ca89ec19d5fe94ad1037bb3c8", size = 2420902, upload-time = "2025-09-01T11:23:24.156Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/e4/99cd1b1c8c69788bd6cb6a2459674f8c75728e79df23ac7beddd094bf805/drf_spectacular_sidecar-2025.10.1.tar.gz", hash = "sha256:506a5a21ce1ad7211c28acb4e2112e213f6dc095a2052ee6ed6db1ffe8eb5a7b", size = 2420998, upload-time = "2025-10-01T11:23:27.092Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/24/db59146ba89491fe1d44ca8aef239c94bf3c7fd41523976090f099430312/drf_spectacular_sidecar-2025.9.1-py3-none-any.whl", hash = "sha256:8e80625209b8a23ff27616db305b9ab71c2e2d1069dacd99720a9c11e429af50", size = 2440255, upload-time = "2025-09-01T11:23:22.822Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/87/70c67391e4ce68715d4dfae8dd33caeda2552af22f436ba55b8867a040fe/drf_spectacular_sidecar-2025.10.1-py3-none-any.whl", hash = "sha256:f1de343184d1a938179ce363d318258fe1e5f02f2f774625272364835f1c42bd", size = 2440241, upload-time = "2025-10-01T11:23:25.743Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2277,7 +2277,7 @@ requires-dist = [
|
|||||||
{ name = "djangorestframework", specifier = "~=3.16" },
|
{ name = "djangorestframework", specifier = "~=3.16" },
|
||||||
{ name = "djangorestframework-guardian", specifier = "~=0.4.0" },
|
{ name = "djangorestframework-guardian", specifier = "~=0.4.0" },
|
||||||
{ name = "drf-spectacular", specifier = "~=0.28" },
|
{ name = "drf-spectacular", specifier = "~=0.28" },
|
||||||
{ name = "drf-spectacular-sidecar", specifier = "~=2025.9.1" },
|
{ name = "drf-spectacular-sidecar", specifier = "~=2025.10.1" },
|
||||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||||
{ name = "filelock", specifier = "~=3.20.0" },
|
{ name = "filelock", specifier = "~=3.20.0" },
|
||||||
{ name = "flower", specifier = "~=2.0.1" },
|
{ name = "flower", specifier = "~=2.0.1" },
|
||||||
|
|||||||
Reference in New Issue
Block a user