Merge branch 'dev' into feature-nested-tags2

This commit is contained in:
shamoon
2025-09-09 21:07:41 -07:00
157 changed files with 12998 additions and 10945 deletions

View File

@@ -61,6 +61,40 @@ const groups = [
{ id: 2, name: 'group2' },
]
const status: SystemStatus = {
pngx_version: '2.4.3',
server_os: 'macOS-14.1.1-arm64-arm-64bit',
install_type: InstallType.BareMetal,
storage: { total: 494384795648, available: 13573525504 },
database: {
type: 'sqlite',
url: '/paperless-ngx/data/db.sqlite3',
status: SystemStatusItemStatus.ERROR,
error: null,
migration_status: {
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
unapplied_migrations: [],
},
},
tasks: {
redis_url: 'redis://localhost:6379',
redis_status: SystemStatusItemStatus.ERROR,
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
sanity_check_status: SystemStatusItemStatus.ERROR,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: 'Error running sanity check.',
},
}
describe('SettingsComponent', () => {
let component: SettingsComponent
let fixture: ComponentFixture<SettingsComponent>
@@ -290,40 +324,6 @@ describe('SettingsComponent', () => {
})
it('should load system status on initialize, show errors if needed', () => {
const status: SystemStatus = {
pngx_version: '2.4.3',
server_os: 'macOS-14.1.1-arm64-arm-64bit',
install_type: InstallType.BareMetal,
storage: { total: 494384795648, available: 13573525504 },
database: {
type: 'sqlite',
url: '/paperless-ngx/data/db.sqlite3',
status: SystemStatusItemStatus.ERROR,
error: null,
migration_status: {
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
unapplied_migrations: [],
},
},
tasks: {
redis_url: 'redis://localhost:6379',
redis_status: SystemStatusItemStatus.ERROR,
redis_error:
'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
sanity_check_status: SystemStatusItemStatus.ERROR,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: 'Error running sanity check.',
},
}
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
completeSetup()
@@ -340,6 +340,8 @@ describe('SettingsComponent', () => {
it('should open system status dialog', () => {
const modalOpenSpy = jest.spyOn(modalService, 'open')
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
completeSetup()
component.showSystemStatus()
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {

View File

@@ -185,7 +185,8 @@ export class SettingsComponent
this.systemStatus.tasks.classifier_status ===
SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.sanity_check_status ===
SystemStatusItemStatus.ERROR
SystemStatusItemStatus.ERROR ||
this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR
)
}

View File

@@ -113,7 +113,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-1" name="funnel"></i-bs>
<span>&nbsp;<div class="d-inline-flex view-name"><span [class.text-truncate]="!slimSidebarEnabled">{{view.name}}</span></div>
<span>&nbsp;<div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
@if (showSidebarCounts && !slimSidebarEnabled) {
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
}
@@ -147,7 +147,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-1" name="file-text"></i-bs><span>&nbsp;{{d.title | documentTitle}}</span>
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
<i-bs name="x"></i-bs>
</span>
</a>

View File

@@ -191,7 +191,7 @@ main {
list-style-type: none;
&:hover .close {
display: block;
display: flex;
}
.close {

View File

@@ -287,6 +287,9 @@ export class AppFrameComponent
}
get showSidebarCounts(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)
return (
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
!this.settingsService.organizingSidebarSavedViews
)
}
}

View File

@@ -11,7 +11,7 @@
<div class="selected-icon">
@if (createdRelativeDate) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}

View File

@@ -28,6 +28,16 @@
</div>
}
</div>
@if (allSelectOptions.length > SELECT_OPTION_PAGE_SIZE) {
<ngb-pagination
class="d-flex justify-content-end"
[pageSize]="SELECT_OPTION_PAGE_SIZE"
[collectionSize]="allSelectOptions.length"
[(page)]="selectOptionsPage"
[maxSize]="5"
size="sm"
></ngb-pagination>
}
@if (object?.id) {
<small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small>
}

View File

@@ -125,4 +125,42 @@ describe('CustomFieldEditDialogComponent', () => {
fixture.detectChanges()
expect(document.activeElement).toBe(selectOptionInputs.last.nativeElement)
})
it('should send all select options including those changed in form on save', () => {
component.dialogMode = EditDialogMode.EDIT
component.object = {
id: 1,
name: 'Field 1',
data_type: CustomFieldDataType.Select,
extra_data: {
select_options: Array.from({ length: 50 }, (_, i) => ({
label: `Option ${i + 1}`,
id: `${i + 1}-xyz`,
})),
},
}
fixture.detectChanges()
component.ngOnInit()
component.selectOptionsPage = 2
fixture.detectChanges()
component.objectForm
.get('extra_data')
.get('select_options')
.get('0')
.get('label')
.setValue('Updated Option 9')
const formValues = (component as any).getFormValues()
// first item unchanged
expect(formValues.extra_data.select_options[0]).toEqual({
label: 'Option 1',
id: '1-xyz',
})
// page 2 first item updated
expect(
formValues.extra_data.select_options[component.SELECT_OPTION_PAGE_SIZE]
).toEqual({
label: 'Updated Option 9',
id: '9-xyz',
})
})
})

View File

@@ -14,6 +14,7 @@ import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs'
import {
@@ -28,6 +29,8 @@ import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component'
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
const SELECT_OPTION_PAGE_SIZE = 8
@Component({
selector: 'pngx-custom-field-edit-dialog',
templateUrl: './custom-field-edit-dialog.component.html',
@@ -37,6 +40,7 @@ import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
TextComponent,
FormsModule,
ReactiveFormsModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
],
})
@@ -45,6 +49,21 @@ export class CustomFieldEditDialogComponent
implements OnInit, AfterViewInit
{
CustomFieldDataType = CustomFieldDataType
SELECT_OPTION_PAGE_SIZE = SELECT_OPTION_PAGE_SIZE
private _allSelectOptions: any[] = []
public get allSelectOptions(): any[] {
return this._allSelectOptions
}
private _selectOptionsPage: number
public get selectOptionsPage(): number {
return this._selectOptionsPage
}
public set selectOptionsPage(v: number) {
this._selectOptionsPage = v
this.updateSelectOptions()
}
@ViewChildren('selectOption')
private selectOptionInputs: QueryList<ElementRef>
@@ -67,17 +86,10 @@ export class CustomFieldEditDialogComponent
this.objectForm.get('data_type').disable()
}
if (this.object?.data_type === CustomFieldDataType.Select) {
this.selectOptions.clear()
this.object.extra_data.select_options
.filter((option) => option)
.forEach((option) =>
this.selectOptions.push(
new FormGroup({
label: new FormControl(option.label),
id: new FormControl(option.id),
})
)
)
this._allSelectOptions = [
...(this.object.extra_data.select_options ?? []),
]
this.selectOptionsPage = 1
}
}
@@ -87,6 +99,19 @@ export class CustomFieldEditDialogComponent
.subscribe(() => {
this.selectOptionInputs.last?.nativeElement.focus()
})
this.objectForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((change) => {
// Update the relevant select options values if changed in the form, which is only a page of the entire list
this.objectForm
.get('extra_data.select_options')
?.value.forEach((option, index) => {
this._allSelectOptions[
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
] = option
})
})
}
getCreateTitle() {
@@ -108,6 +133,17 @@ export class CustomFieldEditDialogComponent
})
}
protected getFormValues() {
const formValues = super.getFormValues()
if (
this.objectForm.get('data_type')?.value === CustomFieldDataType.Select
) {
// Make sure we send all select options, with updated values
formValues.extra_data.select_options = this._allSelectOptions
}
return formValues
}
getDataTypes() {
return DATA_TYPE_LABELS
}
@@ -116,13 +152,35 @@ export class CustomFieldEditDialogComponent
return this.dialogMode === EditDialogMode.EDIT
}
private updateSelectOptions() {
this.selectOptions.clear()
this._allSelectOptions
.slice(
(this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
this.selectOptionsPage * SELECT_OPTION_PAGE_SIZE
)
.forEach((option) =>
this.selectOptions.push(
new FormGroup({
label: new FormControl(option.label),
id: new FormControl(option.id),
})
)
)
}
public addSelectOption() {
this.selectOptions.push(
new FormGroup({ label: new FormControl(null), id: new FormControl(null) })
this._allSelectOptions.push({ label: null, id: null })
this.selectOptionsPage = Math.ceil(
this.allSelectOptions.length / SELECT_OPTION_PAGE_SIZE
)
}
public removeSelectOption(index: number) {
this.selectOptions.removeAt(index)
this._allSelectOptions.splice(
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
1
)
}
}

View File

@@ -147,9 +147,13 @@ export abstract class EditDialogComponent<
)
}
protected getFormValues(): any {
return Object.assign({}, this.objectForm.value)
}
save() {
this.error = null
const formValues = Object.assign({}, this.objectForm.value)
const formValues = this.getFormValues()
const permissionsObject: PermissionsFormObject =
this.objectForm.get('permissions_form')?.value
if (permissionsObject) {

View File

@@ -254,6 +254,18 @@
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
}
</ng-template>
<dt i18n>WebSocket Connection</dt>
<dd>
<span class="btn btn-sm pe-none align-items-center btn-dark text-uppercase small">
@if (status.websocket_connected === 'OK') {
<ng-container i18n>OK</ng-container>
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<ng-container i18n>Error</ng-container>
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</span>
</dd>
</dl>
</div>
</div>

View File

@@ -24,7 +24,7 @@ import {
} from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { Subject, of, throwError } from 'rxjs'
import { PaperlessTaskName } from 'src/app/data/paperless-task'
import {
InstallType,
@@ -34,6 +34,7 @@ import {
import { SystemStatusService } from 'src/app/services/system-status.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { SystemStatusDialogComponent } from './system-status-dialog.component'
const status: SystemStatus = {
@@ -77,6 +78,8 @@ describe('SystemStatusDialogComponent', () => {
let tasksService: TasksService
let systemStatusService: SystemStatusService
let toastService: ToastService
let websocketStatusService: WebsocketStatusService
let websocketSubject: Subject<boolean> = new Subject<boolean>()
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -98,6 +101,12 @@ describe('SystemStatusDialogComponent', () => {
tasksService = TestBed.inject(TasksService)
systemStatusService = TestBed.inject(SystemStatusService)
toastService = TestBed.inject(ToastService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
jest
.spyOn(websocketStatusService, 'onConnectionStatus')
.mockImplementation(() => {
return websocketSubject.asObservable()
})
fixture.detectChanges()
})
@@ -168,4 +177,19 @@ describe('SystemStatusDialogComponent', () => {
component.ngOnInit()
expect(component.versionMismatch).toBeFalsy()
})
it('should update websocket connection status', () => {
websocketSubject.next(true)
expect(component.status.websocket_connected).toEqual(
SystemStatusItemStatus.OK
)
websocketSubject.next(false)
expect(component.status.websocket_connected).toEqual(
SystemStatusItemStatus.ERROR
)
websocketSubject.next(true)
expect(component.status.websocket_connected).toEqual(
SystemStatusItemStatus.OK
)
})
})

View File

@@ -1,5 +1,5 @@
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import {
NgbActiveModal,
NgbModalModule,
@@ -7,6 +7,7 @@ import {
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, takeUntil } from 'rxjs'
import { PaperlessTaskName } from 'src/app/data/paperless-task'
import {
SystemStatus,
@@ -18,6 +19,7 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
@Component({
@@ -34,13 +36,14 @@ import { environment } from 'src/environments/environment'
NgxBootstrapIconsModule,
],
})
export class SystemStatusDialogComponent implements OnInit {
export class SystemStatusDialogComponent implements OnInit, OnDestroy {
activeModal = inject(NgbActiveModal)
private clipboard = inject(Clipboard)
private systemStatusService = inject(SystemStatusService)
private tasksService = inject(TasksService)
private toastService = inject(ToastService)
private permissionsService = inject(PermissionsService)
private websocketStatusService = inject(WebsocketStatusService)
public SystemStatusItemStatus = SystemStatusItemStatus
public PaperlessTaskName = PaperlessTaskName
@@ -51,6 +54,7 @@ export class SystemStatusDialogComponent implements OnInit {
public copied: boolean = false
private runningTasks: Set<PaperlessTaskName> = new Set()
private unsubscribeNotifier: Subject<any> = new Subject()
get currentUserIsSuperUser(): boolean {
return this.permissionsService.isSuperUser()
@@ -65,6 +69,17 @@ export class SystemStatusDialogComponent implements OnInit {
if (this.versionMismatch) {
this.status.pngx_version = `${this.status.pngx_version} (frontend: ${this.frontendVersion})`
}
this.status.websocket_connected = this.websocketStatusService.isConnected()
? SystemStatusItemStatus.OK
: SystemStatusItemStatus.ERROR
this.websocketStatusService
.onConnectionStatus()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((connected) => {
this.status.websocket_connected = connected
? SystemStatusItemStatus.OK
: SystemStatusItemStatus.ERROR
})
}
public close() {
@@ -97,7 +112,7 @@ export class SystemStatusDialogComponent implements OnInit {
this.runningTasks.delete(taskName)
this.systemStatusService.get().subscribe({
next: (status) => {
this.status = status
Object.assign(this.status, status)
},
})
},
@@ -110,4 +125,9 @@ export class SystemStatusDialogComponent implements OnInit {
},
})
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
}

View File

@@ -106,6 +106,7 @@ describe('DashboardComponent', () => {
}),
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
allViews: saved_views,
setDocumentCount: jest.fn(),
},
},
provideHttpClient(withInterceptorsFromDi()),

View File

@@ -52,6 +52,7 @@ import {
} from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@@ -94,6 +95,7 @@ export class SavedViewWidgetComponent
permissionsService = inject(PermissionsService)
private settingsService = inject(SettingsService)
private customFieldService = inject(CustomFieldsService)
private savedViewService = inject(SavedViewService)
public DisplayMode = DisplayMode
public DisplayField = DisplayField
@@ -181,6 +183,7 @@ export class SavedViewWidgetComponent
this.show = true
this.documents = result.results
this.count = result.count
this.savedViewService.setDocumentCount(this.savedView, result.count)
}),
delay(500)
)

View File

@@ -452,6 +452,18 @@ describe('DocumentDetailComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
})
it('should navigate to 404 if error on load', () => {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
const navigateSpy = jest.spyOn(router, 'navigate')
jest
.spyOn(documentService, 'get')
.mockReturnValue(throwError(() => new Error('not found')))
fixture.detectChanges()
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
})
it('should support save, close and show success toast', () => {
initNormally()
component.title = 'Foo Bar'
@@ -1388,4 +1400,19 @@ describe('DocumentDetailComponent', () => {
component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled()
})
it('should set previewText', () => {
initNormally()
const previewText = 'Hello world, this is a test'
httpTestingController.expectOne(component.previewUrl).flush(previewText)
expect(component.previewText).toEqual(previewText)
})
it('should set previewText to error message if preview fails', () => {
initNormally()
httpTestingController
.expectOne(component.previewUrl)
.flush('fail', { status: 500, statusText: 'Server Error' })
expect(component.previewText).toContain('An error occurred loading content')
})
})

View File

@@ -21,8 +21,9 @@ 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, Subject } from 'rxjs'
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
import {
catchError,
debounceTime,
distinctUntilChanged,
filter,
@@ -327,19 +328,164 @@ export class DocumentDetailComponent
}
}
private mapDocToForm(doc: Document): any {
return {
...doc,
permissions_form: { owner: doc.owner, set_permissions: doc.permissions },
}
}
private mapFormToDoc(value: any): any {
const docValues = { ...value }
docValues['owner'] = value['permissions_form']?.owner
docValues['set_permissions'] = value['permissions_form']?.set_permissions
delete docValues['permissions_form']
return docValues
}
private prepareForm(doc: Document): void {
this.documentForm.reset(this.mapDocToForm(doc), { emitEvent: false })
if (!this.userCanEditDoc(doc)) {
this.documentForm.disable({ emitEvent: false })
} else {
this.documentForm.enable({ emitEvent: false })
}
if (doc.__changedFields) {
doc.__changedFields.forEach((field) => {
if (field === 'owner' || field === 'set_permissions') {
this.documentForm.get('permissions_form')?.markAsDirty()
} else {
this.documentForm.get(field)?.markAsDirty()
}
})
}
}
private setupDirtyTracking(
currentDocument: Document,
originalDocument: Document
): void {
this.store = new BehaviorSubject({
title: originalDocument.title,
content: originalDocument.content,
created: originalDocument.created,
correspondent: originalDocument.correspondent,
document_type: originalDocument.document_type,
storage_path: originalDocument.storage_path,
archive_serial_number: originalDocument.archive_serial_number,
tags: [...originalDocument.tags],
permissions_form: {
owner: originalDocument.owner,
set_permissions: originalDocument.permissions,
},
custom_fields: [...originalDocument.custom_fields],
})
this.isDirty$ = dirtyCheck(this.documentForm, this.store.asObservable())
this.isDirty$
.pipe(
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe((dirty) =>
this.openDocumentService.setDirty(
currentDocument,
dirty,
this.getChangedFields()
)
)
}
private loadDocument(documentId: number): void {
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.http
.get(this.previewUrl, { responseType: 'text' })
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (res) => (this.previewText = res.toString()),
error: (err) =>
(this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`),
})
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
this.documentsService
.get(documentId)
.pipe(
catchError(() => {
// 404 is handled in the subscribe below
return of(null)
}),
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (doc) => {
if (!doc) {
this.router.navigate(['404'], { replaceUrl: true })
return
}
this.documentId = doc.id
this.suggestions = null
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId
)
const useDoc = openDocument || doc
if (openDocument) {
if (
new Date(doc.modified) > new Date(openDocument.modified) &&
!this.modalService.hasOpenModals()
) {
const modal = this.modalService.open(ConfirmDialogComponent)
modal.componentInstance.title = $localize`Document changes detected`
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
modal.componentInstance.cancelBtnClass = 'visually-hidden'
modal.componentInstance.btnCaption = $localize`Ok`
modal.componentInstance.confirmClicked.subscribe(() =>
modal.close()
)
}
} else {
this.openDocumentService
.openDocument(doc)
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe()
}
this.updateComponent(useDoc)
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.docChangeNotifier),
takeUntil(this.unsubscribeNotifier)
)
.subscribe((titleValue) => {
if (titleValue !== this.titleInput.value) return
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
this.documentForm.get('title').markAsDirty()
})
this.setupDirtyTracking(useDoc, doc)
},
})
}
ngOnInit(): void {
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
.subscribe((values) => {
this.error = null
const docValues = Object.assign({}, this.documentForm.value)
docValues['owner'] =
this.documentForm.get('permissions_form').value['owner']
docValues['set_permissions'] =
this.documentForm.get('permissions_form').value['set_permissions']
delete docValues['permissions_form']
Object.assign(this.document, docValues)
Object.assign(this.document, this.mapFormToDoc(values))
})
if (
@@ -391,163 +537,36 @@ export class DocumentDetailComponent
this.route.paramMap
.pipe(
filter((paramMap) => {
// only init when changing docs & section is set
return (
filter(
(paramMap) =>
+paramMap.get('id') !== this.documentId &&
paramMap.get('section')?.length > 0
)
}),
takeUntil(this.unsubscribeNotifier),
switchMap((paramMap) => {
const documentId = +paramMap.get('id')
this.docChangeNotifier.next(documentId)
// Dont wait to get the preview
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.http.get(this.previewUrl, { responseType: 'text' }).subscribe({
next: (res) => {
this.previewText = res.toString()
},
error: (err) => {
this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`
},
})
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
return this.documentsService.get(documentId)
})
),
takeUntil(this.unsubscribeNotifier)
)
.pipe(
switchMap((doc) => {
this.documentId = doc.id
this.suggestions = null
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId
)
if (openDocument) {
if (
new Date(doc.modified) > new Date(openDocument.modified) &&
!this.modalService.hasOpenModals()
) {
let modal = this.modalService.open(ConfirmDialogComponent)
modal.componentInstance.title = $localize`Document changes detected`
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
modal.componentInstance.cancelBtnClass = 'visually-hidden'
modal.componentInstance.btnCaption = $localize`Ok`
modal.componentInstance.confirmClicked.subscribe(() =>
modal.close()
)
}
if (this.documentForm.dirty) {
Object.assign(openDocument, this.documentForm.value)
openDocument['owner'] =
this.documentForm.get('permissions_form').value['owner']
openDocument['permissions'] =
this.documentForm.get('permissions_form').value[
'set_permissions'
]
delete openDocument['permissions_form']
}
if (openDocument.__changedFields) {
openDocument.__changedFields.forEach((field) => {
if (field === 'owner' || field === 'set_permissions') {
this.documentForm.get('permissions_form').markAsDirty()
} else {
this.documentForm.get(field)?.markAsDirty()
}
})
}
this.updateComponent(openDocument)
} else {
this.openDocumentService.openDocument(doc)
this.updateComponent(doc)
}
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.docChangeNotifier),
takeUntil(this.unsubscribeNotifier)
)
.subscribe({
next: (titleValue) => {
// In the rare case when the field changed just after debounced event was fired.
// We dont want to overwrite what's actually in the text field, so just return
if (titleValue !== this.titleInput.value) return
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
},
complete: () => {
// doc changed so we manually check dirty in case title was changed
if (
this.store.getValue().title !==
this.documentForm.get('title').value
) {
this.openDocumentService.setDirty(doc, true)
}
},
})
// Initialize dirtyCheck
this.store = new BehaviorSubject({
title: doc.title,
content: doc.content,
created: doc.created,
correspondent: doc.correspondent,
document_type: doc.document_type,
storage_path: doc.storage_path,
archive_serial_number: doc.archive_serial_number,
tags: [...doc.tags],
permissions_form: {
owner: doc.owner,
set_permissions: doc.permissions,
},
custom_fields: [...doc.custom_fields],
})
this.isDirty$ = dirtyCheck(
this.documentForm,
this.store.asObservable()
)
return this.isDirty$.pipe(
takeUntil(this.unsubscribeNotifier),
map((dirty) => ({ doc, dirty }))
)
})
)
.subscribe({
next: ({ doc, dirty }) => {
this.openDocumentService.setDirty(doc, dirty, this.getChangedFields())
},
error: (error) => {
this.router.navigate(['404'], {
replaceUrl: true,
})
},
.subscribe((paramMap) => {
const documentId = +paramMap.get('id')
this.docChangeNotifier.next(documentId)
this.loadDocument(documentId)
})
this.route.paramMap.subscribe((paramMap) => {
const section = paramMap.get('section')
if (section) {
const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID = DocumentDetailNavIDs[navIDKey]
this.route.paramMap
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((paramMap) => {
const section = paramMap.get('section')
if (section) {
const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID = DocumentDetailNavIDs[navIDKey]
}
} else if (paramMap.get('id')) {
this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
replaceUrl: true,
})
}
} else if (paramMap.get('id')) {
this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
replaceUrl: true,
})
}
})
})
this.hotKeyService
.addShortcut({
@@ -674,14 +693,7 @@ export class DocumentDetailComponent
})
}
this.title = this.documentTitlePipe.transform(doc.title)
const docFormValues = Object.assign({}, doc)
docFormValues['permissions_form'] = {
owner: doc.owner,
set_permissions: doc.permissions,
}
this.documentForm.patchValue(docFormValues, { emitEvent: false })
if (!this.userCanEdit) this.documentForm.disable()
this.prepareForm(doc)
}
get customFieldFormFields(): FormArray {
@@ -784,7 +796,11 @@ export class DocumentDetailComponent
discard() {
this.documentsService
.get(this.documentId)
.pipe(first())
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (doc) => {
Object.assign(this.document, doc)
@@ -887,9 +903,11 @@ export class DocumentDetailComponent
.patch(this.getChangedFields())
.pipe(
switchMap((updateResult) => {
return this.documentListViewService
.getNext(this.documentId)
.pipe(map((nextDocId) => ({ nextDocId, updateResult })))
this.savedViewService.maybeRefreshDocumentCounts()
return this.documentListViewService.getNext(this.documentId).pipe(
map((nextDocId) => ({ nextDocId, updateResult })),
takeUntil(this.unsubscribeNotifier)
)
})
)
.pipe(
@@ -899,7 +917,10 @@ export class DocumentDetailComponent
return this.openDocumentService
.closeDocument(this.document)
.pipe(
map((closeResult) => ({ updateResult, nextDocId, closeResult }))
map(
(closeResult) => ({ updateResult, nextDocId, closeResult }),
takeUntil(this.unsubscribeNotifier)
)
)
}
})
@@ -1225,16 +1246,19 @@ export class DocumentDetailComponent
) {
doc.owner = this.store.value.permissions_form.owner
}
return !this.document || this.userCanEditDoc(doc)
}
private userCanEditDoc(doc: Document): boolean {
return (
!this.document ||
(this.permissionsService.currentUserCan(
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.Document
) &&
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
doc
))
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
doc
)
)
}
@@ -1416,43 +1440,50 @@ export class DocumentDetailComponent
}
private tryRenderTiff() {
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
next: (res) => {
/* istanbul ignore next */
try {
// See UTIF.js > _imgLoaded
const tiffIfds: any[] = UTIF.decode(res)
var vsns = tiffIfds,
ma = 0,
page = vsns[0]
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
for (var i = 0; i < vsns.length; i++) {
var img = vsns[i]
if (img['t258'] == null || img['t258'].length < 3) continue
var ar = img['t256'] * img['t257']
if (ar > ma) {
ma = ar
page = img
this.http
.get(this.previewUrl, { responseType: 'arraybuffer' })
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (res) => {
/* istanbul ignore next */
try {
// See UTIF.js > _imgLoaded
const tiffIfds: any[] = UTIF.decode(res)
var vsns = tiffIfds,
ma = 0,
page = vsns[0]
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
for (var i = 0; i < vsns.length; i++) {
var img = vsns[i]
if (img['t258'] == null || img['t258'].length < 3) continue
var ar = img['t256'] * img['t257']
if (ar > ma) {
ma = ar
page = img
}
}
UTIF.decodeImage(res, page, tiffIfds)
const rgba = UTIF.toRGBA8(page)
const { width: w, height: h } = page
var cnv = document.createElement('canvas')
cnv.width = w
cnv.height = h
var ctx = cnv.getContext('2d'),
imgd = ctx.createImageData(w, h)
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
ctx.putImageData(imgd, 0, 0)
this.tiffURL = cnv.toDataURL()
} catch (err) {
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
}
UTIF.decodeImage(res, page, tiffIfds)
const rgba = UTIF.toRGBA8(page)
const { width: w, height: h } = page
var cnv = document.createElement('canvas')
cnv.width = w
cnv.height = h
var ctx = cnv.getContext('2d'),
imgd = ctx.createImageData(w, h)
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
ctx.putImageData(imgd, 0, 0)
this.tiffURL = cnv.toDataURL()
} catch (err) {
},
error: (err) => {
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
}
},
error: (err) => {
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
},
})
},
})
}
}

