mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			5410074062
			...
			fded55dc70
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					fded55dc70 | ||
| 
						 | 
					20da51278e | ||
| 
						 | 
					293c84d871 | ||
| 
						 | 
					1fe8599266 | 
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -176,6 +176,7 @@
 | 
			
		||||
            <div class="row">
 | 
			
		||||
              <div class="col">
 | 
			
		||||
                <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
 | 
			
		||||
                <pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -31,6 +31,7 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 | 
			
		||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
 | 
			
		||||
import { PermissionsService } from 'src/app/services/permissions.service'
 | 
			
		||||
import { GroupService } from 'src/app/services/rest/group.service'
 | 
			
		||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
 | 
			
		||||
import { UserService } from 'src/app/services/rest/user.service'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
import { SystemStatusService } from 'src/app/services/system-status.service'
 | 
			
		||||
@@ -72,6 +73,7 @@ describe('SettingsComponent', () => {
 | 
			
		||||
  let groupService: GroupService
 | 
			
		||||
  let modalService: NgbModal
 | 
			
		||||
  let systemStatusService: SystemStatusService
 | 
			
		||||
  let savedViewsService: SavedViewService
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
@@ -122,6 +124,7 @@ describe('SettingsComponent', () => {
 | 
			
		||||
    permissionsService = TestBed.inject(PermissionsService)
 | 
			
		||||
    modalService = TestBed.inject(NgbModal)
 | 
			
		||||
    systemStatusService = TestBed.inject(SystemStatusService)
 | 
			
		||||
    savedViewsService = TestBed.inject(SavedViewService)
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
 | 
			
		||||
@@ -212,7 +215,7 @@ describe('SettingsComponent', () => {
 | 
			
		||||
    expect(toastErrorSpy).toHaveBeenCalled()
 | 
			
		||||
    expect(storeSpy).toHaveBeenCalled()
 | 
			
		||||
    expect(appearanceSettingsSpy).not.toHaveBeenCalled()
 | 
			
		||||
    expect(setSpy).toHaveBeenCalledTimes(29)
 | 
			
		||||
    expect(setSpy).toHaveBeenCalledTimes(30)
 | 
			
		||||
 | 
			
		||||
    // succeed
 | 
			
		||||
    storeSpy.mockReturnValueOnce(of(true))
 | 
			
		||||
@@ -345,4 +348,14 @@ describe('SettingsComponent', () => {
 | 
			
		||||
    component.reset()
 | 
			
		||||
    expect(component.settingsForm.get('themeColor').value).toEqual('')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should trigger maybeRefreshDocumentCounts on settings save', () => {
 | 
			
		||||
    completeSetup()
 | 
			
		||||
    const maybeRefreshSpy = jest.spyOn(
 | 
			
		||||
      savedViewsService,
 | 
			
		||||
      'maybeRefreshDocumentCounts'
 | 
			
		||||
    )
 | 
			
		||||
    settingsService.settingsSaved.emit(true)
 | 
			
		||||
    expect(maybeRefreshSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -49,6 +49,7 @@ import {
 | 
			
		||||
  PermissionsService,
 | 
			
		||||
} from 'src/app/services/permissions.service'
 | 
			
		||||
import { GroupService } from 'src/app/services/rest/group.service'
 | 
			
		||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
 | 
			
		||||
import { UserService } from 'src/app/services/rest/user.service'
 | 
			
		||||
import {
 | 
			
		||||
  LanguageOption,
 | 
			
		||||
@@ -117,6 +118,7 @@ export class SettingsComponent
 | 
			
		||||
  permissionsService = inject(PermissionsService)
 | 
			
		||||
  private modalService = inject(NgbModal)
 | 
			
		||||
  private systemStatusService = inject(SystemStatusService)
 | 
			
		||||
  private savedViewsService = inject(SavedViewService)
 | 
			
		||||
 | 
			
		||||
  activeNavID: number
 | 
			
		||||
 | 
			
		||||
@@ -152,6 +154,7 @@ export class SettingsComponent
 | 
			
		||||
    notificationsConsumerSuppressOnDashboard: new FormControl(null),
 | 
			
		||||
 | 
			
		||||
    savedViewsWarnOnUnsavedChange: new FormControl(null),
 | 
			
		||||
    sidebarViewsShowCount: new FormControl(null),
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  SettingsNavIDs = SettingsNavIDs
 | 
			
		||||
@@ -197,6 +200,7 @@ export class SettingsComponent
 | 
			
		||||
    super()
 | 
			
		||||
    this.settings.settingsSaved.subscribe(() => {
 | 
			
		||||
      if (!this.savePending) this.initialize()
 | 
			
		||||
      this.savedViewsService.maybeRefreshDocumentCounts()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -308,6 +312,9 @@ export class SettingsComponent
 | 
			
		||||
      savedViewsWarnOnUnsavedChange: this.settings.get(
 | 
			
		||||
        SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
 | 
			
		||||
      ),
 | 
			
		||||
      sidebarViewsShowCount: this.settings.get(
 | 
			
		||||
        SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
 | 
			
		||||
      ),
 | 
			
		||||
      defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
 | 
			
		||||
      defaultPermsViewUsers: this.settings.get(
 | 
			
		||||
        SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
 | 
			
		||||
@@ -485,6 +492,10 @@ export class SettingsComponent
 | 
			
		||||
      SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE,
 | 
			
		||||
      this.settingsForm.value.savedViewsWarnOnUnsavedChange
 | 
			
		||||
    )
 | 
			
		||||
    this.settings.set(
 | 
			
		||||
      SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT,
 | 
			
		||||
      this.settingsForm.value.sidebarViewsShowCount
 | 
			
		||||
    )
 | 
			
		||||
    this.settings.set(
 | 
			
		||||
      SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
 | 
			
		||||
      this.settingsForm.value.defaultPermsOwner
 | 
			
		||||
 
 | 
			
		||||
@@ -112,7 +112,14 @@
 | 
			
		||||
                    routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
 | 
			
		||||
                    [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
 | 
			
		||||
                    popoverClass="popover-slim">
 | 
			
		||||
                    <i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span>
 | 
			
		||||
                    <i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}
 | 
			
		||||
                      @if (showSidebarCounts && !slimSidebarEnabled) {
 | 
			
		||||
                        <span><span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span></span>
 | 
			
		||||
                      }
 | 
			
		||||
                    </span>
 | 
			
		||||
                    @if (showSidebarCounts && slimSidebarEnabled) {
 | 
			
		||||
                      <span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
 | 
			
		||||
                    }
 | 
			
		||||
                  </a>
 | 
			
		||||
                  @if (settingsService.organizingSidebarSavedViews) {
 | 
			
		||||
                    <div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
 | 
			
		||||
 
 | 
			
		||||
@@ -92,6 +92,7 @@ describe('AppFrameComponent', () => {
 | 
			
		||||
  let router: Router
 | 
			
		||||
  let savedViewSpy
 | 
			
		||||
  let modalService: NgbModal
 | 
			
		||||
  let maybeRefreshSpy
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
@@ -113,7 +114,11 @@ describe('AppFrameComponent', () => {
 | 
			
		||||
        {
 | 
			
		||||
          provide: SavedViewService,
 | 
			
		||||
          useValue: {
 | 
			
		||||
            reload: () => {},
 | 
			
		||||
            reload: (fn: any) => {
 | 
			
		||||
              if (fn) {
 | 
			
		||||
                fn()
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            listAll: () =>
 | 
			
		||||
              of({
 | 
			
		||||
                all: [saved_views.map((v) => v.id)],
 | 
			
		||||
@@ -121,6 +126,8 @@ describe('AppFrameComponent', () => {
 | 
			
		||||
                results: saved_views,
 | 
			
		||||
              }),
 | 
			
		||||
            sidebarViews: saved_views.filter((v) => v.show_in_sidebar),
 | 
			
		||||
            getDocumentCount: (view: SavedView) => 5,
 | 
			
		||||
            maybeRefreshDocumentCounts: () => {},
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        PermissionsService,
 | 
			
		||||
@@ -169,6 +176,7 @@ describe('AppFrameComponent', () => {
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 | 
			
		||||
 | 
			
		||||
    savedViewSpy = jest.spyOn(savedViewService, 'reload')
 | 
			
		||||
    maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
 | 
			
		||||
 | 
			
		||||
    fixture = TestBed.createComponent(AppFrameComponent)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
@@ -359,4 +367,8 @@ describe('AppFrameComponent', () => {
 | 
			
		||||
    expect(toastErrorSpy).toHaveBeenCalledTimes(2)
 | 
			
		||||
    expect(toastInfoSpy).toHaveBeenCalledTimes(3)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should call maybeRefreshDocumentCounts after saved views reload', () => {
 | 
			
		||||
    expect(maybeRefreshSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -102,7 +102,9 @@ export class AppFrameComponent
 | 
			
		||||
        PermissionType.SavedView
 | 
			
		||||
      )
 | 
			
		||||
    ) {
 | 
			
		||||
      this.savedViewService.reload()
 | 
			
		||||
      this.savedViewService.reload(() => {
 | 
			
		||||
        this.savedViewService.maybeRefreshDocumentCounts()
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -283,4 +285,8 @@ export class AppFrameComponent
 | 
			
		||||
  onLogout() {
 | 
			
		||||
    this.openDocumentsService.closeAll()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get showSidebarCounts(): boolean {
 | 
			
		||||
    return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
<pngx-widget-frame
 | 
			
		||||
  *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
 | 
			
		||||
  [title]="savedView.name"
 | 
			
		||||
  [badge]="count"
 | 
			
		||||
  [loading]="loading"
 | 
			
		||||
  [draggable]="savedView"
 | 
			
		||||
  >
 | 
			
		||||
 
 | 
			
		||||
@@ -118,6 +118,8 @@ export class SavedViewWidgetComponent
 | 
			
		||||
 | 
			
		||||
  displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
 | 
			
		||||
 | 
			
		||||
  count: number
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.reload()
 | 
			
		||||
    this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
 | 
			
		||||
@@ -178,6 +180,7 @@ export class SavedViewWidgetComponent
 | 
			
		||||
        tap((result) => {
 | 
			
		||||
          this.show = true
 | 
			
		||||
          this.documents = result.results
 | 
			
		||||
          this.count = result.count
 | 
			
		||||
        }),
 | 
			
		||||
        delay(500)
 | 
			
		||||
      )
 | 
			
		||||
 
 | 
			
		||||
@@ -2,13 +2,16 @@
 | 
			
		||||
  <div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
 | 
			
		||||
    <div class="card-header">
 | 
			
		||||
      <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
        <div class="d-flex">
 | 
			
		||||
        <div class="d-flex align-items-center">
 | 
			
		||||
          @if (draggable) {
 | 
			
		||||
            <div class="ms-n2 me-1" cdkDragHandle>
 | 
			
		||||
              <i-bs name="grip-vertical"></i-bs>
 | 
			
		||||
            </div>
 | 
			
		||||
          }
 | 
			
		||||
          <h6 class="card-title mb-0">{{title}}</h6>
 | 
			
		||||
          @if (badge) {
 | 
			
		||||
            <span class="badge bg-info text-dark ms-2">{{badge}}</span>
 | 
			
		||||
          }
 | 
			
		||||
        </div>
 | 
			
		||||
        @if (loading) {
 | 
			
		||||
          <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
 | 
			
		||||
 
 | 
			
		||||
@@ -30,6 +30,9 @@ export class WidgetFrameComponent
 | 
			
		||||
  @Input()
 | 
			
		||||
  cardless: boolean = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  badge: string
 | 
			
		||||
 | 
			
		||||
  ngAfterViewInit(): void {
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.show = true
 | 
			
		||||
 
 | 
			
		||||
@@ -73,6 +73,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
 | 
			
		||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 | 
			
		||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service'
 | 
			
		||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
 | 
			
		||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
 | 
			
		||||
import { UserService } from 'src/app/services/rest/user.service'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
@@ -195,6 +196,7 @@ export class DocumentDetailComponent
 | 
			
		||||
  private hotKeyService = inject(HotKeyService)
 | 
			
		||||
  private componentRouterService = inject(ComponentRouterService)
 | 
			
		||||
  private deviceDetectorService = inject(DeviceDetectorService)
 | 
			
		||||
  private savedViewService = inject(SavedViewService)
 | 
			
		||||
 | 
			
		||||
  @ViewChild('inputTitle')
 | 
			
		||||
  titleInput: TextComponent
 | 
			
		||||
@@ -841,6 +843,7 @@ export class DocumentDetailComponent
 | 
			
		||||
          } else {
 | 
			
		||||
            this.openDocumentService.refreshDocument(this.documentId)
 | 
			
		||||
          }
 | 
			
		||||
          this.savedViewService.maybeRefreshDocumentCounts()
 | 
			
		||||
        },
 | 
			
		||||
        error: (error) => {
 | 
			
		||||
          this.networkActive = false
 | 
			
		||||
@@ -1188,6 +1191,7 @@ export class DocumentDetailComponent
 | 
			
		||||
  notesUpdated(notes: DocumentNote[]) {
 | 
			
		||||
    this.document.notes = notes
 | 
			
		||||
    this.openDocumentService.refreshDocument(this.documentId)
 | 
			
		||||
    this.savedViewService.maybeRefreshDocumentCounts()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get userIsOwner(): boolean {
 | 
			
		||||
 
 | 
			
		||||
@@ -32,6 +32,7 @@ import {
 | 
			
		||||
  DocumentService,
 | 
			
		||||
  SelectionDataItem,
 | 
			
		||||
} from 'src/app/services/rest/document.service'
 | 
			
		||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
 | 
			
		||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
 | 
			
		||||
import { TagService } from 'src/app/services/rest/tag.service'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
@@ -83,6 +84,7 @@ export class BulkEditorComponent
 | 
			
		||||
  private storagePathService = inject(StoragePathService)
 | 
			
		||||
  private customFieldService = inject(CustomFieldsService)
 | 
			
		||||
  private permissionService = inject(PermissionsService)
 | 
			
		||||
  private savedViewService = inject(SavedViewService)
 | 
			
		||||
 | 
			
		||||
  tagSelectionModel = new FilterableDropdownSelectionModel(true)
 | 
			
		||||
  correspondentSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
@@ -270,6 +272,7 @@ export class BulkEditorComponent
 | 
			
		||||
          this.list.selected.forEach((id) => {
 | 
			
		||||
            this.openDocumentService.refreshDocument(id)
 | 
			
		||||
          })
 | 
			
		||||
          this.savedViewService.maybeRefreshDocumentCounts()
 | 
			
		||||
          if (modal) {
 | 
			
		||||
            modal.close()
 | 
			
		||||
          }
 | 
			
		||||
 
 | 
			
		||||
@@ -58,6 +58,8 @@ export const SETTINGS_KEYS = {
 | 
			
		||||
    'general-settings:saved-views:dashboard-views-sort-order',
 | 
			
		||||
  SIDEBAR_VIEWS_SORT_ORDER:
 | 
			
		||||
    'general-settings:saved-views:sidebar-views-sort-order',
 | 
			
		||||
  SIDEBAR_VIEWS_SHOW_COUNT:
 | 
			
		||||
    'general-settings:saved-views:sidebar-views-show-count',
 | 
			
		||||
  TOUR_COMPLETE: 'general-settings:tour-complete',
 | 
			
		||||
  DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
 | 
			
		||||
  DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
 | 
			
		||||
@@ -227,6 +229,11 @@ export const SETTINGS: UiSetting[] = [
 | 
			
		||||
    type: 'array',
 | 
			
		||||
    default: [],
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    key: SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT,
 | 
			
		||||
    type: 'boolean',
 | 
			
		||||
    default: true,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    key: SETTINGS_KEYS.APP_LOGO,
 | 
			
		||||
    type: 'string',
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@ const saved_views = [
 | 
			
		||||
    id: 1,
 | 
			
		||||
    show_on_dashboard: true,
 | 
			
		||||
    show_in_sidebar: true,
 | 
			
		||||
    sort_field: 'name',
 | 
			
		||||
    sort_field: 'title',
 | 
			
		||||
    sort_reverse: true,
 | 
			
		||||
    filter_rules: [],
 | 
			
		||||
  },
 | 
			
		||||
@@ -26,7 +26,7 @@ const saved_views = [
 | 
			
		||||
    id: 2,
 | 
			
		||||
    show_on_dashboard: true,
 | 
			
		||||
    show_in_sidebar: true,
 | 
			
		||||
    sort_field: 'name',
 | 
			
		||||
    sort_field: 'created',
 | 
			
		||||
    sort_reverse: true,
 | 
			
		||||
    filter_rules: [],
 | 
			
		||||
  },
 | 
			
		||||
@@ -35,7 +35,7 @@ const saved_views = [
 | 
			
		||||
    id: 3,
 | 
			
		||||
    show_on_dashboard: true,
 | 
			
		||||
    show_in_sidebar: true,
 | 
			
		||||
    sort_field: 'name',
 | 
			
		||||
    sort_field: 'added',
 | 
			
		||||
    sort_reverse: true,
 | 
			
		||||
    filter_rules: [],
 | 
			
		||||
  },
 | 
			
		||||
@@ -44,7 +44,7 @@ const saved_views = [
 | 
			
		||||
    id: 4,
 | 
			
		||||
    show_on_dashboard: false,
 | 
			
		||||
    show_in_sidebar: false,
 | 
			
		||||
    sort_field: 'name',
 | 
			
		||||
    sort_field: 'owner',
 | 
			
		||||
    sort_reverse: true,
 | 
			
		||||
    filter_rules: [],
 | 
			
		||||
  },
 | 
			
		||||
@@ -222,6 +222,43 @@ describe(`Additional service tests for SavedViewService`, () => {
 | 
			
		||||
      })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should accept a callback for reload', () => {
 | 
			
		||||
    const reloadSpy = jest.fn()
 | 
			
		||||
    service.reload(reloadSpy)
 | 
			
		||||
    const req = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
    req.flush({
 | 
			
		||||
      results: saved_views,
 | 
			
		||||
    })
 | 
			
		||||
    expect(reloadSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support getting document counts for views', () => {
 | 
			
		||||
    service.maybeRefreshDocumentCounts(saved_views)
 | 
			
		||||
    saved_views.forEach((saved_view) => {
 | 
			
		||||
      const req = httpTestingController.expectOne(
 | 
			
		||||
        `${environment.apiBaseUrl}documents/?page=1&page_size=1&ordering=-${saved_view.sort_field}&fields=id&truncate_content=true`
 | 
			
		||||
      )
 | 
			
		||||
      req.flush({
 | 
			
		||||
        all: [],
 | 
			
		||||
        count: 1,
 | 
			
		||||
        results: [{ id: 1 }],
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
    expect(service.getDocumentCount(saved_views[0])).toEqual(1)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should not refresh document counts if setting is disabled', () => {
 | 
			
		||||
    jest.spyOn(settingsService, 'get').mockImplementation((key) => {
 | 
			
		||||
      if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) return false
 | 
			
		||||
    })
 | 
			
		||||
    service.maybeRefreshDocumentCounts(saved_views)
 | 
			
		||||
    httpTestingController.expectNone(
 | 
			
		||||
      `${environment.apiBaseUrl}documents/?page=1&page_size=1&ordering=-${saved_views[0].sort_field}&fields=id&truncate_content=true`
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    // Dont need to setup again
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,13 @@
 | 
			
		||||
import { HttpClient } from '@angular/common/http'
 | 
			
		||||
import { inject, Injectable } from '@angular/core'
 | 
			
		||||
import { combineLatest, Observable } from 'rxjs'
 | 
			
		||||
import { tap } from 'rxjs/operators'
 | 
			
		||||
import { combineLatest, Observable, Subject } from 'rxjs'
 | 
			
		||||
import { takeUntil, tap } from 'rxjs/operators'
 | 
			
		||||
import { Results } from 'src/app/data/results'
 | 
			
		||||
import { SavedView } from 'src/app/data/saved-view'
 | 
			
		||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
 | 
			
		||||
import { SettingsService } from '../settings.service'
 | 
			
		||||
import { AbstractPaperlessService } from './abstract-paperless-service'
 | 
			
		||||
import { DocumentService } from './document.service'
 | 
			
		||||
 | 
			
		||||
@Injectable({
 | 
			
		||||
  providedIn: 'root',
 | 
			
		||||
@@ -14,9 +15,12 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
 | 
			
		||||
export class SavedViewService extends AbstractPaperlessService<SavedView> {
 | 
			
		||||
  protected http: HttpClient
 | 
			
		||||
  private settingsService = inject(SettingsService)
 | 
			
		||||
  private documentService = inject(DocumentService)
 | 
			
		||||
 | 
			
		||||
  public loading: boolean = true
 | 
			
		||||
  private savedViews: SavedView[] = []
 | 
			
		||||
  private savedViewDocumentCounts: Map<number, number> = new Map()
 | 
			
		||||
  private unsubscribeNotifier: Subject<void> = new Subject<void>()
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super()
 | 
			
		||||
@@ -46,8 +50,16 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public reload() {
 | 
			
		||||
    this.listAll().subscribe()
 | 
			
		||||
  public reload(callback: any = null) {
 | 
			
		||||
    this.listAll()
 | 
			
		||||
      .pipe(
 | 
			
		||||
        tap((r) => {
 | 
			
		||||
          if (callback) {
 | 
			
		||||
            callback(r)
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      )
 | 
			
		||||
      .subscribe()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get allViews() {
 | 
			
		||||
@@ -110,4 +122,30 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
 | 
			
		||||
  delete(o: SavedView) {
 | 
			
		||||
    return super.delete(o).pipe(tap(() => this.reload()))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public maybeRefreshDocumentCounts(views: SavedView[] = this.sidebarViews) {
 | 
			
		||||
    if (!this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
    this.unsubscribeNotifier.next() // clear previous subscriptions
 | 
			
		||||
    views.forEach((view) => {
 | 
			
		||||
      this.documentService
 | 
			
		||||
        .listFiltered(
 | 
			
		||||
          1,
 | 
			
		||||
          1,
 | 
			
		||||
          view.sort_field,
 | 
			
		||||
          view.sort_reverse,
 | 
			
		||||
          view.filter_rules,
 | 
			
		||||
          { fields: 'id', truncate_content: true }
 | 
			
		||||
        )
 | 
			
		||||
        .pipe(takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
        .subscribe((results: Results<Document>) => {
 | 
			
		||||
          this.savedViewDocumentCounts.set(view.id, results.count)
 | 
			
		||||
        })
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getDocumentCount(view: SavedView): number {
 | 
			
		||||
    return this.savedViewDocumentCounts.get(view.id)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,10 +2,12 @@ from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
import math
 | 
			
		||||
import re
 | 
			
		||||
from collections import Counter
 | 
			
		||||
from contextlib import contextmanager
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from datetime import time
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from datetime import timezone
 | 
			
		||||
from shutil import rmtree
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
@@ -13,6 +15,8 @@ from typing import Literal
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.utils import timezone as django_timezone
 | 
			
		||||
from django.utils.timezone import get_current_timezone
 | 
			
		||||
from django.utils.timezone import now
 | 
			
		||||
from guardian.shortcuts import get_users_with_perms
 | 
			
		||||
from whoosh import classify
 | 
			
		||||
from whoosh import highlight
 | 
			
		||||
@@ -344,6 +348,7 @@ class LocalDateParser(English):
 | 
			
		||||
class DelayedFullTextQuery(DelayedQuery):
 | 
			
		||||
    def _get_query(self) -> tuple:
 | 
			
		||||
        q_str = self.query_params["query"]
 | 
			
		||||
        q_str = rewrite_natural_date_keywords(q_str)
 | 
			
		||||
        qp = MultifieldParser(
 | 
			
		||||
            [
 | 
			
		||||
                "content",
 | 
			
		||||
@@ -450,3 +455,37 @@ def get_permissions_criterias(user: User | None = None) -> list:
 | 
			
		||||
                query.Term("viewer_id", str(user.id)),
 | 
			
		||||
            )
 | 
			
		||||
    return user_criterias
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def rewrite_natural_date_keywords(query_string: str) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    Rewrites natural date keywords (e.g. added:today or added:"yesterday") to UTC range syntax for Whoosh.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    tz = get_current_timezone()
 | 
			
		||||
    local_now = now().astimezone(tz)
 | 
			
		||||
 | 
			
		||||
    today = local_now.date()
 | 
			
		||||
    yesterday = today - timedelta(days=1)
 | 
			
		||||
 | 
			
		||||
    ranges = {
 | 
			
		||||
        "today": (
 | 
			
		||||
            datetime.combine(today, time.min, tzinfo=tz),
 | 
			
		||||
            datetime.combine(today, time.max, tzinfo=tz),
 | 
			
		||||
        ),
 | 
			
		||||
        "yesterday": (
 | 
			
		||||
            datetime.combine(yesterday, time.min, tzinfo=tz),
 | 
			
		||||
            datetime.combine(yesterday, time.max, tzinfo=tz),
 | 
			
		||||
        ),
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pattern = r"(\b(?:added|created))\s*:\s*[\"']?(today|yesterday)[\"']?"
 | 
			
		||||
 | 
			
		||||
    def repl(m):
 | 
			
		||||
        field, keyword = m.group(1), m.group(2)
 | 
			
		||||
        start, end = ranges[keyword]
 | 
			
		||||
        start_str = start.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S")
 | 
			
		||||
        end_str = end.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S")
 | 
			
		||||
        return f"{field}:[{start_str} TO {end_str}]"
 | 
			
		||||
 | 
			
		||||
    return re.sub(pattern, repl, query_string)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,11 @@
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from unittest import mock
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test import override_settings
 | 
			
		||||
from django.utils.timezone import get_current_timezone
 | 
			
		||||
from django.utils.timezone import timezone
 | 
			
		||||
 | 
			
		||||
from documents import index
 | 
			
		||||
from documents.models import Document
 | 
			
		||||
@@ -90,3 +95,35 @@ class TestAutoComplete(DirectoriesMixin, TestCase):
 | 
			
		||||
            _, kwargs = mocked_update_doc.call_args
 | 
			
		||||
 | 
			
		||||
            self.assertIsNone(kwargs["asn"])
 | 
			
		||||
 | 
			
		||||
    @override_settings(TIME_ZONE="Pacific/Auckland")
 | 
			
		||||
    def test_added_today_respects_local_timezone_boundary(self):
 | 
			
		||||
        tz = get_current_timezone()
 | 
			
		||||
        fixed_now = datetime(2025, 7, 20, 15, 0, 0, tzinfo=tz)
 | 
			
		||||
 | 
			
		||||
        # Fake a time near the local boundary (1 AM NZT = 13:00 UTC on previous UTC day)
 | 
			
		||||
        local_dt = datetime(2025, 7, 20, 1, 0, 0).replace(tzinfo=tz)
 | 
			
		||||
        utc_dt = local_dt.astimezone(timezone.utc)
 | 
			
		||||
 | 
			
		||||
        doc = Document.objects.create(
 | 
			
		||||
            title="Time zone",
 | 
			
		||||
            content="Testing added:today",
 | 
			
		||||
            checksum="edgecase123",
 | 
			
		||||
            added=utc_dt,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        with index.open_index_writer() as writer:
 | 
			
		||||
            index.update_document(writer, doc)
 | 
			
		||||
 | 
			
		||||
        superuser = User.objects.create_superuser(username="testuser")
 | 
			
		||||
        self.client.force_login(superuser)
 | 
			
		||||
 | 
			
		||||
        with mock.patch("documents.index.now", return_value=fixed_now):
 | 
			
		||||
            response = self.client.get("/api/documents/?query=added:today")
 | 
			
		||||
            results = response.json()["results"]
 | 
			
		||||
            self.assertEqual(len(results), 1)
 | 
			
		||||
            self.assertEqual(results[0]["id"], doc.id)
 | 
			
		||||
 | 
			
		||||
            response = self.client.get("/api/documents/?query=added:yesterday")
 | 
			
		||||
            results = response.json()["results"]
 | 
			
		||||
            self.assertEqual(len(results), 0)
 | 
			
		||||
 
 | 
			
		||||
@@ -1095,7 +1095,14 @@ class DocumentViewSet(
 | 
			
		||||
 | 
			
		||||
@extend_schema_view(
 | 
			
		||||
    list=extend_schema(
 | 
			
		||||
        description="Document views including search",
 | 
			
		||||
        parameters=[
 | 
			
		||||
            OpenApiParameter(
 | 
			
		||||
                name="query",
 | 
			
		||||
                type=OpenApiTypes.STR,
 | 
			
		||||
                location=OpenApiParameter.QUERY,
 | 
			
		||||
                description="Advanced search query string",
 | 
			
		||||
            ),
 | 
			
		||||
            OpenApiParameter(
 | 
			
		||||
                name="full_perms",
 | 
			
		||||
                type=OpenApiTypes.BOOL,
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user