feat: add structured search with visual filter builder (#469)
This commit was merged in pull request #469.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
enum FilterField {
|
||||
from_,
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
size;
|
||||
|
||||
String get label => switch (this) {
|
||||
FilterField.from_ => 'From',
|
||||
FilterField.to => 'To',
|
||||
FilterField.cc => 'CC',
|
||||
FilterField.subject => 'Subject',
|
||||
FilterField.size => 'Size (bytes)',
|
||||
};
|
||||
|
||||
List<FilterComparison> get allowedComparisons => switch (this) {
|
||||
FilterField.size => [FilterComparison.over, FilterComparison.under],
|
||||
_ => [
|
||||
FilterComparison.contains,
|
||||
FilterComparison.is_,
|
||||
FilterComparison.matches,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
enum FilterComparison {
|
||||
contains,
|
||||
is_,
|
||||
matches,
|
||||
over,
|
||||
under;
|
||||
|
||||
String get label => switch (this) {
|
||||
FilterComparison.contains => 'contains',
|
||||
FilterComparison.is_ => 'is',
|
||||
FilterComparison.matches => 'matches',
|
||||
FilterComparison.over => 'over',
|
||||
FilterComparison.under => 'under',
|
||||
};
|
||||
}
|
||||
|
||||
enum FilterOperator { and_, or_ }
|
||||
|
||||
sealed class FilterNode {}
|
||||
|
||||
final class FilterLeaf extends FilterNode {
|
||||
FilterLeaf({
|
||||
required this.field,
|
||||
required this.comparison,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final FilterField field;
|
||||
final FilterComparison comparison;
|
||||
final String value;
|
||||
|
||||
FilterLeaf copyWith({
|
||||
FilterField? field,
|
||||
FilterComparison? comparison,
|
||||
String? value,
|
||||
}) =>
|
||||
FilterLeaf(
|
||||
field: field ?? this.field,
|
||||
comparison: comparison ?? this.comparison,
|
||||
value: value ?? this.value,
|
||||
);
|
||||
}
|
||||
|
||||
final class FilterGroup extends FilterNode {
|
||||
FilterGroup({required this.operator, required this.children});
|
||||
|
||||
final FilterOperator operator;
|
||||
final List<FilterNode> children;
|
||||
|
||||
bool get isEmpty => children.isEmpty;
|
||||
|
||||
FilterGroup copyWith({
|
||||
FilterOperator? operator,
|
||||
List<FilterNode>? children,
|
||||
}) =>
|
||||
FilterGroup(
|
||||
operator: operator ?? this.operator,
|
||||
children: children ?? this.children,
|
||||
);
|
||||
|
||||
static FilterGroup empty() =>
|
||||
FilterGroup(operator: FilterOperator.and_, children: []);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
|
||||
/// Converts a Sieve script (RFC 5228 subset) to a [FilterGroup] + actions,
|
||||
/// suitable for display in the visual filter editor.
|
||||
///
|
||||
/// Returns null if the script uses features outside the supported subset.
|
||||
class FilterSieveConverter {
|
||||
({FilterGroup group, List<SieveAction> actions})? parse(String script) {
|
||||
try {
|
||||
final s = _Sc(script);
|
||||
s.skip();
|
||||
if (s.peekWord() == 'require') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
_parseStringOrList(s);
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
s.skip();
|
||||
}
|
||||
if (s.peekWord() != 'if') return null;
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final node = _parseTest(s);
|
||||
if (node == null) return null;
|
||||
s.skip();
|
||||
s.expectChar('{');
|
||||
s.skip();
|
||||
final actions = <SieveAction>[];
|
||||
while (s.peek() != '}' && !s.isAtEnd) {
|
||||
final action = _parseAction(s);
|
||||
if (action == null) return null;
|
||||
actions.add(action);
|
||||
s.skip();
|
||||
}
|
||||
s.expectChar('}');
|
||||
final group = switch (node) {
|
||||
final FilterGroup g => g,
|
||||
final FilterLeaf l =>
|
||||
FilterGroup(operator: FilterOperator.and_, children: [l]),
|
||||
};
|
||||
return (group: group, actions: actions);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
FilterNode? _parseTest(_Sc s) {
|
||||
s.skip();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
if (word == 'allof' || word == 'anyof') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
s.expectChar('(');
|
||||
final op = word == 'allof' ? FilterOperator.and_ : FilterOperator.or_;
|
||||
final children = <FilterNode>[];
|
||||
while (true) {
|
||||
s.skip();
|
||||
if (s.peek() == ')') break;
|
||||
final child = _parseTest(s);
|
||||
if (child == null) return null;
|
||||
children.add(child);
|
||||
s.skip();
|
||||
if (s.peek() == ',') s.advance();
|
||||
}
|
||||
s.expectChar(')');
|
||||
return FilterGroup(operator: op, children: children);
|
||||
}
|
||||
return _parseSingleTest(s);
|
||||
}
|
||||
|
||||
FilterLeaf? _parseSingleTest(_Sc s) {
|
||||
s.skip();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
|
||||
if (word == 'address') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final matchType = s.readTaggedArg();
|
||||
s.skip();
|
||||
final headers = _parseStringOrList(s);
|
||||
s.skip();
|
||||
final values = _parseStringOrList(s);
|
||||
final field = switch (headers.firstOrNull?.toLowerCase()) {
|
||||
'from' => FilterField.from_,
|
||||
'to' => FilterField.to,
|
||||
'cc' => FilterField.cc,
|
||||
_ => null,
|
||||
};
|
||||
if (field == null) return null;
|
||||
final comp = _comp(matchType);
|
||||
if (comp == null) return null;
|
||||
return FilterLeaf(
|
||||
field: field,
|
||||
comparison: comp,
|
||||
value: values.firstOrNull ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
if (word == 'header') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final matchType = s.readTaggedArg();
|
||||
s.skip();
|
||||
final headers = _parseStringOrList(s);
|
||||
s.skip();
|
||||
final values = _parseStringOrList(s);
|
||||
if (headers.firstOrNull?.toLowerCase() != 'subject') return null;
|
||||
final comp = _comp(matchType);
|
||||
if (comp == null) return null;
|
||||
return FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: comp,
|
||||
value: values.firstOrNull ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
if (word == 'size') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final compTag = s.readTaggedArg();
|
||||
s.skip();
|
||||
final numStr = s.readDigits();
|
||||
final comp = switch (compTag.toLowerCase()) {
|
||||
':over' => FilterComparison.over,
|
||||
':under' => FilterComparison.under,
|
||||
_ => null,
|
||||
};
|
||||
if (comp == null) return null;
|
||||
return FilterLeaf(
|
||||
field: FilterField.size,
|
||||
comparison: comp,
|
||||
value: numStr,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
FilterComparison? _comp(String tag) => switch (tag.toLowerCase()) {
|
||||
':contains' => FilterComparison.contains,
|
||||
':is' => FilterComparison.is_,
|
||||
':matches' => FilterComparison.matches,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
SieveAction? _parseAction(_Sc s) {
|
||||
s.skip();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
if (word == 'fileinto') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final folder = _parseString(s);
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
return FileIntoAction(folder);
|
||||
}
|
||||
if (word == 'keep') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
return KeepAction();
|
||||
}
|
||||
if (word == 'discard') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
return DiscardAction();
|
||||
}
|
||||
if (word == 'setflag' || word == 'addflag') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final flags = _parseStringOrList(s);
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
if (flags.any(
|
||||
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
||||
)) {
|
||||
return MarkAsSeenAction();
|
||||
}
|
||||
return FlagAction(flags);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _parseStringOrList(_Sc s) {
|
||||
s.skip();
|
||||
if (s.peek() == '[') {
|
||||
s.advance();
|
||||
final items = <String>[];
|
||||
while (true) {
|
||||
s.skip();
|
||||
if (s.peek() == ']') {
|
||||
s.advance();
|
||||
break;
|
||||
}
|
||||
items.add(_parseString(s));
|
||||
s.skip();
|
||||
if (s.peek() == ',') s.advance();
|
||||
}
|
||||
return items;
|
||||
}
|
||||
return [_parseString(s)];
|
||||
}
|
||||
|
||||
String _parseString(_Sc s) {
|
||||
s.skip();
|
||||
return s.readQuotedString();
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal scanner for the supported Sieve subset.
|
||||
class _Sc {
|
||||
_Sc(this._src);
|
||||
final String _src;
|
||||
int _pos = 0;
|
||||
|
||||
bool get isAtEnd => _pos >= _src.length;
|
||||
String? peek() => isAtEnd ? null : _src[_pos];
|
||||
|
||||
String advance() {
|
||||
if (isAtEnd) throw _ScanErr('Unexpected end');
|
||||
return _src[_pos++];
|
||||
}
|
||||
|
||||
void skip() {
|
||||
while (!isAtEnd) {
|
||||
final ch = _src[_pos];
|
||||
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||
_pos++;
|
||||
} else if (ch == '#') {
|
||||
while (!isAtEnd && _src[_pos] != '\n') {
|
||||
_pos++;
|
||||
}
|
||||
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
||||
_pos += 2;
|
||||
while (_pos + 1 < _src.length) {
|
||||
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
||||
_pos += 2;
|
||||
break;
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? peekWord() {
|
||||
if (isAtEnd) return null;
|
||||
final ch = _src[_pos];
|
||||
if ('{}();[],'.contains(ch)) return ch;
|
||||
if (ch == ':') {
|
||||
var end = _pos + 1;
|
||||
while (end < _src.length && _wc(_src[end])) {
|
||||
end++;
|
||||
}
|
||||
return _src.substring(_pos, end).toLowerCase();
|
||||
}
|
||||
if (_wc(ch)) {
|
||||
var end = _pos + 1;
|
||||
while (end < _src.length && _wc(_src[end])) {
|
||||
end++;
|
||||
}
|
||||
return _src.substring(_pos, end).toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String readWord() {
|
||||
final start = _pos;
|
||||
final ch = _src[_pos];
|
||||
if ('{}();[],'.contains(ch)) {
|
||||
_pos++;
|
||||
return ch;
|
||||
}
|
||||
if (ch == ':') {
|
||||
_pos++;
|
||||
while (!isAtEnd && _wc(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
} else {
|
||||
while (!isAtEnd && _wc(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
return _src.substring(start, _pos).toLowerCase();
|
||||
}
|
||||
|
||||
String readTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||
throw _ScanErr('Expected tagged arg at $_pos');
|
||||
}
|
||||
|
||||
String readDigits() {
|
||||
final start = _pos;
|
||||
while (!isAtEnd && _dig(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
if (_pos == start) throw _ScanErr('Expected digits at $_pos');
|
||||
return _src.substring(start, _pos);
|
||||
}
|
||||
|
||||
String readQuotedString() {
|
||||
if (isAtEnd || _src[_pos] != '"') throw _ScanErr('Expected " at $_pos');
|
||||
_pos++;
|
||||
final buf = StringBuffer();
|
||||
while (!isAtEnd) {
|
||||
final ch = _src[_pos];
|
||||
if (ch == '"') {
|
||||
_pos++;
|
||||
return buf.toString();
|
||||
}
|
||||
if (ch == '\\' && _pos + 1 < _src.length) {
|
||||
_pos++;
|
||||
buf.write(_src[_pos]);
|
||||
_pos++;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
throw _ScanErr('Unterminated string');
|
||||
}
|
||||
|
||||
void expectChar(String ch) {
|
||||
skip();
|
||||
if (isAtEnd || _src[_pos] != ch) {
|
||||
throw _ScanErr(
|
||||
'Expected "$ch" at $_pos, got ${isAtEnd ? "EOF" : _src[_pos]}',
|
||||
);
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
|
||||
static bool _wc(String ch) {
|
||||
final c = ch.codeUnitAt(0);
|
||||
return (c >= 0x41 && c <= 0x5A) ||
|
||||
(c >= 0x61 && c <= 0x7A) ||
|
||||
(c >= 0x30 && c <= 0x39) ||
|
||||
c == 0x5F ||
|
||||
c == 0x2D;
|
||||
}
|
||||
|
||||
static bool _dig(String ch) {
|
||||
final c = ch.codeUnitAt(0);
|
||||
return c >= 0x30 && c <= 0x39;
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanErr implements Exception {
|
||||
_ScanErr(this.message);
|
||||
final String message;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
|
||||
abstract class EmailRepository {
|
||||
@@ -61,6 +62,12 @@ abstract class EmailRepository {
|
||||
/// if null) by subject, preview, and notes. Fast, works offline.
|
||||
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
||||
|
||||
/// Searches the local DB using a structured [FilterGroup]. Fast, works offline.
|
||||
Future<List<Email>> searchEmailsStructured(
|
||||
String? accountId,
|
||||
FilterGroup filter,
|
||||
);
|
||||
|
||||
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||
/// accounts if null) whose from, to, or cc fields contain [address].
|
||||
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
|
||||
/// Serialises a [FilterGroup] + list of [SieveAction]s to a Sieve script
|
||||
/// (RFC 5228 subset).
|
||||
class SieveSerializer {
|
||||
String serialize(FilterGroup filter, List<SieveAction> actions) {
|
||||
final buf = StringBuffer();
|
||||
final requires = _collectRequires(actions);
|
||||
if (requires.isNotEmpty) {
|
||||
buf.writeln(
|
||||
'require [${requires.map((r) => '"$r"').join(', ')}];',
|
||||
);
|
||||
}
|
||||
if (filter.isEmpty) {
|
||||
for (final a in actions) {
|
||||
buf.writeln(_serializeAction(a));
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
buf.write('if ');
|
||||
buf.write(_serializeNode(filter));
|
||||
buf.writeln(' {');
|
||||
for (final a in actions) {
|
||||
buf.writeln(' ${_serializeAction(a)}');
|
||||
}
|
||||
buf.writeln('}');
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
List<String> _collectRequires(List<SieveAction> actions) {
|
||||
final req = <String>[];
|
||||
for (final a in actions) {
|
||||
if (a is FileIntoAction && !req.contains('fileinto')) req.add('fileinto');
|
||||
if ((a is FlagAction || a is MarkAsSeenAction) &&
|
||||
!req.contains('imap4flags')) {
|
||||
req.add('imap4flags');
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
String _serializeNode(FilterNode node) => switch (node) {
|
||||
final FilterLeaf leaf => _serializeLeaf(leaf),
|
||||
final FilterGroup group => _serializeGroup(group),
|
||||
};
|
||||
|
||||
String _serializeGroup(FilterGroup group) {
|
||||
if (group.isEmpty) return 'true';
|
||||
if (group.children.length == 1) return _serializeNode(group.children.first);
|
||||
final op = group.operator == FilterOperator.and_ ? 'allof' : 'anyof';
|
||||
final parts = group.children.map(_serializeNode).join(',\n ');
|
||||
return '$op(\n $parts\n)';
|
||||
}
|
||||
|
||||
String _serializeLeaf(FilterLeaf leaf) => switch (leaf.field) {
|
||||
FilterField.from_ ||
|
||||
FilterField.to ||
|
||||
FilterField.cc =>
|
||||
_serializeAddressLeaf(leaf),
|
||||
FilterField.subject => _serializeHeaderLeaf(leaf),
|
||||
FilterField.size => _serializeSizeLeaf(leaf),
|
||||
};
|
||||
|
||||
String _serializeAddressLeaf(FilterLeaf leaf) {
|
||||
final header = switch (leaf.field) {
|
||||
FilterField.from_ => 'from',
|
||||
FilterField.to => 'to',
|
||||
FilterField.cc => 'cc',
|
||||
_ => throw StateError('not an address field'),
|
||||
};
|
||||
return 'address ${_matchType(leaf.comparison)} "$header" "${_esc(leaf.value)}"';
|
||||
}
|
||||
|
||||
String _serializeHeaderLeaf(FilterLeaf leaf) =>
|
||||
'header ${_matchType(leaf.comparison)} "subject" "${_esc(leaf.value)}"';
|
||||
|
||||
String _serializeSizeLeaf(FilterLeaf leaf) {
|
||||
final comp = leaf.comparison == FilterComparison.over ? ':over' : ':under';
|
||||
return 'size $comp ${leaf.value}';
|
||||
}
|
||||
|
||||
String _matchType(FilterComparison comp) => switch (comp) {
|
||||
FilterComparison.contains => ':contains',
|
||||
FilterComparison.is_ => ':is',
|
||||
FilterComparison.matches => ':matches',
|
||||
_ => ':contains',
|
||||
};
|
||||
|
||||
String _serializeAction(SieveAction action) => switch (action) {
|
||||
final FileIntoAction a => 'fileinto "${_esc(a.folder)}";',
|
||||
KeepAction() => 'keep;',
|
||||
DiscardAction() => 'discard;',
|
||||
MarkAsSeenAction() => r'setflag "\\Seen";',
|
||||
final FlagAction a =>
|
||||
'addflag [${a.flags.map((f) => '"${_esc(f)}"').join(', ')}];',
|
||||
};
|
||||
|
||||
String _esc(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
|
||||
}
|
||||
Reference in New Issue
Block a user