View File

@@ -199,6 +199,14 @@ describe('DocumentListComponent', () => {
}
const queryParams = { id: view.id.toString() }
const getSavedViewSpy = jest.spyOn(savedViewService, 'getCached')
const setCountSpy = jest.spyOn(savedViewService, 'setDocumentCount')
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
results: docs,
count: 3,
all: docs.map((d) => d.id),
})
)
getSavedViewSpy.mockReturnValue(of(view))
const activateSavedViewSpy = jest.spyOn(
documentListService,
@@ -215,6 +223,7 @@ describe('DocumentListComponent', () => {
view,
convertToParamMap(queryParams)
)
expect(setCountSpy).toHaveBeenCalledWith(view, 3)
})
it('should 404 on load saved view from URL if no view', () => {
@@ -248,6 +257,34 @@ describe('DocumentListComponent', () => {
expect(getSavedViewSpy).toHaveBeenCalledWith(view.id)
})
it('should update saved view document count on load saved view from query params', () => {
jest.spyOn(savedViewService, 'getCached').mockReturnValue(
of({
id: 10,
sort_field: 'added',
sort_reverse: true,
filter_rules: [],
})
)
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
results: docs,
count: 3,
all: docs.map((d) => d.id),
})
)
const setCountSpy = jest.spyOn(savedViewService, 'setDocumentCount')
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
results: docs,
count: 3,
all: docs.map((d) => d.id),
})
)
component.loadViewConfig(10)
expect(setCountSpy).toHaveBeenCalledWith(expect.any(Object), 3)
})
it('should support 3 different display modes', () => {
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
fixture.detectChanges()

View File

@@ -264,7 +264,9 @@ export class DocumentListComponent
view,
convertToParamMap(this.route.snapshot.queryParams)
)
this.list.reload()
this.list.reload(() => {
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
})
this.updateDisplayCustomFields()
this.unmodifiedFilterRules = view.filter_rules
})
@@ -399,7 +401,9 @@ export class DocumentListComponent
.subscribe((view) => {
this.unmodifiedSavedView = view
this.list.activateSavedView(view)
this.list.reload()
this.list.reload(() => {
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
})
})
}

