Merge branch 'dev' into feature-ai

This commit is contained in:
shamoon
2025-08-26 13:30:16 -07:00
66 changed files with 3599 additions and 3463 deletions

View File

@@ -116,7 +116,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-truncate]="!slimSidebarEnabled">{{view.name}}</span></div>
@if (showSidebarCounts && !slimSidebarEnabled) {
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
}
@@ -150,7 +150,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

@@ -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

@@ -495,6 +495,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'
@@ -1443,4 +1455,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,
@@ -339,19 +340,163 @@ 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.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 (
@@ -403,163 +548,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({
@@ -673,14 +691,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 {
@@ -845,7 +856,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)
@@ -948,9 +963,10 @@ export class DocumentDetailComponent
.patch(this.getChangedFields())
.pipe(
switchMap((updateResult) => {
return this.documentListViewService
.getNext(this.documentId)
.pipe(map((nextDocId) => ({ nextDocId, updateResult })))
return this.documentListViewService.getNext(this.documentId).pipe(
map((nextDocId) => ({ nextDocId, updateResult })),
takeUntil(this.unsubscribeNotifier)
)
})
)
.pipe(
@@ -960,7 +976,10 @@ export class DocumentDetailComponent
return this.openDocumentService
.closeDocument(this.document)
.pipe(
map((closeResult) => ({ updateResult, nextDocId, closeResult }))
map(
(closeResult) => ({ updateResult, nextDocId, closeResult }),
takeUntil(this.unsubscribeNotifier)
)
)
}
})
@@ -1286,16 +1305,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
)
)
}
@@ -1477,43 +1499,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

@@ -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)
}