mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-24 02:05:48 -06:00
Compare commits
14 Commits
v2.19.3
...
42e77ae614
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42e77ae614 | ||
|
|
e9511bd3da | ||
|
|
8b9ca75a90 | ||
|
|
9f0a4ac19d | ||
|
|
8f969ecab5 | ||
|
|
245e52a4eb | ||
|
|
a8c75d95d8 | ||
|
|
d6e2456baf | ||
|
|
3b75d3271e | ||
|
|
e88816d141 | ||
|
|
e5bd4713ac | ||
|
|
b9aced07fb | ||
|
|
6b55740f56 | ||
|
|
9aee063347 |
@@ -294,6 +294,9 @@ 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.
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 2.19.3
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215))
|
||||||
|
- Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214))
|
||||||
|
- Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208))
|
||||||
|
- Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196))
|
||||||
|
- Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167))
|
||||||
|
- Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157))
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209))
|
||||||
|
- Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>9 changes</summary>
|
||||||
|
|
||||||
|
- Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200))
|
||||||
|
- Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215))
|
||||||
|
- Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214))
|
||||||
|
- Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208))
|
||||||
|
- Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209))
|
||||||
|
- Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194))
|
||||||
|
- Chore: Minor migration optimization for workflow titles [@stumpylog](https://github.com/stumpylog) ([#11197](https://github.com/paperless-ngx/paperless-ngx/pull/11197))
|
||||||
|
- Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196))
|
||||||
|
- Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167))
|
||||||
|
- Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157))
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 2.19.2
|
## paperless-ngx 2.19.2
|
||||||
|
|
||||||
### Features / Enhancements
|
### Features / Enhancements
|
||||||
|
|||||||
@@ -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">36</context>
|
<context context-type="linenumber">39</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">1455</context>
|
<context context-type="linenumber">1460</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">1463</context>
|
<context context-type="linenumber">1472</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">1528</context>
|
<context context-type="linenumber">1537</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">1532</context>
|
<context context-type="linenumber">1541</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4958946940233632319" datatype="html">
|
<trans-unit id="4958946940233632319" datatype="html">
|
||||||
|
|||||||
@@ -29,14 +29,19 @@
|
|||||||
|
|
||||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||||
|
|
||||||
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
<cdk-virtual-scroll-viewport
|
||||||
|
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>
|
||||||
}
|
}
|
||||||
@for (log of logs; track $index) {
|
<p *cdkVirtualFor="let log of logs"
|
||||||
<p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p>
|
class="m-0 p-0"
|
||||||
}
|
[ngClass]="'log-entry-' + log.level">
|
||||||
</div>
|
{{log.message}}
|
||||||
|
</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: 70px;
|
top: 0;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
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'
|
||||||
@@ -38,6 +43,9 @@ describe('LogsComponent', () => {
|
|||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
LogsComponent,
|
LogsComponent,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
|
CommonModule,
|
||||||
|
CdkVirtualScrollViewport,
|
||||||
|
ScrollingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
@@ -54,7 +62,6 @@ 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()
|
||||||
})
|
})
|
||||||
@@ -83,6 +90,10 @@ 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,7 +1,11 @@
|
|||||||
|
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,
|
||||||
@@ -21,8 +25,11 @@ 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
|
||||||
@@ -32,7 +39,7 @@ export class LogsComponent
|
|||||||
private logService = inject(LogService)
|
private logService = inject(LogService)
|
||||||
private changedetectorRef = inject(ChangeDetectorRef)
|
private changedetectorRef = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
public logs: string[] = []
|
public logs: Array<{ message: string; level: number }> = []
|
||||||
|
|
||||||
public logFiles: string[] = []
|
public logFiles: string[] = []
|
||||||
|
|
||||||
@@ -40,7 +47,7 @@ export class LogsComponent
|
|||||||
|
|
||||||
public autoRefreshEnabled: boolean = true
|
public autoRefreshEnabled: boolean = true
|
||||||
|
|
||||||
@ViewChild('logContainer') logContainer: ElementRef
|
@ViewChild('logContainer') logContainer: CdkVirtualScrollViewport
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.logService
|
this.logService
|
||||||
@@ -75,7 +82,7 @@ export class LogsComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.logs = result
|
this.logs = this.parseLogsWithLevel(result)
|
||||||
this.loading = false
|
this.loading = false
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
@@ -100,12 +107,19 @@ 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()
|
||||||
this.logContainer?.nativeElement.scroll({
|
if (this.logContainer) {
|
||||||
top: this.logContainer.nativeElement.scrollHeight,
|
this.logContainer.scrollToIndex(this.logs.length - 1)
|
||||||
left: 0,
|
}
|
||||||
behavior: 'auto',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,12 @@
|
|||||||
<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,6 +1489,8 @@ 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')
|
||||||
|
|
||||||
@@ -1512,65 +1514,97 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show error toast if printing throws inside iframe', fakeAsync(() => {
|
const iframePrintErrorCases: Array<{
|
||||||
initNormally()
|
description: string
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const appendChildSpy = jest
|
iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => {
|
||||||
.spyOn(document.body, 'appendChild')
|
it(
|
||||||
.mockImplementation((node: Node) => node)
|
description,
|
||||||
const removeChildSpy = jest
|
fakeAsync(() => {
|
||||||
.spyOn(document.body, 'removeChild')
|
initNormally()
|
||||||
.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 appendChildSpy = jest
|
||||||
|
.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 mockContentWindow = {
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
focus: jest.fn().mockImplementation(() => {
|
|
||||||
throw new Error('focus failed')
|
|
||||||
}),
|
|
||||||
print: jest.fn(),
|
|
||||||
onafterprint: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockIframe: any = {
|
const mockContentWindow = {
|
||||||
style: {},
|
focus: jest.fn().mockImplementation(() => {
|
||||||
src: '',
|
throw thrownError
|
||||||
onload: null,
|
}),
|
||||||
contentWindow: mockContentWindow,
|
print: jest.fn(),
|
||||||
}
|
onafterprint: null,
|
||||||
|
}
|
||||||
|
|
||||||
const createElementSpy = jest
|
const mockIframe: any = {
|
||||||
.spyOn(document, 'createElement')
|
style: {},
|
||||||
.mockReturnValue(mockIframe as any)
|
src: '',
|
||||||
|
onload: null,
|
||||||
|
contentWindow: mockContentWindow,
|
||||||
|
}
|
||||||
|
|
||||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
const createElementSpy = jest
|
||||||
component.printDocument()
|
.spyOn(document, 'createElement')
|
||||||
|
.mockReturnValue(mockIframe as any)
|
||||||
|
|
||||||
const req = httpTestingController.expectOne(
|
const blob = new Blob(['test'], { type: 'application/pdf' })
|
||||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
component.printDocument()
|
||||||
|
|
||||||
|
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 } from 'rxjs'
|
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -1428,6 +1428,37 @@ 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,
|
||||||
@@ -1452,9 +1483,18 @@ export class DocumentDetailComponent
|
|||||||
URL.revokeObjectURL(blobUrl)
|
URL.revokeObjectURL(blobUrl)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.toastService.showError($localize`Print failed.`, err)
|
// FF throws cross-origin error on onafterprint
|
||||||
document.body.removeChild(iframe)
|
const isCrossOriginAfterPrintError =
|
||||||
URL.revokeObjectURL(blobUrl)
|
err instanceof DOMException &&
|
||||||
|
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,9 +73,14 @@ describe('TagListComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should filter out child tags if name filter is empty, otherwise show all', () => {
|
it('should omit matching children from top level when their parent is present', () => {
|
||||||
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 },
|
||||||
]
|
]
|
||||||
@@ -86,7 +91,13 @@ 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(3)
|
expect(filteredWithName.length).toBe(2)
|
||||||
|
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,9 +69,13 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filterData(data: Tag[]) {
|
filterData(data: Tag[]) {
|
||||||
return this.nameFilter?.length
|
if (!this.nameFilter?.length) {
|
||||||
? [...data]
|
return data.filter((tag) => !tag.parent)
|
||||||
: 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,6 +132,7 @@ import {
|
|||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
|
unlock,
|
||||||
upcScan,
|
upcScan,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
@@ -346,6 +347,7 @@ const icons = {
|
|||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
|
unlock,
|
||||||
upcScan,
|
upcScan,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
|
|||||||
@@ -644,6 +644,77 @@ 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,6 +1400,7 @@ class BulkEditSerializer(
|
|||||||
"split",
|
"split",
|
||||||
"delete_pages",
|
"delete_pages",
|
||||||
"edit_pdf",
|
"edit_pdf",
|
||||||
|
"remove_password",
|
||||||
],
|
],
|
||||||
label="Method",
|
label="Method",
|
||||||
write_only=True,
|
write_only=True,
|
||||||
@@ -1475,6 +1476,8 @@ 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.")
|
||||||
@@ -1671,6 +1674,12 @@ 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"]
|
||||||
@@ -1711,6 +1720,8 @@ 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,3 +1,4 @@
|
|||||||
|
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
|
||||||
@@ -8,6 +9,9 @@ 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,6 +50,7 @@ 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
|
||||||
@@ -1462,6 +1463,7 @@ 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,)
|
||||||
@@ -1480,6 +1482,7 @@ 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
|
||||||
|
|
||||||
@@ -1508,6 +1511,7 @@ 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 (
|
||||||
@@ -1524,7 +1528,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 == bulk_edit.edit_pdf
|
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
||||||
and not parameters["update_document"]
|
and not parameters["update_document"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -2402,6 +2406,7 @@ 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",
|
||||||
|
|||||||
Reference in New Issue
Block a user