View File

@@ -44,4 +44,5 @@ export interface SystemStatus {
sanity_check_last_run: string // ISO date string
sanity_check_error: string
}
websocket_connected?: SystemStatusItemStatus // added client-side
}

View File

@@ -140,11 +140,15 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((results: Results<Document>) => {
this.savedViewDocumentCounts.set(view.id, results.count)
this.setDocumentCount(view, results.count)
})
})
}
public setDocumentCount(view: SavedView, count: number) {
this.savedViewDocumentCounts.set(view.id, count)
}
public getDocumentCount(view: SavedView): number {
return this.savedViewDocumentCounts.get(view.id)
}

View File

@@ -103,6 +103,7 @@ export class WebsocketStatusService {
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
private documentConsumptionFailedSubject = new Subject<FileStatus>()
private documentDeletedSubject = new Subject<boolean>()
private connectionStatusSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) {
let status =
@@ -153,6 +154,15 @@ export class WebsocketStatusService {
this.statusWebSocket = new WebSocket(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`
)
this.statusWebSocket.onopen = () => {
this.connectionStatusSubject.next(true)
}
this.statusWebSocket.onclose = () => {
this.connectionStatusSubject.next(false)
}
this.statusWebSocket.onerror = () => {
this.connectionStatusSubject.next(false)
}
this.statusWebSocket.onmessage = (ev: MessageEvent) => {
const {
type,
@@ -286,4 +296,12 @@ export class WebsocketStatusService {
onDocumentDeleted() {
return this.documentDeletedSubject
}
onConnectionStatus() {
return this.connectionStatusSubject.asObservable()
}
isConnected(): boolean {
return this.statusWebSocket?.readyState === WebSocket.OPEN
}
}