From 9a2c2b9331dd73d5d5c02ceda2467bff990f3722 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:30:21 -0700 Subject: [PATCH] Basic advanced search query foundations --- src-ui/src/app/data/advanced-search-query.ts | 149 +++++++ .../app/utils/advanced-search-query.spec.ts | 398 ++++++++++++++++++ src-ui/src/app/utils/advanced-search-query.ts | 185 ++++++++ 3 files changed, 732 insertions(+) create mode 100644 src-ui/src/app/data/advanced-search-query.ts create mode 100644 src-ui/src/app/utils/advanced-search-query.spec.ts create mode 100644 src-ui/src/app/utils/advanced-search-query.ts diff --git a/src-ui/src/app/data/advanced-search-query.ts b/src-ui/src/app/data/advanced-search-query.ts new file mode 100644 index 000000000..5bfd7c5a3 --- /dev/null +++ b/src-ui/src/app/data/advanced-search-query.ts @@ -0,0 +1,149 @@ +// 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 + // words, phrase, number or yyyy-mm-dd date; the lower bound for Between; + // the amount for WithinLast + value?: string + // upper bound for Between + valueTo?: string + // unit for WithinLast + unit?: AdvancedSearchDateUnit +} + +export interface AdvancedSearchQueryGroup { + type: AdvancedSearchQueryElementType.Group + operator: AdvancedSearchLogicalOperator + children: AdvancedSearchQueryElement[] +} + +export type AdvancedSearchQueryElement = + AdvancedSearchQueryAtom | AdvancedSearchQueryGroup diff --git a/src-ui/src/app/utils/advanced-search-query.spec.ts b/src-ui/src/app/utils/advanced-search-query.spec.ts new file mode 100644 index 000000000..a6e9d3df0 --- /dev/null +++ b/src-ui/src/app/utils/advanced-search-query.spec.ts @@ -0,0 +1,398 @@ +import { + AdvancedSearchDateUnit, + AdvancedSearchField, + AdvancedSearchLogicalOperator, + AdvancedSearchOperator, + AdvancedSearchQueryAtom, + AdvancedSearchQueryElement, + AdvancedSearchQueryElementType, + AdvancedSearchQueryGroup, +} from '../data/advanced-search-query' +import { serializeAdvancedSearchQuery } from './advanced-search-query' + +const atom = ( + field: AdvancedSearchField, + operator: AdvancedSearchOperator, + value?: string, + extra: Partial = {} +): 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' + ) + }) + }) +}) diff --git a/src-ui/src/app/utils/advanced-search-query.ts b/src-ui/src/app/utils/advanced-search-query.ts new file mode 100644 index 000000000..d713cdfbe --- /dev/null +++ b/src-ui/src/app/utils/advanced-search-query.ts @@ -0,0 +1,185 @@ +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 ?? '' +}