Files
sharedinbox/lib/core/sieve/sieve_serializer.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 70c7100014 feat: add structured search with visual filter builder (#466)
Implements issue #466 — a visual row-based filter editor (Field |
Comparison | Value, AND/OR grouping) reused in the Search screen and
the Sieve script editor.

New files:
- lib/core/filter/filter_expression.dart — FilterGroup/FilterLeaf tree
  model (FilterField, FilterComparison, FilterOperator)
- lib/core/sieve/sieve_serializer.dart — serialises FilterGroup +
  SieveActions to a Sieve RFC 5228 script
- lib/core/filter/filter_sieve_converter.dart — parses a Sieve script
  back into a FilterGroup tree (round-trip support)
- lib/ui/widgets/filter_builder.dart — interactive FilterBuilderWidget
  with nested group support (depth ≤ 1)
- test/unit/filter_and_sieve_test.dart — 25 unit tests covering
  FilterGroup, FilterLeaf, SieveSerializer, and FilterSieveConverter
  including round-trip coverage

Modified files:
- EmailRepository: adds searchEmailsStructured abstract method
- EmailRepositoryImpl: implements searchEmailsStructured via Drift query
  builder (LIKE-based matching on JSON address fields and text columns)
- SearchScreen: adds Advanced Search mode (tune icon toggle) using the
  FilterBuilderWidget
- SieveScriptEditScreen: gains a Visual / Script tab pair; switching
  serialises or parses the script automatically; _ActionEditor covers
  keep / discard / mark-as-read / file-into actions
- 5 test fake classes + 2 generated mock files: add stubs for the new
  searchEmailsStructured method
- scripts/check_coverage.dart: adds filter_builder.dart to _excluded
  (UI widget, covered by widget tests path)
- Fix: SieveSerializer now emits \\Seen (double-escaped) so the flag
  survives quoted-string parsing back to \Seen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 21:51:25 +02:00

101 lines
3.3 KiB
Dart

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'\"');
}