Compare commits

...
Author SHA1 Message Date
shamoon f5a4026d4f Update advanced-search-query.ts 2026-09-19 21:04:52 -07:00
shamoon 339f9a4d30 Hook up to the filter bar 2026-09-19 21:04:52 -07:00
shamoon 856140c068 Advanced search dialog 2026-09-19 21:04:52 -07:00
shamoonandClaude Opus 5 62cf7c4429 feat(search): read a query back into the advanced search editor
The inverse of the serializer, deliberately narrow: it reads the forms
the serializer writes and nothing else, so a query it cannot show stays
text instead of being converted into something subtly different.

The guarantee is enforced rather than argued: a tree is only returned
when writing it out again reproduces the query character for character.
That turns every near miss into a plain "can't show this one" - a
boost, an implicit AND, a field alias, or AND and OR mixed at one level.
Aliases are refused for the same reason, since reading type: would mean
rewriting the query as it was read.

Per-word field prefixes are put back together into the single condition
they came from, but only when adjacent and on the same field, so the
order of what the user typed is never rearranged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:04:51 -07:00
shamoon 9a2c2b9331 Basic advanced search query foundations 2026-09-19 21:04:51 -07:00
11 changed files with 2090 additions and 1 deletions
@@ -0,0 +1,347 @@
<div class="modal-header">
<h4 class="modal-title" id="advanced-search-dialog-title" i18n>
Advanced search
</h4>
<button
type="button"
class="btn-close"
aria-label="Close"
i18n-aria-label
(click)="cancel()"
></button>
</div>
<div class="modal-body">
@if (unreadable) {
<div class="alert alert-warning d-flex flex-wrap gap-2 align-items-center">
<div class="flex-grow-1">
<span i18n
>The current query uses syntax this editor can't show. It still works
as typed.</span
>
</div>
<button
type="button"
class="btn btn-sm btn-outline-secondary"
(click)="startOver()"
i18n
>
Start a new query
</button>
</div>
}
<ng-container
*ngTemplateOutlet="
groupTemplate;
context: { group: root, parent: null, depth: 0 }
"
></ng-container>
<div class="mt-4">
<label
class="form-label small text-muted"
for="advanced-search-preview"
i18n
>Query</label
>
<pre
id="advanced-search-preview"
class="query-preview mb-0 p-2 rounded border"
>@if (generatedQuery) {{{ generatedQuery }}} @else {<span class="text-muted fst-italic" i18n>Nothing to search for yet</span>}</pre>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-outline-secondary"
(click)="cancel()"
i18n
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
(click)="apply()"
[disabled]="!generatedQuery"
i18n
>
Apply
</button>
</div>
<ng-template
#groupTemplate
let-group="group"
let-parent="parent"
let-depth="depth"
>
<div class="d-flex w-100 gap-2">
<div class="d-flex flex-grow-1 flex-column">
<div class="d-flex align-items-center flex-wrap">
<div
class="btn-group btn-group-xs"
role="group"
aria-label="Match"
i18n-aria-label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.Or"
id="advancedSearchAny_{{ idFor(group) }}"
name="advancedSearchAny_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-primary"
for="advancedSearchAny_{{ idFor(group) }}"
i18n
>Any</label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.And"
id="advancedSearchAll_{{ idFor(group) }}"
name="advancedSearchAll_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-primary"
for="advancedSearchAll_{{ idFor(group) }}"
i18n
>All</label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.Not"
id="advancedSearchNot_{{ idFor(group) }}"
name="advancedSearchNot_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-secondary"
for="advancedSearchNot_{{ idFor(group) }}"
i18n
>Not</label
>
</div>
<span class="small text-muted ms-2">
@switch (group.operator) {
@case (LogicalOperator.And) {
<ng-container i18n>match all of these</ng-container>
}
@case (LogicalOperator.Or) {
<ng-container i18n>match any of these</ng-container>
}
@case (LogicalOperator.Not) {
<ng-container i18n>match none of these</ng-container>
}
}
</span>
</div>
<div class="list-group list-group-flush">
@for (element of group.children; track element) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@if (element.type === ElementType.Group) {
<ng-container
*ngTemplateOutlet="
groupTemplate;
context: { group: element, parent: group, depth: depth + 1 }
"
></ng-container>
} @else {
<ng-container
*ngTemplateOutlet="
atomTemplate;
context: { atom: element, parent: group }
"
></ng-container>
}
</div>
}
</div>
</div>
<div
class="btn-group-vertical align-self-start ms-2 ps-2 border-start"
role="group"
aria-label="Group actions"
i18n-aria-label
>
<button
type="button"
class="btn btn-sm btn-outline-secondary text-primary"
title="Add condition"
i18n-title
(click)="addAtom(group)"
[disabled]="group.children.length >= maxAtoms"
>
<i-bs name="node-plus"></i-bs>
</button>
<button
type="button"
class="btn btn-sm btn-outline-secondary text-primary"
title="Add group"
i18n-title
(click)="addGroup(group)"
[disabled]="depth >= maxDepth"
>
<i-bs name="braces"></i-bs>
</button>
@if (parent) {
<button
type="button"
class="btn btn-sm btn-outline-secondary text-danger"
aria-label="Remove group"
i18n-aria-label
(click)="remove(parent, group)"
>
<i-bs name="x-circle"></i-bs>
</button>
}
</div>
</div>
</ng-template>
<ng-template #atomTemplate let-atom="atom" let-parent="parent">
<div class="d-flex align-items-center gap-1 w-100">
<div class="input-group input-group-sm flex-wrap">
<select
class="form-select flex-grow-0 w-auto"
[(ngModel)]="atom.field"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="onFieldChange(atom)"
aria-label="Field"
i18n-aria-label
>
@for (fieldGroup of fieldGroups; track fieldGroup.label) {
<optgroup [label]="fieldGroup.label">
@for (field of fieldGroup.fields; track field) {
<option [ngValue]="field">{{ fieldLabels[field] }}</option>
}
</optgroup>
}
</select>
<select
class="form-select flex-grow-0 w-auto"
[(ngModel)]="atom.operator"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="onOperatorChange(atom)"
aria-label="Condition"
i18n-aria-label
>
@for (operator of operatorsFor(atom); track operator) {
<option [ngValue]="operator">
{{ operatorLabel(atom, operator) }}
</option>
}
</select>
@switch (atom.operator) {
@case (Operator.DateKeyword) {
<select
class="form-select"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Period"
i18n-aria-label
>
<option [ngValue]="''" disabled i18n>Choose a period</option>
@for (keyword of dateKeywords; track keyword) {
<option [ngValue]="keyword">
{{ dateKeywordLabels[keyword] }}
</option>
}
</select>
}
@case (Operator.WithinLast) {
<input
class="form-control amount"
type="number"
min="1"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Amount"
i18n-aria-label
/>
<select
class="form-select"
[(ngModel)]="atom.unit"
[ngModelOptions]="{ standalone: true }"
aria-label="Unit"
i18n-aria-label
>
@for (unit of dateUnits; track unit) {
<option [ngValue]="unit">{{ dateUnitLabels[unit] }}</option>
}
</select>
}
@case (Operator.Between) {
<input
class="form-control"
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="From"
i18n-aria-label
/>
<span class="input-group-text" i18n>and</span>
<input
class="form-control"
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
[(ngModel)]="atom.valueTo"
[ngModelOptions]="{ standalone: true }"
aria-label="To"
i18n-aria-label
/>
}
@default {
@if (kindOf(atom) === FieldKind.Date) {
<input
class="form-control"
type="date"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Date"
i18n-aria-label
/>
} @else if (kindOf(atom) === FieldKind.Number) {
<input
class="form-control"
type="number"
min="0"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Number"
i18n-aria-label
/>
} @else {
<input
class="form-control"
type="text"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
[placeholder]="placeholderFor(atom)"
aria-label="Value"
i18n-aria-label
/>
}
}
}
</div>
<button
class="btn btn-link btn-sm text-danger px-1"
type="button"
(click)="remove(parent, atom)"
aria-label="Remove condition"
i18n-aria-label
>
<i-bs name="x-circle"></i-bs>
</button>
</div>
</ng-template>
@@ -0,0 +1,11 @@
.query-preview {
font-size: 0.8125rem;
white-space: pre-wrap;
word-break: break-word;
background-color: var(--pngx-bg-darker);
border-color: var(--bs-border-color) !important;
}
.input-group .amount {
flex: 0 1 5rem;
}
@@ -0,0 +1,186 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from 'src/app/data/advanced-search-query'
import { AdvancedSearchDialogComponent } from './advanced-search-dialog.component'
describe('AdvancedSearchDialogComponent', () => {
let component: AdvancedSearchDialogComponent
let fixture: ComponentFixture<AdvancedSearchDialogComponent>
let activeModal: NgbActiveModal
const firstAtom = (): AdvancedSearchQueryAtom =>
component.root.children[0] as AdvancedSearchQueryAtom
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
AdvancedSearchDialogComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [NgbActiveModal],
}).compileComponents()
fixture = TestBed.createComponent(AdvancedSearchDialogComponent)
activeModal = TestBed.inject(NgbActiveModal)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should start with one empty condition and nothing to search for', () => {
expect(component.root.children).toHaveLength(1)
expect(component.generatedQuery).toBe('')
expect(component.unreadable).toBeFalsy()
})
it('should show an existing query as conditions', () => {
component.query = 'title:invoice AND NOT tag:paid'
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(2)
expect(component.generatedQuery).toBe('title:invoice AND NOT tag:paid')
})
it('should flag a query it cannot show and start empty', () => {
component.query = 'title:invoice^2'
expect(component.unreadable).toBeTruthy()
expect(component.generatedQuery).toBe('')
})
it('should clear the warning when starting a new query', () => {
component.query = 'title:invoice^2'
component.startOver()
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(1)
})
it('should treat an empty query as a fresh start', () => {
component.query = ' '
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(1)
})
it('should write the query as conditions are filled in', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Title
atom.value = 'unpaid invoice'
expect(component.generatedQuery).toBe('title:unpaid AND title:invoice')
})
it('should offer the conditions of the chosen field', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Added
component.onFieldChange(atom)
expect(component.operatorsFor(atom)).toContain(
AdvancedSearchOperator.WithinLast
)
expect(component.operatorsFor(atom)).not.toContain(
AdvancedSearchOperator.Phrase
)
})
it('should keep a condition the new field still offers', () => {
const atom = firstAtom()
atom.operator = AdvancedSearchOperator.Phrase
atom.field = AdvancedSearchField.Correspondent
component.onFieldChange(atom)
expect(atom.operator).toBe(AdvancedSearchOperator.Phrase)
})
it('should replace a condition the new field does not offer, and clear the value', () => {
const atom = firstAtom()
atom.operator = AdvancedSearchOperator.Phrase
atom.value = 'invoice'
atom.field = AdvancedSearchField.ASN
component.onFieldChange(atom)
expect(atom.operator).toBe(AdvancedSearchOperator.Equals)
expect(atom.value).toBe('')
})
it('should give a within-the-last condition a unit to start from', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Added
atom.operator = AdvancedSearchOperator.WithinLast
component.onOperatorChange(atom)
expect(atom.unit).toBe(AdvancedSearchDateUnit.Day)
atom.value = '3'
expect(component.generatedQuery).toBe('added:[-3 days to now]')
})
it('should label date comparisons as dates', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Created
expect(
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
).toEqual('is on or after')
atom.field = AdvancedSearchField.ASN
expect(
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
).toEqual('is at least')
})
it('should add and remove conditions', () => {
component.addAtom(component.root)
expect(component.root.children).toHaveLength(2)
component.remove(component.root, component.root.children[1])
expect(component.root.children).toHaveLength(1)
})
it('should add a group, which starts as Any', () => {
component.addGroup(component.root)
const group = component.root.children[1] as AdvancedSearchQueryGroup
expect(group.type).toBe(AdvancedSearchQueryElementType.Group)
expect(group.operator).toBe(AdvancedSearchLogicalOperator.Or)
expect(group.children).toHaveLength(1)
})
it('should give every group its own id, once', () => {
component.addGroup(component.root)
const group = component.root.children[1] as AdvancedSearchQueryGroup
expect(component.idFor(component.root)).not.toEqual(component.idFor(group))
expect(component.idFor(group)).toEqual(component.idFor(group))
})
it('should apply the query and close', () => {
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
const closeSpy = jest.spyOn(activeModal, 'close')
const atom = firstAtom()
atom.value = 'invoice'
component.apply()
expect(emitSpy).toHaveBeenCalledWith('content:invoice')
expect(closeSpy).toHaveBeenCalled()
})
it('should close without applying on cancel', () => {
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
const closeSpy = jest.spyOn(activeModal, 'close')
component.cancel()
expect(emitSpy).not.toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
})
it('should show the query it will apply', () => {
component.query = 'content:invoice OR content:receipt'
fixture.detectChanges()
const preview = fixture.nativeElement.querySelector(
'#advanced-search-preview'
)
expect(preview.textContent).toContain('content:invoice OR content:receipt')
})
it('should not offer to apply an empty query', () => {
fixture.detectChanges()
const apply = Array.from(
fixture.nativeElement.querySelectorAll('.modal-footer button')
).pop() as HTMLButtonElement
expect(apply.disabled).toBeTruthy()
})
})
@@ -0,0 +1,197 @@
import { NgTemplateOutlet } from '@angular/common'
import { Component, EventEmitter, inject, Input, Output } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
ADVANCED_SEARCH_DATE_KEYWORD_LABELS,
ADVANCED_SEARCH_DATE_KEYWORDS,
ADVANCED_SEARCH_DATE_OPERATOR_LABELS,
ADVANCED_SEARCH_DATE_UNIT_LABELS,
ADVANCED_SEARCH_FIELD_GROUPS,
ADVANCED_SEARCH_FIELD_KINDS,
ADVANCED_SEARCH_FIELD_LABELS,
ADVANCED_SEARCH_MAX_ATOMS,
ADVANCED_SEARCH_MAX_DEPTH,
ADVANCED_SEARCH_OPERATOR_LABELS,
ADVANCED_SEARCH_OPERATORS_BY_KIND,
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchFieldKind,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from 'src/app/data/advanced-search-query'
import {
parseAdvancedSearchQuery,
serializeAdvancedSearchQuery,
} from 'src/app/utils/advanced-search-query'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({
selector: 'pngx-advanced-search-dialog',
templateUrl: './advanced-search-dialog.component.html',
styleUrl: './advanced-search-dialog.component.scss',
imports: [FormsModule, NgTemplateOutlet, NgxBootstrapIconsModule],
})
export class AdvancedSearchDialogComponent extends LoadingComponentWithPermissions {
private activeModal = inject(NgbActiveModal)
public readonly ElementType = AdvancedSearchQueryElementType
public readonly LogicalOperator = AdvancedSearchLogicalOperator
public readonly Operator = AdvancedSearchOperator
public readonly FieldKind = AdvancedSearchFieldKind
public readonly fieldGroups = ADVANCED_SEARCH_FIELD_GROUPS
public readonly fieldLabels = ADVANCED_SEARCH_FIELD_LABELS
public readonly dateKeywords = ADVANCED_SEARCH_DATE_KEYWORDS
public readonly dateKeywordLabels = ADVANCED_SEARCH_DATE_KEYWORD_LABELS
public readonly dateUnits = Object.values(AdvancedSearchDateUnit)
public readonly dateUnitLabels = ADVANCED_SEARCH_DATE_UNIT_LABELS
public readonly maxDepth = ADVANCED_SEARCH_MAX_DEPTH
public readonly maxAtoms = ADVANCED_SEARCH_MAX_ATOMS
@Output()
public queryApplied = new EventEmitter<string>()
public root: AdvancedSearchQueryGroup = this.emptyRoot()
// True when the query in the search box uses syntax the editor cannot show
public unreadable: boolean = false
private _query: string = ''
@Input()
set query(query: string) {
this._query = query ?? ''
const parsed = parseAdvancedSearchQuery(this._query)
this.unreadable = !!this._query.trim() && !parsed
this.root = parsed ?? this.emptyRoot()
}
get query(): string {
return this._query
}
constructor() {
super()
this.loading.set(false)
}
// Stable ids for the radio groups, without putting them in the query model
private ids = new WeakMap<object, number>()
private nextId = 0
public idFor(element: AdvancedSearchQueryElement): number {
if (!this.ids.has(element)) {
this.ids.set(element, this.nextId++)
}
return this.ids.get(element)
}
private emptyRoot(): AdvancedSearchQueryGroup {
return {
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.And,
children: [this.newAtom()],
}
}
private newAtom(): AdvancedSearchQueryAtom {
return {
type: AdvancedSearchQueryElementType.Atom,
field: AdvancedSearchField.Content,
operator: AdvancedSearchOperator.AllWords,
value: '',
}
}
public get generatedQuery(): string {
return serializeAdvancedSearchQuery(this.root)
}
public kindOf(atom: AdvancedSearchQueryAtom): AdvancedSearchFieldKind {
return ADVANCED_SEARCH_FIELD_KINDS[atom.field]
}
public operatorsFor(atom: AdvancedSearchQueryAtom): AdvancedSearchOperator[] {
return ADVANCED_SEARCH_OPERATORS_BY_KIND[this.kindOf(atom)]
}
public operatorLabel(
atom: AdvancedSearchQueryAtom,
operator: AdvancedSearchOperator
): string {
return this.kindOf(atom) === AdvancedSearchFieldKind.Date
? (ADVANCED_SEARCH_DATE_OPERATOR_LABELS[operator] ??
ADVANCED_SEARCH_OPERATOR_LABELS[operator])
: ADVANCED_SEARCH_OPERATOR_LABELS[operator]
}
public placeholderFor(atom: AdvancedSearchQueryAtom): string {
switch (atom.operator) {
case AdvancedSearchOperator.Phrase:
return $localize`phrase`
case AdvancedSearchOperator.StartsWith:
return $localize`beginning of a word`
default:
return $localize`words`
}
}
public onFieldChange(atom: AdvancedSearchQueryAtom) {
// Keep the condition only if the new field still offers it
if (!this.operatorsFor(atom).includes(atom.operator)) {
atom.operator = this.operatorsFor(atom)[0]
}
this.clearValues(atom)
}
public onOperatorChange(atom: AdvancedSearchQueryAtom) {
this.clearValues(atom)
}
private clearValues(atom: AdvancedSearchQueryAtom) {
atom.value = ''
atom.valueTo = undefined
atom.unit =
atom.operator === AdvancedSearchOperator.WithinLast
? AdvancedSearchDateUnit.Day
: undefined
}
public addAtom(group: AdvancedSearchQueryGroup) {
group.children.push(this.newAtom())
}
public addGroup(group: AdvancedSearchQueryGroup) {
group.children.push({
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.Or,
children: [this.newAtom()],
})
}
public remove(
parent: AdvancedSearchQueryGroup,
element: AdvancedSearchQueryElement
) {
parent.children = parent.children.filter((child) => child !== element)
}
public startOver() {
this.unreadable = false
this.root = this.emptyRoot()
}
public apply() {
this.queryApplied.emit(this.generatedQuery)
this.activeModal.close()
}
public cancel() {
this.activeModal.close()
}
}
@@ -17,6 +17,12 @@
}
</select>
}
@if (advancedSearchEditorAvailable) {
<button class="btn btn-sm btn-outline-primary" type="button" (click)="openAdvancedSearchEditor()"
title="Edit query" i18n-title [disabled]="disabled">
<i-bs name="sliders"></i-bs>
</button>
}
@if (_textFilter) {
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()" aria-label="Clear search" i18n-aria-label>
<i-bs width="1em" height="1em" name="x"></i-bs>
@@ -12,6 +12,8 @@ import {
NgbDatepickerModule,
NgbDropdownItem,
NgbDropdownModule,
NgbModal,
NgbModalRef,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
@@ -2515,4 +2517,45 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toEqual('help ')
})
it('should open the advanced search editor with the current query and apply the result', () => {
const modalService: NgbModal = TestBed.inject(NgbModal)
let modal: NgbModalRef
modalService.activeInstances.subscribe(
(instances) => (modal = instances[0])
)
component.textFilterTarget = 'fulltext-query'
component.updateTextFilter('title:invoice')
fixture.detectChanges()
const editorButton = fixture.debugElement.query(
By.css('button[title="Edit query"]')
)
expect(editorButton).not.toBeNull()
editorButton.triggerEventHandler('click')
fixture.detectChanges()
expect(modal.componentInstance.query).toEqual('title:invoice')
const rulesSpy = jest.spyOn(component.filterRulesChange, 'next')
modal.componentInstance.queryApplied.emit('title:invoice AND NOT tag:paid')
expect(component.textFilter).toEqual('title:invoice AND NOT tag:paid')
expect(documentService.searchQuery).toEqual(
'title:invoice AND NOT tag:paid'
)
expect(rulesSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'title:invoice AND NOT tag:paid',
},
])
})
it('should not offer the advanced search editor for other targets', () => {
component.textFilterTarget = 'title-content'
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('button[title="Edit query"]'))
).toBeNull()
})
})
@@ -15,12 +15,13 @@ import {
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbDropdownModule,
NgbModal,
NgbTypeahead,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { Observable, Subject, from } from 'rxjs'
import { first, Observable, Subject, from } from 'rxjs'
import {
catchError,
debounceTime,
@@ -121,6 +122,7 @@ import {
PermissionsFilterDropdownComponent,
PermissionsSelectionModel,
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
import { AdvancedSearchDialogComponent } from '../../common/advanced-search-dialog/advanced-search-dialog.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
const TEXT_FILTER_TARGET_TITLE = 'title'
@@ -286,6 +288,7 @@ export class FilterEditorComponent
permissionsService = inject(PermissionsService)
private customFieldService = inject(CustomFieldsService)
private searchService = inject(SearchService)
private modalService = inject(NgbModal)
generateFilterName() {
if (this.filterRules.length == 1) {
@@ -1372,6 +1375,23 @@ export class FilterEditorComponent
}
}
get advancedSearchEditorAvailable(): boolean {
return this.textFilterTarget === TEXT_FILTER_TARGET_FULLTEXT_QUERY
}
openAdvancedSearchEditor() {
const modal = this.modalService.open(AdvancedSearchDialogComponent, {
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.query = this._textFilter ?? ''
modal.componentInstance.queryApplied
.pipe(first())
.subscribe((query: string) => {
this.updateTextFilter(query)
})
}
textFilterKeydown(event: KeyboardEvent) {
if (event.key == 'Enter') {
if (event.defaultPrevented) {
@@ -0,0 +1,262 @@
// Fields and forms documented in docs/usage.md > "Document searches"
export enum AdvancedSearchField {
Any = '',
Title = 'title',
Content = 'content',
OriginalFilename = 'original_filename',
NoteText = 'notes.note',
NoteAuthor = 'notes.user',
CustomFieldName = 'custom_fields.name',
CustomFieldValue = 'custom_fields.value',
Correspondent = 'correspondent',
DocumentType = 'document_type',
StoragePath = 'storage_path',
Tag = 'tag',
ASN = 'asn',
PageCount = 'page_count',
NumNotes = 'num_notes',
Created = 'created',
Added = 'added',
Modified = 'modified',
Checksum = 'checksum',
}
export enum AdvancedSearchFieldKind {
Text = 'text',
Number = 'number',
Date = 'date',
Checksum = 'checksum',
}
export const ADVANCED_SEARCH_FIELD_KINDS: Record<
AdvancedSearchField,
AdvancedSearchFieldKind
> = {
[AdvancedSearchField.Any]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.Title]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.Content]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.OriginalFilename]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.NoteText]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.NoteAuthor]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.CustomFieldName]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.CustomFieldValue]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.Correspondent]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.DocumentType]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.StoragePath]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.Tag]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.ASN]: AdvancedSearchFieldKind.Number,
[AdvancedSearchField.PageCount]: AdvancedSearchFieldKind.Number,
[AdvancedSearchField.NumNotes]: AdvancedSearchFieldKind.Number,
[AdvancedSearchField.Created]: AdvancedSearchFieldKind.Date,
[AdvancedSearchField.Added]: AdvancedSearchFieldKind.Date,
[AdvancedSearchField.Modified]: AdvancedSearchFieldKind.Date,
[AdvancedSearchField.Checksum]: AdvancedSearchFieldKind.Checksum,
}
export enum AdvancedSearchOperator {
AllWords = 'all',
AnyWord = 'any',
Phrase = 'phrase',
StartsWith = 'prefix',
Equals = 'eq',
AtLeast = 'gte',
AtMost = 'lte',
Between = 'between',
DateKeyword = 'keyword',
WithinLast = 'within',
}
export const ADVANCED_SEARCH_OPERATORS_BY_KIND: Record<
AdvancedSearchFieldKind,
AdvancedSearchOperator[]
> = {
[AdvancedSearchFieldKind.Text]: [
AdvancedSearchOperator.AllWords,
AdvancedSearchOperator.AnyWord,
AdvancedSearchOperator.Phrase,
AdvancedSearchOperator.StartsWith,
],
[AdvancedSearchFieldKind.Number]: [
AdvancedSearchOperator.Equals,
AdvancedSearchOperator.AtLeast,
AdvancedSearchOperator.AtMost,
AdvancedSearchOperator.Between,
],
[AdvancedSearchFieldKind.Date]: [
AdvancedSearchOperator.DateKeyword,
AdvancedSearchOperator.WithinLast,
AdvancedSearchOperator.AtLeast,
AdvancedSearchOperator.AtMost,
AdvancedSearchOperator.Between,
],
[AdvancedSearchFieldKind.Checksum]: [AdvancedSearchOperator.StartsWith],
}
export const ADVANCED_SEARCH_DATE_KEYWORDS = [
'today',
'yesterday',
'tomorrow',
'previous week',
'this month',
'previous month',
'previous quarter',
'this year',
'previous year',
] as const
export type AdvancedSearchDateKeyword =
(typeof ADVANCED_SEARCH_DATE_KEYWORDS)[number]
export enum AdvancedSearchDateUnit {
Day = 'day',
Week = 'week',
Month = 'month',
Year = 'year',
}
export enum AdvancedSearchLogicalOperator {
And = 'AND',
Or = 'OR',
Not = 'NOT',
}
export enum AdvancedSearchQueryElementType {
Atom = 'atom',
Group = 'group',
}
export interface AdvancedSearchQueryAtom {
type: AdvancedSearchQueryElementType.Atom
field: AdvancedSearchField
operator: AdvancedSearchOperator
value?: string
valueTo?: string
unit?: AdvancedSearchDateUnit // for WithinLast
}
export interface AdvancedSearchQueryGroup {
type: AdvancedSearchQueryElementType.Group
operator: AdvancedSearchLogicalOperator
children: AdvancedSearchQueryElement[]
}
export type AdvancedSearchQueryElement =
AdvancedSearchQueryAtom | AdvancedSearchQueryGroup
export const ADVANCED_SEARCH_MAX_DEPTH = 2
export const ADVANCED_SEARCH_MAX_ATOMS = 10
export const ADVANCED_SEARCH_FIELD_LABELS: Record<AdvancedSearchField, string> =
{
[AdvancedSearchField.Any]: $localize`Any field`,
[AdvancedSearchField.Title]: $localize`Title`,
[AdvancedSearchField.Content]: $localize`Content`,
[AdvancedSearchField.OriginalFilename]: $localize`File name`,
[AdvancedSearchField.NoteText]: $localize`Note text`,
[AdvancedSearchField.NoteAuthor]: $localize`Note author`,
[AdvancedSearchField.CustomFieldName]: $localize`Custom field name`,
[AdvancedSearchField.CustomFieldValue]: $localize`Custom field value`,
[AdvancedSearchField.Correspondent]: $localize`Correspondent name`,
[AdvancedSearchField.DocumentType]: $localize`Document type name`,
[AdvancedSearchField.StoragePath]: $localize`Storage path name`,
[AdvancedSearchField.Tag]: $localize`Tag name`,
[AdvancedSearchField.ASN]: $localize`ASN`,
[AdvancedSearchField.PageCount]: $localize`Pages`,
[AdvancedSearchField.NumNotes]: $localize`Number of notes`,
[AdvancedSearchField.Created]: $localize`Created`,
[AdvancedSearchField.Added]: $localize`Added`,
[AdvancedSearchField.Modified]: $localize`Modified`,
[AdvancedSearchField.Checksum]: $localize`Checksum`,
}
export const ADVANCED_SEARCH_FIELD_GROUPS: {
label: string
fields: AdvancedSearchField[]
}[] = [
{
label: $localize`Text`,
fields: [
AdvancedSearchField.Any,
AdvancedSearchField.Title,
AdvancedSearchField.Content,
AdvancedSearchField.OriginalFilename,
AdvancedSearchField.NoteText,
AdvancedSearchField.NoteAuthor,
AdvancedSearchField.CustomFieldName,
AdvancedSearchField.CustomFieldValue,
],
},
{
label: $localize`Names`,
fields: [
AdvancedSearchField.Correspondent,
AdvancedSearchField.DocumentType,
AdvancedSearchField.StoragePath,
AdvancedSearchField.Tag,
],
},
{
label: $localize`Numbers`,
fields: [
AdvancedSearchField.ASN,
AdvancedSearchField.PageCount,
AdvancedSearchField.NumNotes,
],
},
{
label: $localize`Dates`,
fields: [
AdvancedSearchField.Created,
AdvancedSearchField.Added,
AdvancedSearchField.Modified,
],
},
{ label: $localize`Other`, fields: [AdvancedSearchField.Checksum] },
]
export const ADVANCED_SEARCH_OPERATOR_LABELS: Record<
AdvancedSearchOperator,
string
> = {
[AdvancedSearchOperator.AllWords]: $localize`contains all words`,
[AdvancedSearchOperator.AnyWord]: $localize`contains any word`,
[AdvancedSearchOperator.Phrase]: $localize`contains the phrase`,
[AdvancedSearchOperator.StartsWith]: $localize`starts with`,
[AdvancedSearchOperator.Equals]: $localize`is`,
[AdvancedSearchOperator.AtLeast]: $localize`is at least`,
[AdvancedSearchOperator.AtMost]: $localize`is at most`,
[AdvancedSearchOperator.Between]: $localize`is between`,
[AdvancedSearchOperator.DateKeyword]: $localize`is`,
[AdvancedSearchOperator.WithinLast]: $localize`is within the last`,
}
// Comparing dates reads differently than comparing counts
export const ADVANCED_SEARCH_DATE_OPERATOR_LABELS: Partial<
Record<AdvancedSearchOperator, string>
> = {
[AdvancedSearchOperator.AtLeast]: $localize`is on or after`,
[AdvancedSearchOperator.AtMost]: $localize`is on or before`,
}
export const ADVANCED_SEARCH_DATE_KEYWORD_LABELS: Record<string, string> = {
today: $localize`today`,
yesterday: $localize`yesterday`,
tomorrow: $localize`tomorrow`,
'previous week': $localize`previous week`,
'this month': $localize`this month`,
'previous month': $localize`previous month`,
'previous quarter': $localize`previous quarter`,
'this year': $localize`this year`,
'previous year': $localize`previous year`,
}
export const ADVANCED_SEARCH_DATE_UNIT_LABELS: Record<
AdvancedSearchDateUnit,
string
> = {
[AdvancedSearchDateUnit.Day]: $localize`days`,
[AdvancedSearchDateUnit.Week]: $localize`weeks`,
[AdvancedSearchDateUnit.Month]: $localize`months`,
[AdvancedSearchDateUnit.Year]: $localize`years`,
}
@@ -0,0 +1,515 @@
import {
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from '../data/advanced-search-query'
import {
parseAdvancedSearchQuery,
serializeAdvancedSearchQuery,
} from './advanced-search-query'
const atom = (
field: AdvancedSearchField,
operator: AdvancedSearchOperator,
value?: string,
extra: Partial<AdvancedSearchQueryAtom> = {}
): AdvancedSearchQueryAtom => ({
type: AdvancedSearchQueryElementType.Atom,
field,
operator,
value,
...extra,
})
const group = (
operator: AdvancedSearchLogicalOperator,
...children: AdvancedSearchQueryElement[]
): AdvancedSearchQueryGroup => ({
type: AdvancedSearchQueryElementType.Group,
operator,
children,
})
const { And, Or, Not } = AdvancedSearchLogicalOperator
describe('serializeAdvancedSearchQuery', () => {
describe('text fields', () => {
it.each([
[AdvancedSearchOperator.AllWords, 'invoice', 'title:invoice'],
[
AdvancedSearchOperator.AllWords,
' invoice unpaid ',
'title:invoice AND title:unpaid',
],
[
AdvancedSearchOperator.AnyWord,
'invoice unpaid',
'title:invoice OR title:unpaid',
],
[
AdvancedSearchOperator.Phrase,
'quick brown fox',
'title:"quick brown fox"',
],
[AdvancedSearchOperator.Phrase, 'say "hi"', 'title:"say hi"'],
[AdvancedSearchOperator.StartsWith, 'invoi', 'title:invoi*'],
[AdvancedSearchOperator.StartsWith, 'in*v?oi', 'title:invoi*'],
])('%s %j writes %s', (operator, value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Title, operator, value)
)
).toBe(expected)
})
it('writes bare words for the Any field', () => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Any, AdvancedSearchOperator.AllWords, 'a b')
)
).toBe('a AND b')
})
it.each([
['A-1312/99', 'custom_fields.value:A-1312/99'],
["O'Brien", "custom_fields.value:O'Brien"],
["'quoted'", `custom_fields.value:"'quoted'"`],
['foo:bar', 'custom_fields.value:"foo:bar"'],
['(x)', 'custom_fields.value:"(x)"'],
['2024*', 'custom_fields.value:"2024*"'],
['a,b', 'custom_fields.value:"a,b"'],
['OR', 'custom_fields.value:"OR"'],
['or', 'custom_fields.value:or'],
])(
'quotes %j only when the grammar would read it as syntax',
(value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.CustomFieldValue,
AdvancedSearchOperator.AllWords,
value
)
)
).toBe(expected)
}
)
it('uses the dotted names for custom fields', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
atom(
AdvancedSearchField.CustomFieldName,
AdvancedSearchOperator.Phrase,
'status'
),
atom(
AdvancedSearchField.CustomFieldValue,
AdvancedSearchOperator.AllWords,
'paid'
)
)
)
).toBe('custom_fields.name:"status" AND custom_fields.value:paid')
})
it('uses the dotted names for notes', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
atom(
AdvancedSearchField.NoteText,
AdvancedSearchOperator.AllWords,
'call'
),
atom(
AdvancedSearchField.NoteAuthor,
AdvancedSearchOperator.AllWords,
'alice'
)
)
)
).toBe('notes.note:call AND notes.user:alice')
})
it.each([
[AdvancedSearchOperator.AllWords, ''],
[AdvancedSearchOperator.AllWords, ' '],
[AdvancedSearchOperator.AllWords, '!! --'],
[AdvancedSearchOperator.Phrase, '""'],
[AdvancedSearchOperator.StartsWith, 'two words'],
[AdvancedSearchOperator.StartsWith, '***'],
[AdvancedSearchOperator.StartsWith, undefined],
])('leaves out %s %j', (operator, value) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Title, operator, value)
)
).toBe('')
})
})
describe('checksum', () => {
it('lowercases the prefix', () => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Checksum,
AdvancedSearchOperator.StartsWith,
'9F86D081'
)
)
).toBe('checksum:9f86d081*')
})
})
describe('number fields', () => {
it.each([
[AdvancedSearchOperator.Equals, '42', undefined, 'asn:42'],
[AdvancedSearchOperator.AtLeast, '50', undefined, 'asn:[50 to]'],
[AdvancedSearchOperator.AtMost, '50', undefined, 'asn:[to 50]'],
[AdvancedSearchOperator.Between, '50', '150', 'asn:[50 to 150]'],
[AdvancedSearchOperator.Equals, '4.2', undefined, ''],
[AdvancedSearchOperator.Equals, '-1', undefined, ''],
[AdvancedSearchOperator.Equals, '2024-01-01', undefined, ''],
[AdvancedSearchOperator.Between, '50', '', ''],
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.ASN, operator, value, { valueTo })
)
).toBe(expected)
})
})
describe('date fields', () => {
it.each([
['today', 'added:today'],
['previous month', 'added:"previous month"'],
['last tuesday', ''],
['', ''],
])('keyword %j writes %j', (value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.DateKeyword,
value
)
)
).toBe(expected)
})
it.each([
['1', AdvancedSearchDateUnit.Day, 'added:[-1 day to now]'],
['3', AdvancedSearchDateUnit.Month, 'added:[-3 months to now]'],
['2', AdvancedSearchDateUnit.Week, 'added:[-2 weeks to now]'],
['0', AdvancedSearchDateUnit.Year, ''],
['1.5', AdvancedSearchDateUnit.Year, ''],
['3', undefined, ''],
['3', 'fortnight' as AdvancedSearchDateUnit, ''],
])('within the last %j %j writes %j', (value, unit, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
value,
{
unit,
}
)
)
).toBe(expected)
})
it.each([
[
AdvancedSearchOperator.AtLeast,
'2024-01-01',
undefined,
'created:[2024-01-01 to]',
],
[
AdvancedSearchOperator.AtMost,
'2024-01-01',
undefined,
'created:[to 2024-01-01]',
],
[
AdvancedSearchOperator.Between,
'2024-01-01',
'2024-03-31',
'created:[2024-01-01 to 2024-03-31]',
],
[AdvancedSearchOperator.AtLeast, '2024', undefined, ''],
[AdvancedSearchOperator.Between, '2024-01-01', 'now', ''],
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Created, operator, value, { valueTo })
)
).toBe(expected)
})
})
describe('groups', () => {
const invoice = atom(
AdvancedSearchField.Content,
AdvancedSearchOperator.AllWords,
'invoice'
)
const letter = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'letter'
)
const paid = atom(
AdvancedSearchField.Tag,
AdvancedSearchOperator.AllWords,
'paid'
)
const twoWords = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'a b'
)
const anyWords = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AnyWord,
'a b'
)
const empty = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
''
)
it.each([
['an empty group', group(And), ''],
['a group of empty atoms', group(Or, empty, group(And, empty)), ''],
[
'a single child without parentheses',
group(Or, invoice),
'content:invoice',
],
['All', group(And, invoice, letter), 'content:invoice AND title:letter'],
['Any', group(Or, invoice, letter), 'content:invoice OR title:letter'],
[
'skipped empty atoms',
group(And, empty, invoice, empty),
'content:invoice',
],
['Not with one child', group(Not, paid), 'NOT tag:paid'],
[
'Not as none of',
group(Not, paid, letter),
'NOT (tag:paid OR title:letter)',
],
[
'Not with a compound child',
group(Not, twoWords),
'NOT (title:a AND title:b)',
],
[
'Not inside All',
group(And, invoice, group(Not, paid)),
'content:invoice AND NOT tag:paid',
],
[
'Not inside Any',
group(Or, invoice, group(Not, paid)),
'content:invoice OR NOT tag:paid',
],
[
'Any inside All',
group(And, invoice, group(Or, letter, paid)),
'content:invoice AND (title:letter OR tag:paid)',
],
[
'All inside Any',
group(Or, invoice, group(And, letter, paid)),
'content:invoice OR (title:letter AND tag:paid)',
],
[
'All inside All flattened',
group(And, invoice, group(And, letter, paid)),
'content:invoice AND title:letter AND tag:paid',
],
[
'an all-words atom inside Any',
group(Or, invoice, twoWords),
'content:invoice OR (title:a AND title:b)',
],
[
'an any-word atom inside All',
group(And, invoice, anyWords),
'content:invoice AND (title:a OR title:b)',
],
[
'an all-words atom inside Not with siblings',
group(Not, paid, twoWords),
'NOT (tag:paid OR (title:a AND title:b))',
],
])('writes %s', (_, tree, expected) => {
expect(serializeAdvancedSearchQuery(tree)).toBe(expected)
})
it('writes the mockup example', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
invoice,
group(
Or,
atom(
AdvancedSearchField.Correspondent,
AdvancedSearchOperator.Phrase,
'acme corp'
),
atom(
AdvancedSearchField.Content,
AdvancedSearchOperator.Phrase,
'acme corporation'
)
),
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
'3',
{
unit: AdvancedSearchDateUnit.Month,
}
),
group(Not, paid)
)
)
).toBe(
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid'
)
})
})
})
describe('parseAdvancedSearchQuery', () => {
const canonical = [
'title:invoice',
'title:invoice AND title:unpaid',
'content:invoice OR content:receipt',
'a AND b',
'title:"quick brown fox"',
'title:invoi*',
"custom_fields.value:O'Brien",
'custom_fields.value:"foo:bar"',
'custom_fields.name:"status" AND custom_fields.value:paid',
'notes.note:call AND notes.user:alice',
'checksum:9f86d081*',
'asn:42',
'asn:[50 to 150]',
'asn:[50 to]',
'asn:[to 50]',
'page_count:[10 to]',
'added:today',
'added:"previous month"',
'added:[-1 day to now]',
'added:[-3 months to now]',
'created:[2024-01-01 to 2024-03-31]',
'created:[2024-01-01 to]',
'created:[to 2024-01-01]',
'NOT tag:paid',
'NOT (tag:paid OR title:letter)',
'NOT (title:a AND title:b)',
'content:invoice OR NOT tag:paid',
'content:invoice AND (title:letter OR tag:paid)',
'content:invoice OR (title:letter AND tag:paid)',
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid',
]
it.each(canonical)('reads back %s unchanged', (query) => {
const tree = parseAdvancedSearchQuery(query)
expect(tree).not.toBeNull()
expect(serializeAdvancedSearchQuery(tree)).toBe(query)
})
it('reads surrounding whitespace', () => {
expect(
serializeAdvancedSearchQuery(
parseAdvancedSearchQuery(' title:invoice ')
)
).toBe('title:invoice')
})
it('puts the words of one condition back together', () => {
expect(parseAdvancedSearchQuery('title:invoice AND title:unpaid')).toEqual(
group(
And,
atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'invoice unpaid'
)
)
)
})
it('keeps words of different fields apart', () => {
expect(parseAdvancedSearchQuery('title:a OR content:b')).toEqual(
group(
Or,
atom(AdvancedSearchField.Title, AdvancedSearchOperator.AllWords, 'a'),
atom(AdvancedSearchField.Content, AdvancedSearchOperator.AllWords, 'b')
)
)
})
it('reads a range as its condition', () => {
expect(parseAdvancedSearchQuery('added:[-3 months to now]')).toEqual(
group(
And,
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
'3',
{
unit: AdvancedSearchDateUnit.Month,
}
)
)
)
})
it.each([
['', 'nothing'],
[' ', 'whitespace'],
['type:invoice', 'a field alias'],
['notes:call', 'a bare notes prefix'],
['unknown:x', 'an unknown field'],
['title:a AND content:b OR title:c', 'AND and OR mixed at one level'],
['title:a b', 'an implicit AND'],
['title:"unterminated', 'an unterminated phrase'],
['asn:[50 to', 'an unterminated range'],
['title:a*b', 'a wildcard in the middle'],
['title:invoice^2', 'a boost'],
['asn:[50 to abc]', 'a non-numeric bound'],
['asn:invoice', 'a word on a number field'],
['created:[2024 to 2025]', 'a year-only date range'],
['added:"last tuesday"', 'a date keyword paperless does not have'],
['title:[a to b]', 'a range on a text field'],
['checksum:9f86d081', 'a checksum without a wildcard'],
['(title:invoice)', 'parentheses the editor would not write'],
['title:invoice AND', 'a trailing operator'],
['title:"a"b', 'a value running into the next'],
['ADDED:today', 'an uppercase field name'],
])('leaves %s alone, having %s', (query) => {
expect(parseAdvancedSearchQuery(query)).toBeNull()
})
})
@@ -0,0 +1,500 @@
import {
ADVANCED_SEARCH_DATE_KEYWORDS,
ADVANCED_SEARCH_FIELD_KINDS,
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchFieldKind,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from '../data/advanced-search-query'
// Anything the query grammar would read as syntax rather than as a word
const SYNTAX_CHARS = /[\s():"[\]*?,{}^~\\]/
const SYNTAX_CHARS_GLOBAL = new RegExp(SYNTAX_CHARS, 'g')
// A word wrapped in single quotes is also syntax, an apostrophe inside one is not
const EDGE_SINGLE_QUOTE = /^'|'$/
const RESERVED_WORDS = /^(AND|OR|NOT|TO)$/
const HAS_WORD_CHAR = /[\p{L}\p{N}]/u
const WHOLE_NUMBER = /^\d+$/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
interface Serialized {
text: string
// The operator joining the top level of `text`, null when it is self-delimiting
join:
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or | null
}
const prefix = (field: AdvancedSearchField) => (field ? `${field}:` : '')
const quote = (text: string) => `"${text.replace(/"/g, '')}"`
const words = (value: string) =>
value
.trim()
.split(/\s+/)
.filter((word) => HAS_WORD_CHAR.test(word))
const word = (w: string) =>
SYNTAX_CHARS.test(w) || EDGE_SINGLE_QUOTE.test(w) || RESERVED_WORDS.test(w)
? quote(w)
: w
const atomic = (text: string): Serialized => ({ text, join: null })
function serializeWords(
atom: AdvancedSearchQueryAtom,
join: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
): Serialized {
// A field applies only to the word right after it, so repeat it per word
const terms = words(atom.value ?? '').map(
(w) => `${prefix(atom.field)}${word(w)}`
)
if (terms.length === 0) return null
if (terms.length === 1) return atomic(terms[0])
return { text: terms.join(` ${join} `), join }
}
function serializeRange(
atom: AdvancedSearchQueryAtom,
isValid: (v: string) => boolean
): Serialized {
const lo = atom.value?.trim() ?? ''
const hi = atom.valueTo?.trim() ?? ''
const field = prefix(atom.field)
switch (atom.operator) {
case AdvancedSearchOperator.Equals:
return isValid(lo) ? atomic(`${field}${lo}`) : null
case AdvancedSearchOperator.AtLeast:
return isValid(lo) ? atomic(`${field}[${lo} to]`) : null
case AdvancedSearchOperator.AtMost:
return isValid(lo) ? atomic(`${field}[to ${lo}]`) : null
case AdvancedSearchOperator.Between:
return isValid(lo) && isValid(hi)
? atomic(`${field}[${lo} to ${hi}]`)
: null
}
return null
}
function serializeAtom(atom: AdvancedSearchQueryAtom): Serialized {
const field = prefix(atom.field)
const value = atom.value?.trim() ?? ''
switch (atom.operator) {
case AdvancedSearchOperator.AllWords:
return serializeWords(atom, AdvancedSearchLogicalOperator.And)
case AdvancedSearchOperator.AnyWord:
return serializeWords(atom, AdvancedSearchLogicalOperator.Or)
case AdvancedSearchOperator.Phrase:
return HAS_WORD_CHAR.test(value)
? atomic(`${field}${quote(value)}`)
: null
case AdvancedSearchOperator.StartsWith: {
if (/\s/.test(value)) return null
let stem = value.replace(SYNTAX_CHARS_GLOBAL, '')
if (!HAS_WORD_CHAR.test(stem)) return null
// checksum is indexed as-is, in lowercase
if (atom.field === AdvancedSearchField.Checksum) {
stem = stem.toLowerCase()
}
return atomic(`${field}${stem}*`)
}
case AdvancedSearchOperator.DateKeyword:
return (ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]).includes(
value
)
? atomic(`${field}${word(value)}`)
: null
case AdvancedSearchOperator.WithinLast: {
const amount = parseInt(value, 10)
const units = Object.values(AdvancedSearchDateUnit) as string[]
if (!WHOLE_NUMBER.test(value) || amount < 1) return null
if (!units.includes(atom.unit)) return null
const unit = amount === 1 ? atom.unit : `${atom.unit}s`
return atomic(`${field}[-${amount} ${unit} to now]`)
}
case AdvancedSearchOperator.Equals:
case AdvancedSearchOperator.AtLeast:
case AdvancedSearchOperator.AtMost:
case AdvancedSearchOperator.Between:
return serializeRange(
atom,
ADVANCED_SEARCH_FIELD_KINDS[atom.field] === AdvancedSearchFieldKind.Date
? (v) => ISO_DATE.test(v)
: (v) => WHOLE_NUMBER.test(v)
)
}
return null
}
function wrap(
child: Serialized,
parentJoin: AdvancedSearchLogicalOperator
): string {
return child.join === null || child.join === parentJoin
? child.text
: `(${child.text})`
}
function serializeGroup(group: AdvancedSearchQueryGroup): Serialized {
const children = group.children.map(serializeElement).filter(Boolean)
if (children.length === 0) return null
if (group.operator === AdvancedSearchLogicalOperator.Not) {
// A Not group matches documents matching none of its children
if (children.length === 1 && children[0].join === null) {
return atomic(`NOT ${children[0].text}`)
}
const inner =
children.length === 1
? children[0].text
: children
.map((c) => wrap(c, AdvancedSearchLogicalOperator.Or))
.join(' OR ')
return atomic(`NOT (${inner})`)
}
if (children.length === 1) return children[0]
return {
text: children
.map((c) => wrap(c, group.operator))
.join(` ${group.operator} `),
join: group.operator,
}
}
function serializeElement(element: AdvancedSearchQueryElement): Serialized {
return element.type === AdvancedSearchQueryElementType.Group
? serializeGroup(element)
: serializeAtom(element)
}
/**
* Writes an editor tree as a full-text query. Atoms that are not filled in
* (or not valid) are left out, and empty groups with them.
*/
export function serializeAdvancedSearchQuery(
element: AdvancedSearchQueryElement
): string {
return serializeElement(element)?.text ?? ''
}
// --- Reading a query back into the editor --------------------------------
//
// Deliberately narrow: this reads the forms serializeAdvancedSearchQuery
// writes, and nothing else. A query it cannot read is not a failure, it just
// stays text, so there is never a lossy or surprising conversion. The final
// round-trip check below is what holds that promise: a tree is only returned
// when writing it out again reproduces the query exactly.
const RELATIVE_BOUND = /^-(\d+) (day|week|month|year)s?$/
const FIELD_PREFIX = /^([a-z_]+(?:\.[a-z_]+)?):/
const KEYWORD_TOKEN = /^(AND|OR|NOT)(?=[\s(]|$)/
// Either bound may be missing: [50 to 150], [50 to], [to 50]
const RANGE_BOUNDS = /^(?:(.+?) )?to(?: (.+))?$/
const TRAILING_WILDCARD = /^([^*?]+)\*$/
class UnreadableQuery extends Error {}
interface Token {
type: 'term' | 'AND' | 'OR' | 'NOT' | '(' | ')'
field?: string
value?: string
quoted?: boolean
range?: boolean
}
// A parsed element, plus what it takes to merge the per-word terms the
// serializer writes for "contains all words" back into a single condition
interface Parsed {
element: AdvancedSearchQueryElement
word?: { field: AdvancedSearchField; text: string }
}
function tokenize(query: string): Token[] {
const tokens: Token[] = []
let i = 0
while (i < query.length) {
const rest = query.slice(i)
if (/^\s/.test(rest)) {
i++
continue
}
if (rest[0] === '(' || rest[0] === ')') {
tokens.push({ type: rest[0] as '(' | ')' })
i++
continue
}
const keyword = KEYWORD_TOKEN.exec(rest)
if (keyword) {
tokens.push({ type: keyword[1] as 'AND' | 'OR' | 'NOT' })
i += keyword[1].length
continue
}
const fieldMatch = FIELD_PREFIX.exec(rest)
const field = fieldMatch ? fieldMatch[1] : ''
i += fieldMatch ? fieldMatch[0].length : 0
const value = query.slice(i)
if (value.startsWith('"')) {
const end = query.indexOf('"', i + 1)
if (end < 0) throw new UnreadableQuery()
tokens.push({
type: 'term',
field,
value: query.slice(i + 1, end),
quoted: true,
})
i = end + 1
} else if (value.startsWith('[')) {
const end = query.indexOf(']', i + 1)
if (end < 0) throw new UnreadableQuery()
tokens.push({
type: 'term',
field,
value: query.slice(i + 1, end),
range: true,
})
i = end + 1
} else {
const bare = /^[^\s()]+/.exec(value)
if (!bare) throw new UnreadableQuery()
tokens.push({ type: 'term', field, value: bare[0] })
i += bare[0].length
}
// Nothing may run on directly after a value, e.g. title:"a"b
if (i < query.length && !/[\s)]/.test(query[i])) throw new UnreadableQuery()
}
return tokens
}
function resolveField(name: string): AdvancedSearchField {
const fields = Object.values(AdvancedSearchField) as string[]
// Aliases (type:, path:, notes:) are left to the text box on purpose:
// reading one would mean rewriting the user's query as it was read
if (!fields.includes(name)) throw new UnreadableQuery()
return name as AdvancedSearchField
}
function atomFrom(
field: AdvancedSearchField,
operator: AdvancedSearchOperator,
value: string,
extra: Partial<AdvancedSearchQueryAtom> = {}
): AdvancedSearchQueryAtom {
return {
type: AdvancedSearchQueryElementType.Atom,
field,
operator,
value,
...extra,
}
}
function parseRange(
field: AdvancedSearchField,
kind: AdvancedSearchFieldKind,
body: string
): AdvancedSearchQueryAtom {
const bounds = RANGE_BOUNDS.exec(body)
if (!bounds) throw new UnreadableQuery()
const lo = bounds[1] ?? ''
const hi = bounds[2] ?? ''
if (kind === AdvancedSearchFieldKind.Date) {
const relative = RELATIVE_BOUND.exec(lo)
if (relative && hi === 'now') {
return atomFrom(field, AdvancedSearchOperator.WithinLast, relative[1], {
unit: relative[2] as AdvancedSearchDateUnit,
})
}
} else if (kind !== AdvancedSearchFieldKind.Number) {
throw new UnreadableQuery()
}
const isValid =
kind === AdvancedSearchFieldKind.Date
? (v: string) => ISO_DATE.test(v)
: (v: string) => WHOLE_NUMBER.test(v)
if (lo && hi) {
if (!isValid(lo) || !isValid(hi)) throw new UnreadableQuery()
return atomFrom(field, AdvancedSearchOperator.Between, lo, { valueTo: hi })
}
if (lo && isValid(lo))
return atomFrom(field, AdvancedSearchOperator.AtLeast, lo)
if (hi && isValid(hi))
return atomFrom(field, AdvancedSearchOperator.AtMost, hi)
throw new UnreadableQuery()
}
function parseTerm(token: Token): Parsed {
const field = resolveField(token.field)
const kind = ADVANCED_SEARCH_FIELD_KINDS[field]
const value = token.value
const isKeyword = (
ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]
).includes(value)
if (token.range) {
return { element: parseRange(field, kind, value) }
}
if (kind === AdvancedSearchFieldKind.Date) {
if (!isKeyword) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.DateKeyword, value),
}
}
if (token.quoted) {
if (kind !== AdvancedSearchFieldKind.Text) throw new UnreadableQuery()
return { element: atomFrom(field, AdvancedSearchOperator.Phrase, value) }
}
const wildcard = TRAILING_WILDCARD.exec(value)
if (wildcard) {
if (kind === AdvancedSearchFieldKind.Number) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.StartsWith, wildcard[1]),
}
}
if (kind === AdvancedSearchFieldKind.Number) {
if (!WHOLE_NUMBER.test(value)) throw new UnreadableQuery()
return { element: atomFrom(field, AdvancedSearchOperator.Equals, value) }
}
// A checksum is only ever searched by its first characters
if (kind === AdvancedSearchFieldKind.Checksum) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.AllWords, value),
word: { field, text: value },
}
}
// The serializer repeats the field for every word, because a field applies
// only to the word after it. Put those back together into one condition.
function mergeWords(
parts: Parsed[],
operator: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
): AdvancedSearchQueryElement[] {
const merged: AdvancedSearchQueryElement[] = []
for (let i = 0; i < parts.length; i++) {
const run = [parts[i]]
while (
parts[i].word &&
parts[i + 1]?.word &&
parts[i + 1].word.field === parts[i].word.field
) {
run.push(parts[++i])
}
if (run.length === 1) {
merged.push(run[0].element)
continue
}
merged.push(
atomFrom(
run[0].word.field,
operator === AdvancedSearchLogicalOperator.And
? AdvancedSearchOperator.AllWords
: AdvancedSearchOperator.AnyWord,
run.map((part) => part.word.text).join(' ')
)
)
}
return merged
}
interface Cursor {
tokens: Token[]
at: number
}
function parseExpression(cursor: Cursor): Parsed {
const parts: Parsed[] = [parseOperand(cursor)]
let operator:
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
while (
cursor.tokens[cursor.at]?.type === 'AND' ||
cursor.tokens[cursor.at]?.type === 'OR'
) {
const next = cursor.tokens[cursor.at++].type as
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
// One level mixing AND and OR is never something the editor wrote
if (operator && next !== operator) throw new UnreadableQuery()
operator = next
parts.push(parseOperand(cursor))
}
if (parts.length === 1) return parts[0]
const children = mergeWords(parts, operator)
if (children.length === 1) return { element: children[0] }
return {
element: {
type: AdvancedSearchQueryElementType.Group,
operator,
children,
},
}
}
function parseOperand(cursor: Cursor): Parsed {
const token = cursor.tokens[cursor.at++]
if (!token) throw new UnreadableQuery()
if (token.type === 'NOT') {
const child = parseOperand(cursor)
return {
element: {
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.Not,
children: [child.element],
},
}
}
if (token.type === '(') {
const inner = parseExpression(cursor)
if (cursor.tokens[cursor.at++]?.type !== ')') throw new UnreadableQuery()
return { element: inner.element }
}
if (token.type !== 'term') throw new UnreadableQuery()
return parseTerm(token)
}
/**
* Reads a query the editor could have written back into an editor tree, or
* returns null when the editor cannot show it, in which case the query stays
* text. Never returns a tree that would be written back differently.
*/
export function parseAdvancedSearchQuery(
query: string
): AdvancedSearchQueryGroup | null {
const trimmed = query?.trim() ?? ''
if (!trimmed) return null
let parsed: Parsed
try {
const cursor: Cursor = { tokens: tokenize(trimmed), at: 0 }
parsed = parseExpression(cursor)
if (cursor.at !== cursor.tokens.length) throw new UnreadableQuery()
} catch {
return null
}
const root =
parsed.element.type === AdvancedSearchQueryElementType.Group
? parsed.element
: {
type: AdvancedSearchQueryElementType.Group as const,
operator: AdvancedSearchLogicalOperator.And,
children: [parsed.element],
}
return serializeAdvancedSearchQuery(root) === trimmed ? root : null
}
+2
View File
@@ -148,6 +148,7 @@ import {
send,
shop,
slashCircle,
sliders,
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
@@ -397,6 +398,7 @@ const icons = {
send,
slashCircle,
shop,
sliders,
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,