Compare commits

..

4 Commits

Author SHA1 Message Date
shamoon
fded55dc70 Documentation: include advanced search query param in API spec (#10449) 2025-07-24 15:13:01 -07:00
GitHub Actions
20da51278e Auto translate strings 2025-07-24 05:09:35 +00:00
shamoon
293c84d871 Enhancement: display saved view counts (#10246) 2025-07-23 22:07:13 -07:00
shamoon
1fe8599266 Fix: Make some natural keyword date searches timezone-aware (#10416) 2025-07-23 22:05:55 -07:00
19 changed files with 454 additions and 215 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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>&nbsp;{{view.name}}</span>
<i-bs class="me-1" name="funnel"></i-bs><span>&nbsp;{{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>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name"
[badge]="count"
[loading]="loading"
[draggable]="savedView"
>

View File

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

View File

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

View File

@@ -30,6 +30,9 @@ export class WidgetFrameComponent
@Input()
cardless: boolean = false
@Input()
badge: string
ngAfterViewInit(): void {
setTimeout(() => {
this.show = true

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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