From 769b3e125788f3b54d440bf5e32978070164de5a Mon Sep 17 00:00:00 2001 From: kei Date: Thu, 11 Jun 2026 07:25:59 +0200 Subject: [PATCH 1/2] test(search): cover advanced search field sync --- ...iple-search-fields-input.component.spec.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app/xapian/multiple-search-fields-input/multiple-search-fields-input.component.spec.ts b/src/app/xapian/multiple-search-fields-input/multiple-search-fields-input.component.spec.ts index a17304417..71fdf7c88 100644 --- a/src/app/xapian/multiple-search-fields-input/multiple-search-fields-input.component.spec.ts +++ b/src/app/xapian/multiple-search-fields-input/multiple-search-fields-input.component.spec.ts @@ -29,7 +29,7 @@ import { CommonModule } from '@angular/common'; import { ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { firstValueFrom } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { SimpleChange } from '@angular/core'; describe('MultipleSearchFieldsInputComponent', () => { let component: MultipleSearchFieldsInputComponent; @@ -71,4 +71,34 @@ describe('MultipleSearchFieldsInputComponent', () => { console.log(searchExpression); expect(searchExpression).toBe('folder:"Testfolder" AND subject:"testsubject"'); }); + + it('should update fields from a generated search expression', () => { + component.currentFolder = 'Inbox'; + (component as any).searchExpression = 'folder:"Inbox" AND from:"from@example.com" AND to:"to@example.com" AND subject:"Quarterly report" AND (budget) AND flag:attachment AND flag:answered AND flag:flagged AND date:202606 AND NOT flag:seen'; + + (component as any).ngOnChanges({ + searchExpression: new SimpleChange('', (component as any).searchExpression, false) + }); + + expect(component.formGroup.get('currentfolderonly').value).toBeTrue(); + expect(component.formGroup.get('from').value).toBe('from@example.com'); + expect(component.formGroup.get('to').value).toBe('to@example.com'); + expect(component.formGroup.get('subject').value).toBe('Quarterly report'); + expect(component.formGroup.get('allfieldsandcontent').value).toBe('budget'); + expect(component.formGroup.get('hasAttachment').value).toBeTrue(); + expect(component.formGroup.get('hasReply').value).toBeTrue(); + expect(component.formGroup.get('hasFlag').value).toBeTrue(); + expect(component.formGroup.get('date').value).toBe('202606'); + expect(component.formGroup.get('unreadOnly').value).toBeTrue(); + }); + + it('should show unstructured query text in the all fields input', () => { + (component as any).searchExpression = 'monthly budget'; + + (component as any).ngOnChanges({ + searchExpression: new SimpleChange('', (component as any).searchExpression, false) + }); + + expect(component.formGroup.get('allfieldsandcontent').value).toBe('monthly budget'); + }); }); From 1685b7d2926249ba4b681e56b3f6e46a98f308c6 Mon Sep 17 00:00:00 2001 From: kei Date: Thu, 11 Jun 2026 07:26:52 +0200 Subject: [PATCH 2/2] fix(search): sync advanced search fields from query --- src/app/app.component.html | 1 + .../multiple-search-fields-input.component.ts | 90 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index ec21e8698..0dedc0dfc 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -406,6 +406,7 @@

No Message Selected

diff --git a/src/app/xapian/multiple-search-fields-input/multiple-search-fields-input.component.ts b/src/app/xapian/multiple-search-fields-input/multiple-search-fields-input.component.ts index d5854fcaf..d13fb159d 100644 --- a/src/app/xapian/multiple-search-fields-input/multiple-search-fields-input.component.ts +++ b/src/app/xapian/multiple-search-fields-input/multiple-search-fields-input.component.ts @@ -17,7 +17,7 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { Component, OnChanges, Output, EventEmitter, Input } from '@angular/core'; +import { Component, OnChanges, Output, EventEmitter, Input, SimpleChanges } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { distinctUntilChanged } from 'rxjs/operators'; @@ -31,9 +31,12 @@ export class MultipleSearchFieldsInputComponent implements OnChanges { formGroup; @Input() currentFolder: string; + @Input() searchExpression = ''; @Output() searchexpression = new EventEmitter(); @Output() close = new EventEmitter(); + private lastEmittedSearchExpression = ''; + constructor( formbuilder: FormBuilder ) { @@ -58,7 +61,12 @@ export class MultipleSearchFieldsInputComponent implements OnChanges { }); } - ngOnChanges() { + ngOnChanges(changes?: SimpleChanges) { + if (changes && changes.searchExpression && this.searchExpression !== this.lastEmittedSearchExpression) { + this.updateFormFromSearchExpression(this.searchExpression || ''); + return; + } + this.enableDisableUnread(); this.buildSearchExpression(); } @@ -99,6 +107,84 @@ export class MultipleSearchFieldsInputComponent implements OnChanges { (fields.date ? (and() + 'date:' + fields.date + '') : '') + // FIXME: This parameter must come last (fields.unreadOnly ? (and() + 'NOT flag:seen') : ''); // FIXME: This parameter must also come last... + this.lastEmittedSearchExpression = searchexpression; this.searchexpression.emit(searchexpression); } + + private updateFormFromSearchExpression(searchExpression: string): void { + this.formGroup.patchValue(this.parseSearchExpression(searchExpression), { emitEvent: false }); + this.enableDisableUnread(); + } + + private parseSearchExpression(searchExpression: string) { + const fields = { + allfieldsandcontent: '', + from: '', + to: '', + subject: '', + date: '', + currentfolderonly: false, + hasAttachment: false, + hasReply: false, + hasFlag: false, + unreadOnly: false + }; + let remaining = searchExpression.trim(); + + remaining = this.consumeQuotedTerm(remaining, 'from', value => fields.from = value); + remaining = this.consumeQuotedTerm(remaining, 'to', value => fields.to = value); + remaining = this.consumeQuotedTerm(remaining, 'subject', value => fields.subject = value); + remaining = remaining.replace(/\bfolder:"([^"]*)"/g, (match, value) => { + if (value === this.currentFolder) { + fields.currentfolderonly = true; + return ' '; + } + return match; + }); + remaining = this.consumeTerm(remaining, /\bdate:([^\s)]+)/g, value => fields.date = value); + remaining = this.consumeFlag(remaining, 'attachment', () => fields.hasAttachment = true); + remaining = this.consumeFlag(remaining, 'answered', () => fields.hasReply = true); + remaining = this.consumeFlag(remaining, 'flagged', () => fields.hasFlag = true); + remaining = this.consumeTerm(remaining, /\bNOT\s+flag:seen\b/g, () => fields.unreadOnly = true); + + remaining = remaining.replace(/\(([^()]*)\)/, (_match, value) => { + fields.allfieldsandcontent = value; + return ' '; + }); + remaining = this.cleanDanglingAnd(remaining); + if (remaining) { + fields.allfieldsandcontent = fields.allfieldsandcontent + ? `${fields.allfieldsandcontent} ${remaining}` + : remaining; + } + + return fields; + } + + private consumeQuotedTerm(searchExpression: string, term: string, applyValue: (value: string) => void): string { + return this.consumeTerm(searchExpression, new RegExp(`\\b${term}:"([^"]*)"`, 'g'), applyValue); + } + + private consumeFlag(searchExpression: string, flag: string, applyValue: () => void): string { + return this.consumeTerm(searchExpression, new RegExp(`\\bflag:${flag}\\b`, 'g'), applyValue); + } + + private consumeTerm(searchExpression: string, pattern: RegExp, applyValue: (value?: string) => void): string { + return searchExpression.replace(pattern, (_match, value) => { + applyValue(value); + return ' '; + }); + } + + private cleanDanglingAnd(searchExpression: string): string { + const cleaned = searchExpression + .replace(/\s+/g, ' ') + .trim() + .replace(/^(AND\s+)+/, '') + .replace(/(\s+AND)+$/, '') + .replace(/\s+AND\s+AND\s+/g, ' AND ') + .trim(); + + return /^(AND\s*)+$/.test(cleaned) ? '' : cleaned; + } }