From 6b55740f563c4d0973779dd40fd021fa8c4d0cab Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:02:00 -0700 Subject: [PATCH 01/19] Fix: de-deduplicate children in tag list when filtering (#11229) --- .../manage/tag-list/tag-list.component.spec.ts | 17 ++++++++++++++--- .../manage/tag-list/tag-list.component.ts | 10 +++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts index 91cf09264..0db84182b 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts @@ -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 = [ - { 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: 3, name: 'Tag3', parent: null }, ] @@ -86,7 +91,13 @@ describe('TagListComponent', () => { component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter 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', () => { diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts index bf5ad1b50..64ca121df 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts @@ -69,9 +69,13 @@ export class TagListComponent extends ManagementListComponent { } filterData(data: Tag[]) { - return this.nameFilter?.length - ? [...data] - : data.filter((tag) => !tag.parent) + if (!this.nameFilter?.length) { + return 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[] { From b9aced07fb336eb785f50c78a94b5dd4b2c96bb2 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:53:30 -0700 Subject: [PATCH 02/19] Chore: cache Github version check for 15 minutes (#11235) --- src/documents/tests/test_api_remote_version.py | 4 ++++ src/documents/views.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/documents/tests/test_api_remote_version.py b/src/documents/tests/test_api_remote_version.py index 721d29424..9ade7d2c3 100644 --- a/src/documents/tests/test_api_remote_version.py +++ b/src/documents/tests/test_api_remote_version.py @@ -1,3 +1,4 @@ +from django.core.cache import cache from pytest_httpx import HTTPXMock from rest_framework import status from rest_framework.test import APIClient @@ -8,6 +9,9 @@ from paperless import version class TestApiRemoteVersion: ENDPOINT = "/api/remote_version/" + def setup_method(self): + cache.clear() + def test_remote_version_enabled_no_update_prefix( self, rest_api_client: APIClient, diff --git a/src/documents/views.py b/src/documents/views.py index 9365a82c2..761cba4db 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -50,6 +50,7 @@ from django.utils.timezone import make_aware from django.utils.translation import get_language from django.views import View 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 last_modified from django.views.generic import TemplateView @@ -2402,6 +2403,7 @@ class UiSettingsView(GenericAPIView): ) +@method_decorator(cache_page(60 * 15), name="dispatch") @extend_schema_view( get=extend_schema( description="Get the current version of the Paperless-NGX server", From e5bd4713ac045c07eade1e1890e1f7a252a0b682 Mon Sep 17 00:00:00 2001 From: CanbiZ <47820557+MickLesk@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:34:53 -0700 Subject: [PATCH 03/19] Performance: use virtual scroll container and log level parsing for logs view (#11233) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .../components/admin/logs/logs.component.html | 15 ++++++--- .../components/admin/logs/logs.component.scss | 2 +- .../admin/logs/logs.component.spec.ts | 13 +++++++- .../components/admin/logs/logs.component.ts | 32 +++++++++++++------ 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src-ui/src/app/components/admin/logs/logs.component.html b/src-ui/src/app/components/admin/logs/logs.component.html index d6685d857..21df9f33b 100644 --- a/src-ui/src/app/components/admin/logs/logs.component.html +++ b/src-ui/src/app/components/admin/logs/logs.component.html @@ -29,14 +29,19 @@
-
+ @if (loading && logFiles.length) {
Loading...
} - @for (log of logs; track $index) { -

{{log}}

- } -
+

+ {{log.message}} +

+ diff --git a/src-ui/src/app/components/admin/logs/logs.component.scss b/src-ui/src/app/components/admin/logs/logs.component.scss index 834c8c1cb..56fd2e8f3 100644 --- a/src-ui/src/app/components/admin/logs/logs.component.scss +++ b/src-ui/src/app/components/admin/logs/logs.component.scss @@ -18,7 +18,7 @@ .log-container { overflow-y: scroll; height: calc(100vh - 200px); - top: 70px; + top: 0; p { white-space: pre-wrap; diff --git a/src-ui/src/app/components/admin/logs/logs.component.spec.ts b/src-ui/src/app/components/admin/logs/logs.component.spec.ts index 6e4adacfe..841fec44d 100644 --- a/src-ui/src/app/components/admin/logs/logs.component.spec.ts +++ b/src-ui/src/app/components/admin/logs/logs.component.spec.ts @@ -1,3 +1,8 @@ +import { + CdkVirtualScrollViewport, + ScrollingModule, +} from '@angular/cdk/scrolling' +import { CommonModule } from '@angular/common' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClientTesting } from '@angular/common/http/testing' import { ComponentFixture, TestBed } from '@angular/core/testing' @@ -38,6 +43,9 @@ describe('LogsComponent', () => { NgxBootstrapIconsModule.pick(allIcons), LogsComponent, PageHeaderComponent, + CommonModule, + CdkVirtualScrollViewport, + ScrollingModule, ], providers: [ provideHttpClient(withInterceptorsFromDi()), @@ -54,7 +62,6 @@ describe('LogsComponent', () => { fixture = TestBed.createComponent(LogsComponent) component = fixture.componentInstance reloadSpy = jest.spyOn(component, 'reloadLogs') - window.HTMLElement.prototype.scroll = function () {} // mock scroll jest.useFakeTimers() fixture.detectChanges() }) @@ -83,6 +90,10 @@ describe('LogsComponent', () => { }) it('should auto refresh, allow toggle', () => { + jest + .spyOn(CdkVirtualScrollViewport.prototype, 'scrollToIndex') + .mockImplementation(() => undefined) + jest.advanceTimersByTime(6000) expect(reloadSpy).toHaveBeenCalledTimes(2) diff --git a/src-ui/src/app/components/admin/logs/logs.component.ts b/src-ui/src/app/components/admin/logs/logs.component.ts index 4799b6125..488f9db26 100644 --- a/src-ui/src/app/components/admin/logs/logs.component.ts +++ b/src-ui/src/app/components/admin/logs/logs.component.ts @@ -1,7 +1,11 @@ +import { + CdkVirtualScrollViewport, + ScrollingModule, +} from '@angular/cdk/scrolling' +import { CommonModule } from '@angular/common' import { ChangeDetectorRef, Component, - ElementRef, OnDestroy, OnInit, ViewChild, @@ -21,8 +25,11 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading imports: [ PageHeaderComponent, NgbNavModule, + CommonModule, FormsModule, ReactiveFormsModule, + CdkVirtualScrollViewport, + ScrollingModule, ], }) export class LogsComponent @@ -32,7 +39,7 @@ export class LogsComponent private logService = inject(LogService) private changedetectorRef = inject(ChangeDetectorRef) - public logs: string[] = [] + public logs: Array<{ message: string; level: number }> = [] public logFiles: string[] = [] @@ -40,7 +47,7 @@ export class LogsComponent public autoRefreshEnabled: boolean = true - @ViewChild('logContainer') logContainer: ElementRef + @ViewChild('logContainer') logContainer: CdkVirtualScrollViewport ngOnInit(): void { this.logService @@ -75,7 +82,7 @@ export class LogsComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe({ next: (result) => { - this.logs = result + this.logs = this.parseLogsWithLevel(result) this.loading = false 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 { this.changedetectorRef.detectChanges() - this.logContainer?.nativeElement.scroll({ - top: this.logContainer.nativeElement.scrollHeight, - left: 0, - behavior: 'auto', - }) + if (this.logContainer) { + this.logContainer.scrollToIndex(this.logs.length - 1) + } } } From e88816d141748865d79ecd7c26810ac880b8c039 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 23:36:37 +0000 Subject: [PATCH 04/19] Auto translate strings --- src-ui/messages.xlf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index cf79d162f..d37fe50df 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -691,7 +691,7 @@ src/app/components/admin/logs/logs.component.html - 36 + 39 src/app/components/admin/tasks/tasks.component.html From 3b75d3271e77b6eee4be6ef028e58e01cf8d52f9 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:54:28 -0700 Subject: [PATCH 05/19] Fix: delay iframe DOM removal for print in FF --- .../document-detail.component.spec.ts | 4 ++++ .../document-detail/document-detail.component.ts | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 752df3b21..db537e1b2 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1489,6 +1489,8 @@ describe('DocumentDetailComponent', () => { mockContentWindow.onafterprint(new Event('afterprint')) } + tick(500) + expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') @@ -1563,6 +1565,8 @@ describe('DocumentDetailComponent', () => { mockIframe.onload(new Event('load')) } + tick(500) + expect(toastSpy).toHaveBeenCalled() expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 48ddf61e5..4e0d3259f 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -21,7 +21,7 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms' import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { DeviceDetectorService } from 'ngx-device-detector' -import { BehaviorSubject, Observable, of, Subject } from 'rxjs' +import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs' import { catchError, debounceTime, @@ -1448,13 +1448,19 @@ export class DocumentDetailComponent iframe.contentWindow.focus() iframe.contentWindow.print() iframe.contentWindow.onafterprint = () => { - document.body.removeChild(iframe) - URL.revokeObjectURL(blobUrl) + timer(500).subscribe(() => { + // delay to avoid print failure + document.body.removeChild(iframe) + URL.revokeObjectURL(blobUrl) + }) } } catch (err) { this.toastService.showError($localize`Print failed.`, err) - document.body.removeChild(iframe) - URL.revokeObjectURL(blobUrl) + timer(500).subscribe(() => { + // delay to avoid print failure + document.body.removeChild(iframe) + URL.revokeObjectURL(blobUrl) + }) } } }, From d6e2456bafe95629b97834ac2cc5330d21b7b7de Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:14:44 -0700 Subject: [PATCH 06/19] Update document-detail.component.ts --- .../document-detail.component.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 4e0d3259f..e7197b9b9 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -1448,16 +1448,23 @@ export class DocumentDetailComponent iframe.contentWindow.focus() iframe.contentWindow.print() iframe.contentWindow.onafterprint = () => { - timer(500).subscribe(() => { - // delay to avoid print failure + timer(100).subscribe(() => { + // delay to avoid FF print failure document.body.removeChild(iframe) URL.revokeObjectURL(blobUrl) }) } } catch (err) { - this.toastService.showError($localize`Print failed.`, err) - timer(500).subscribe(() => { - // delay to avoid print failure + const isCrossOriginAfterPrintError = + err instanceof DOMException && + (err.name === 'SecurityError' || + err.message.includes('onafterprint')) && + err.message.includes('cross-origin') + 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) }) From a8c75d95d8314dd8a5bb2ec2ea37a8d738a2a91c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:29:56 -0700 Subject: [PATCH 07/19] Update document-detail.component.ts --- .../document-detail/document-detail.component.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index e7197b9b9..3c692e496 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -1448,13 +1448,11 @@ export class DocumentDetailComponent iframe.contentWindow.focus() iframe.contentWindow.print() iframe.contentWindow.onafterprint = () => { - timer(100).subscribe(() => { - // delay to avoid FF print failure - document.body.removeChild(iframe) - URL.revokeObjectURL(blobUrl) - }) + document.body.removeChild(iframe) + URL.revokeObjectURL(blobUrl) } } catch (err) { + // FF throws cross-origin error on onafterprint const isCrossOriginAfterPrintError = err instanceof DOMException && (err.name === 'SecurityError' || From 245e52a4eb3732fffc2a040412d967a24969227d Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:59:49 -0700 Subject: [PATCH 08/19] Coverage --- .../document-detail.component.spec.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index db537e1b2..e1bc0bf2b 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1577,4 +1577,71 @@ describe('DocumentDetailComponent', () => { createObjectURLSpy.mockRestore() revokeObjectURLSpy.mockRestore() })) + + it('should suppress toast if cross-origin afterprint error occurs', fakeAsync(() => { + initNormally() + + 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 toastSpy = jest.spyOn(toastService, 'showError') + + const mockContentWindow = { + focus: jest.fn().mockImplementation(() => { + throw new DOMException( + 'Accessing onafterprint triggered a cross-origin violation', + 'SecurityError' + ) + }), + print: jest.fn(), + onafterprint: null, + } + + const mockIframe: any = { + style: {}, + src: '', + onload: null, + contentWindow: mockContentWindow, + } + + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(mockIframe as any) + + const blob = new Blob(['test'], { type: 'application/pdf' }) + 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) + + expect(toastSpy).not.toHaveBeenCalled() + expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') + + createElementSpy.mockRestore() + appendChildSpy.mockRestore() + removeChildSpy.mockRestore() + createObjectURLSpy.mockRestore() + revokeObjectURLSpy.mockRestore() + })) }) From 8f969ecab54f70df408fd07e12a661d488c8622e Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:24:44 -0700 Subject: [PATCH 09/19] Fix: delay iframe DOM removal for print in FF --- .../components/document-detail/document-detail.component.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 3c692e496..9c0c84592 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -1455,9 +1455,7 @@ export class DocumentDetailComponent // FF throws cross-origin error on onafterprint const isCrossOriginAfterPrintError = err instanceof DOMException && - (err.name === 'SecurityError' || - err.message.includes('onafterprint')) && - err.message.includes('cross-origin') + err.message.includes('onafterprint') if (!isCrossOriginAfterPrintError) { this.toastService.showError($localize`Print failed.`, err) } From 9f0a4ac19d0c45ccb4f431679ac81239c349e3eb Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:00:19 -0700 Subject: [PATCH 10/19] Sure sonar, consolidate --- .../document-detail.component.spec.ts | 195 +++++++----------- 1 file changed, 79 insertions(+), 116 deletions(-) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index e1bc0bf2b..dada60074 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1514,134 +1514,97 @@ describe('DocumentDetailComponent', () => { ) }) - it('should show error toast if printing throws inside iframe', fakeAsync(() => { - initNormally() + const iframePrintErrorCases: Array<{ + 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 - .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(() => {}) + iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => { + it( + description, + fakeAsync(() => { + initNormally() - 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 = { - focus: jest.fn().mockImplementation(() => { - throw new Error('focus failed') - }), - print: jest.fn(), - onafterprint: null, - } + const toastSpy = jest.spyOn(toastService, 'showError') - const mockIframe: any = { - style: {}, - src: '', - onload: null, - contentWindow: mockContentWindow, - } + const mockContentWindow = { + focus: jest.fn().mockImplementation(() => { + throw thrownError + }), + print: jest.fn(), + onafterprint: null, + } - const createElementSpy = jest - .spyOn(document, 'createElement') - .mockReturnValue(mockIframe as any) + const mockIframe: any = { + style: {}, + src: '', + onload: null, + contentWindow: mockContentWindow, + } - const blob = new Blob(['test'], { type: 'application/pdf' }) - component.printDocument() + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(mockIframe as any) - const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/${doc.id}/download/` - ) - req.flush(blob) + const blob = new Blob(['test'], { type: 'application/pdf' }) + component.printDocument() - tick() - - if (mockIframe.onload) { - mockIframe.onload(new Event('load')) - } - - tick(500) - - expect(toastSpy).toHaveBeenCalled() - expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) - expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') - - createElementSpy.mockRestore() - appendChildSpy.mockRestore() - removeChildSpy.mockRestore() - createObjectURLSpy.mockRestore() - revokeObjectURLSpy.mockRestore() - })) - - it('should suppress toast if cross-origin afterprint error occurs', fakeAsync(() => { - initNormally() - - 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 toastSpy = jest.spyOn(toastService, 'showError') - - const mockContentWindow = { - focus: jest.fn().mockImplementation(() => { - throw new DOMException( - 'Accessing onafterprint triggered a cross-origin violation', - 'SecurityError' + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/${doc.id}/download/` ) - }), - print: jest.fn(), - onafterprint: null, - } + req.flush(blob) - const mockIframe: any = { - style: {}, - src: '', - onload: null, - contentWindow: mockContentWindow, - } + tick() - const createElementSpy = jest - .spyOn(document, 'createElement') - .mockReturnValue(mockIframe as any) + if (mockIframe.onload) { + mockIframe.onload(new Event('load')) + } - const blob = new Blob(['test'], { type: 'application/pdf' }) - component.printDocument() + tick(200) - const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/${doc.id}/download/` + 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')) - } - - tick(200) - - expect(toastSpy).not.toHaveBeenCalled() - expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) - expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') - - createElementSpy.mockRestore() - appendChildSpy.mockRestore() - removeChildSpy.mockRestore() - createObjectURLSpy.mockRestore() - revokeObjectURLSpy.mockRestore() - })) + }) }) From e9511bd3daf636fb8ab90dc1469537253b6cb548 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 01:28:27 +0000 Subject: [PATCH 11/19] Auto translate strings --- src-ui/messages.xlf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index d37fe50df..c8f88f5ea 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -7259,25 +7259,25 @@ Print failed. src/app/components/document-detail/document-detail.component.ts - 1455 + 1460 Error loading document for printing. src/app/components/document-detail/document-detail.component.ts - 1463 + 1472 An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 1528 + 1537 src/app/components/document-detail/document-detail.component.ts - 1532 + 1541 From 4e64ca7ca60c8cd91f881f8bf4dbf0a867783ee6 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 1 Nov 2025 07:49:31 -0700 Subject: [PATCH 12/19] Chore: add max-height and overflow to processedmail error popover (#11252) --- .../processed-mail-dialog/processed-mail-dialog.component.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss index 6aadd8330..c87a5c3f6 100644 --- a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss +++ b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss @@ -1,5 +1,7 @@ ::ng-deep .popover { max-width: 350px; + max-height: 600px; + overflow: hidden; pre { white-space: pre-wrap; From a0d3527d206eeb7745c4c240f1ef6089dbcf77e1 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 1 Nov 2025 07:49:52 -0700 Subject: [PATCH 13/19] Fixhancement: truncate large logs, improve auto-scroll (#11239) --- .../components/admin/logs/logs.component.html | 20 +++++++++-- .../admin/logs/logs.component.spec.ts | 13 ++++++-- .../components/admin/logs/logs.component.ts | 33 ++++++++++++++++--- .../src/app/services/rest/log.service.spec.ts | 10 ++++++ src-ui/src/app/services/rest/log.service.ts | 12 +++++-- src/documents/tests/test_api_documents.py | 17 ++++++++++ src/documents/views.py | 25 +++++++++++++- 7 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src-ui/src/app/components/admin/logs/logs.component.html b/src-ui/src/app/components/admin/logs/logs.component.html index 21df9f33b..bdc80583f 100644 --- a/src-ui/src/app/components/admin/logs/logs.component.html +++ b/src-ui/src/app/components/admin/logs/logs.component.html @@ -3,9 +3,23 @@ i18n-title info="Review the log files for the application and for email checking." i18n-info> -
- - +
+
+ Show + + lines +
+
+ + +
diff --git a/src-ui/src/app/components/admin/logs/logs.component.spec.ts b/src-ui/src/app/components/admin/logs/logs.component.spec.ts index 841fec44d..728916830 100644 --- a/src-ui/src/app/components/admin/logs/logs.component.spec.ts +++ b/src-ui/src/app/components/admin/logs/logs.component.spec.ts @@ -67,7 +67,7 @@ describe('LogsComponent', () => { }) it('should display logs with first log initially', () => { - expect(logSpy).toHaveBeenCalledWith('paperless') + expect(logSpy).toHaveBeenCalledWith('paperless', 5000) fixture.detectChanges() expect(fixture.debugElement.nativeElement.textContent).toContain( paperless_logs[0] @@ -78,7 +78,7 @@ describe('LogsComponent', () => { fixture.debugElement .queryAll(By.directive(NgbNavLink))[1] .nativeElement.dispatchEvent(new MouseEvent('click')) - expect(logSpy).toHaveBeenCalledWith('mail') + expect(logSpy).toHaveBeenCalledWith('mail', 5000) }) it('should handle error with no logs', () => { @@ -101,4 +101,13 @@ describe('LogsComponent', () => { jest.advanceTimersByTime(6000) expect(reloadSpy).toHaveBeenCalledTimes(2) }) + + it('should debounce limit changes before reloading logs', () => { + const initialCalls = reloadSpy.mock.calls.length + component.onLimitChange(6000) + jest.advanceTimersByTime(299) + expect(reloadSpy).toHaveBeenCalledTimes(initialCalls) + jest.advanceTimersByTime(1) + expect(reloadSpy).toHaveBeenCalledTimes(initialCalls + 1) + }) }) diff --git a/src-ui/src/app/components/admin/logs/logs.component.ts b/src-ui/src/app/components/admin/logs/logs.component.ts index 488f9db26..68b88265d 100644 --- a/src-ui/src/app/components/admin/logs/logs.component.ts +++ b/src-ui/src/app/components/admin/logs/logs.component.ts @@ -13,7 +13,7 @@ import { } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap' -import { filter, takeUntil, timer } from 'rxjs' +import { Subject, debounceTime, filter, takeUntil, timer } from 'rxjs' import { LogService } from 'src/app/services/rest/log.service' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' @@ -47,9 +47,17 @@ export class LogsComponent public autoRefreshEnabled: boolean = true + public limit: number = 5000 + + private readonly limitChange$ = new Subject() + @ViewChild('logContainer') logContainer: CdkVirtualScrollViewport ngOnInit(): void { + this.limitChange$ + .pipe(debounceTime(300), takeUntil(this.unsubscribeNotifier)) + .subscribe(() => this.reloadLogs()) + this.logService .list() .pipe(takeUntil(this.unsubscribeNotifier)) @@ -75,16 +83,33 @@ export class LogsComponent super.ngOnDestroy() } + onLimitChange(limit: number): void { + this.limitChange$.next(limit) + } + reloadLogs() { this.loading = true this.logService - .get(this.activeLog) + .get(this.activeLog, this.limit) .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe({ next: (result) => { - this.logs = this.parseLogsWithLevel(result) this.loading = false - this.scrollToBottom() + const parsed = this.parseLogsWithLevel(result) + const hasChanges = + parsed.length !== this.logs.length || + parsed.some((log, idx) => { + const current = this.logs[idx] + return ( + !current || + current.message !== log.message || + current.level !== log.level + ) + }) + if (hasChanges) { + this.logs = parsed + this.scrollToBottom() + } }, error: () => { this.logs = [] diff --git a/src-ui/src/app/services/rest/log.service.spec.ts b/src-ui/src/app/services/rest/log.service.spec.ts index e3138b895..7eda9c4a3 100644 --- a/src-ui/src/app/services/rest/log.service.spec.ts +++ b/src-ui/src/app/services/rest/log.service.spec.ts @@ -49,4 +49,14 @@ describe('LogService', () => { ) expect(req.request.method).toEqual('GET') }) + + it('should pass limit param on logs get when provided', () => { + const id: string = 'mail' + const limit: number = 100 + subscription = service.get(id, limit).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/${id}/?limit=${limit}` + ) + expect(req.request.method).toEqual('GET') + }) }) diff --git a/src-ui/src/app/services/rest/log.service.ts b/src-ui/src/app/services/rest/log.service.ts index a836fa555..d07f7cd69 100644 --- a/src-ui/src/app/services/rest/log.service.ts +++ b/src-ui/src/app/services/rest/log.service.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http' +import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable, inject } from '@angular/core' import { Observable } from 'rxjs' import { environment } from 'src/environments/environment' @@ -13,7 +13,13 @@ export class LogService { return this.http.get(`${environment.apiBaseUrl}logs/`) } - get(id: string): Observable { - return this.http.get(`${environment.apiBaseUrl}logs/${id}/`) + get(id: string, limit?: number): Observable { + let params = new HttpParams() + if (limit !== undefined) { + params = params.set('limit', limit.toString()) + } + return this.http.get(`${environment.apiBaseUrl}logs/${id}/`, { + params, + }) } } diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 3f7b2c385..8145e4793 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -2250,6 +2250,23 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.data, ["test", "test2"]) + def test_get_log_with_limit(self): + log_data = "test1\ntest2\ntest3\n" + with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f: + f.write(log_data) + response = self.client.get("/api/logs/paperless/", {"limit": 2}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, ["test2", "test3"]) + + def test_get_log_with_invalid_limit(self): + log_data = "test1\ntest2\n" + with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f: + f.write(log_data) + response = self.client.get("/api/logs/paperless/", {"limit": "abc"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response = self.client.get("/api/logs/paperless/", {"limit": -5}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_invalid_regex_other_algorithm(self): for endpoint in ["correspondents", "tags", "document_types"]: response = self.client.post( diff --git a/src/documents/views.py b/src/documents/views.py index 761cba4db..ec347a553 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -6,6 +6,7 @@ import re import tempfile import zipfile from collections import defaultdict +from collections import deque from datetime import datetime from pathlib import Path from time import mktime @@ -70,6 +71,7 @@ from rest_framework import parsers from rest_framework import serializers from rest_framework.decorators import action from rest_framework.exceptions import NotFound +from rest_framework.exceptions import ValidationError from rest_framework.filters import OrderingFilter from rest_framework.filters import SearchFilter from rest_framework.generics import GenericAPIView @@ -1363,6 +1365,13 @@ class UnifiedSearchViewSet(DocumentViewSet): type=OpenApiTypes.STR, location=OpenApiParameter.PATH, ), + OpenApiParameter( + name="limit", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Return only the last N entries from the log file", + required=False, + ), ], responses={ (200, "application/json"): serializers.ListSerializer( @@ -1394,8 +1403,22 @@ class LogViewSet(ViewSet): if not log_file.is_file(): raise Http404 + limit_param = request.query_params.get("limit") + if limit_param is not None: + try: + limit = int(limit_param) + except (TypeError, ValueError): + raise ValidationError({"limit": "Must be a positive integer"}) + if limit < 1: + raise ValidationError({"limit": "Must be a positive integer"}) + else: + limit = None + with log_file.open() as f: - lines = [line.rstrip() for line in f.readlines()] + if limit is None: + lines = [line.rstrip() for line in f.readlines()] + else: + lines = [line.rstrip() for line in deque(f, maxlen=limit)] return Response(lines) From 6f52614817ddafe6b54b7c25b4cbd374514ef82f Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:53:03 +0000 Subject: [PATCH 14/19] Auto translate strings --- src-ui/messages.xlf | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index c8f88f5ea..13a71a2ea 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -672,11 +672,33 @@ 4 + + Show + + src/app/components/admin/logs/logs.component.html + 8 + + + src/app/components/document-list/document-list.component.html + 37 + + + src/app/components/manage/saved-views/saved-views.component.html + 52 + + + + lines + + src/app/components/admin/logs/logs.component.html + 17 + + Auto refresh src/app/components/admin/logs/logs.component.html - 8 + 21 src/app/components/admin/tasks/tasks.component.html @@ -687,11 +709,11 @@ Loading... src/app/components/admin/logs/logs.component.html - 24 + 38 src/app/components/admin/logs/logs.component.html - 39 + 53 src/app/components/admin/tasks/tasks.component.html @@ -7881,17 +7903,6 @@ 45 - - Show - - src/app/components/document-list/document-list.component.html - 37 - - - src/app/components/manage/saved-views/saved-views.component.html - 52 - - Sort From cffb9c34f0fd66f0ae0acfc678b3447f54b75435 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 1 Nov 2025 09:37:49 -0700 Subject: [PATCH 15/19] Chore: add headers for wikipedia CI tests (#11253) --- src/paperless_mail/tests/test_parsers_live.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/paperless_mail/tests/test_parsers_live.py b/src/paperless_mail/tests/test_parsers_live.py index e1febb1e5..2c3ef262a 100644 --- a/src/paperless_mail/tests/test_parsers_live.py +++ b/src/paperless_mail/tests/test_parsers_live.py @@ -53,6 +53,15 @@ class TestUrlCanary: Verify certain URLs are still available so testing is valid still """ + # Wikimedia rejects requests without a browser-like User-Agent header and returns 403. + _WIKIMEDIA_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36" + ), + } + def test_online_image_exception_on_not_available(self): """ GIVEN: @@ -70,6 +79,7 @@ class TestUrlCanary: with pytest.raises(httpx.HTTPStatusError) as exec_info: resp = httpx.get( "https://upload.wikimedia.org/wikipedia/en/f/f7/nonexistent.png", + headers=self._WIKIMEDIA_HEADERS, ) resp.raise_for_status() @@ -90,7 +100,10 @@ class TestUrlCanary: """ # Now check the URL used in samples/sample.html - resp = httpx.get("https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png") + resp = httpx.get( + "https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png", + headers=self._WIKIMEDIA_HEADERS, + ) resp.raise_for_status() From 74b10db02827cbda9fcda9ae565c21fb4262bb63 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 1 Nov 2025 12:49:05 -0700 Subject: [PATCH 16/19] Fix: improve legibility of processed mail error popover in light mode (#11258) --- .../processed-mail-dialog/processed-mail-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html index 604e3fd68..26b63fd56 100644 --- a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html +++ b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html @@ -68,7 +68,7 @@ -
+                  
                     {{ mail.error }}
                   
From ad45e3f747c5f6ec6b8a821505d5f291bef5b80c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 1 Nov 2025 13:13:39 -0700 Subject: [PATCH 17/19] Fix: respect fields parameter for created field (#11251) --- src/documents/serialisers.py | 2 +- src/documents/tests/test_api_documents.py | 29 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 25207bdfa..f04bb70da 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1041,7 +1041,7 @@ class DocumentSerializer( request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"], ) - if api_version < 9: + if api_version < 9 and "created" in self.fields: # provide created as a datetime for backwards compatibility from django.utils import timezone diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 8145e4793..f7f44c974 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -172,6 +172,35 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): results = response.data["results"] self.assertEqual(len(results[0]), 0) + def test_document_fields_api_version_8_respects_created(self): + Document.objects.create( + title="legacy", + checksum="123", + mime_type="application/pdf", + created=date(2024, 1, 15), + ) + + response = self.client.get( + "/api/documents/?fields=id", + headers={"Accept": "application/json; version=8"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = response.data["results"] + self.assertIn("id", results[0]) + self.assertNotIn("created", results[0]) + + response = self.client.get( + "/api/documents/?fields=id,created", + headers={"Accept": "application/json; version=8"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = response.data["results"] + self.assertIn("id", results[0]) + self.assertIn("created", results[0]) + self.assertRegex(results[0]["created"], r"^2024-01-15T00:00:00.*$") + def test_document_legacy_created_format(self): """ GIVEN: From 819f6063351d1ff8f263ead50b85c21798eef83f Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 1 Nov 2025 13:18:49 -0700 Subject: [PATCH 18/19] Chore: hide slim toggler if insufficient permissions --- .../app-frame/app-frame.component.html | 16 +++++++++------- .../components/app-frame/app-frame.component.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 7ec92cda8..673eaf03b 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -68,13 +68,15 @@