Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ <h3 class="noMessageSelectedNotification">No Message Selected</h3>
<app-multiple-search-fields-input
*ngIf="showMultipleSearchFields && searchService.downloadProgress===null"
[currentFolder]="selectedFolder"
[searchExpression]="searchText"
(close)="showMultipleSearchFields = false"
(searchexpression)="searchFor($event)">
</app-multiple-search-fields-input>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// along with Runbox 7. If not, see <https://www.gnu.org/licenses/>.
// ---------- 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';

Expand All @@ -31,9 +31,12 @@ export class MultipleSearchFieldsInputComponent implements OnChanges {
formGroup;

@Input() currentFolder: string;
@Input() searchExpression = '';
@Output() searchexpression = new EventEmitter<string>();
@Output() close = new EventEmitter();

private lastEmittedSearchExpression = '';

constructor(
formbuilder: FormBuilder
) {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
}
}