Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e95ae381 | ||
|
|
609208247a | ||
|
|
69606ce586 | ||
|
|
9081b452f3 | ||
|
|
b9ccafc709 | ||
|
|
b1e1ac1de7 | ||
|
|
f22f211e8a | ||
|
|
ec6c89d174 | ||
|
|
47aa1a6e5d |
@@ -12,10 +12,116 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
name: Detect Website Changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect website changes since last deploy
|
||||||
|
id: diff
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
# On push or workflow_dispatch always deploy
|
||||||
|
if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
# Find the most recent successful website.yml run where the deploy job
|
||||||
|
# actually ran (not merely skipped). Uses head_sha (not commit_sha which
|
||||||
|
# is always None in Forgejo's API).
|
||||||
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
|
import json, os, sys, urllib.request
|
||||||
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
||||||
|
url = f"{base_api}/runs?workflow_id=website.yml&status=success&limit=10"
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
runs = [
|
||||||
|
r for r in data.get("workflow_runs", [])
|
||||||
|
if r.get("status") == "success"
|
||||||
|
]
|
||||||
|
for run in runs:
|
||||||
|
run_id = run.get("id")
|
||||||
|
jobs_url = f"{base_api}/runs/{run_id}/jobs"
|
||||||
|
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(jobs_req) as jr:
|
||||||
|
jobs_data = json.loads(jr.read())
|
||||||
|
for job in jobs_data.get("workflow_jobs", []):
|
||||||
|
if "Build & Update Website" in job.get("name", "") and (
|
||||||
|
job.get("conclusion") == "success" or
|
||||||
|
job.get("status") == "success"
|
||||||
|
):
|
||||||
|
print(run.get("head_sha") or "")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
pass # skip this run if jobs API fails
|
||||||
|
print("")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||||
|
print("")
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::warning::Could not determine last successfully deployed SHA — deploying as a precaution"
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::notice::Website deploy SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Diff from last successfully deployed commit to catch all changes since
|
||||||
|
# that deploy, not just the most recent commit.
|
||||||
|
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||||
|
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||||
|
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||||
|
|| git show --name-only --format= HEAD)
|
||||||
|
else
|
||||||
|
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying as a precaution"
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
|
||||||
|
website_re='^(website/|scripts/website-verify\.sh|\.forgejo/workflows/website\.yml)'
|
||||||
|
|
||||||
|
if echo "$CHANGED" | grep -qE "$website_re"; then
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Website deploy TRIGGERED — website-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Website deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no website-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Build & Update Website
|
name: Build & Update Website
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Print runner wait time
|
- name: Print runner wait time
|
||||||
|
|||||||
@@ -13,23 +13,27 @@ Automation is handled by [agentloop](https://github.com/guettli/agentloop) runni
|
|||||||
| Label | Trigger | Outcome |
|
| Label | Trigger | Outcome |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` |
|
||||||
|
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
|
||||||
|
|
||||||
**State machine:**
|
**State machine:**
|
||||||
|
|
||||||
```
|
```
|
||||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
loop/plan → loop/plan-in-process → loop/plan-done
|
||||||
↘ NeedSupervisor (on failure)
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
loop/code → loop/code-in-progress → loop/code-done
|
loop/code → loop/code-in-process → loop/merge (via route)
|
||||||
↘ NeedSupervisor (on failure)
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
|
loop/merge → loop/merge-in-process → loop/merge-done
|
||||||
|
↘ NeedSupervisor (on failure)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
**Rules:**
|
||||||
|
|
||||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
- The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review.
|
||||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
- Planning agents only post a comment — they do NOT write code or open PRs.
|
||||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
||||||
|
|
||||||
@@ -39,9 +43,9 @@ loop/code → loop/code-in-progress → loop/code-done
|
|||||||
1. Create issue
|
1. Create issue
|
||||||
2. Add label loop/plan → agent writes plan as comment
|
2. Add label loop/plan → agent writes plan as comment
|
||||||
3. Review plan, request changes or approve
|
3. Review plan, request changes or approve
|
||||||
4. Add label loop/code → agent implements + opens PR
|
4. Add label loop/code → agent implements + opens PR + hands off to merge
|
||||||
5. Review PR, merge
|
5. (Optional) Review PR before it merges
|
||||||
6. Close issue
|
6. Merge agent waits for CI and merges the PR automatically
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|||||||
+28
-20
@@ -388,7 +388,7 @@ func (m *Ci) Stalwart() *dagger.Service {
|
|||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("stalwartlabs/stalwart:v0.14.1").
|
From("stalwartlabs/stalwart:v0.14.1").
|
||||||
WithFile("/etc/stalwart/config.toml.orig", config).
|
WithFile("/etc/stalwart/config.toml.orig", config).
|
||||||
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
|
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
|
||||||
WithDirectory("/tmp/stalwart", dataDir).
|
WithDirectory("/tmp/stalwart", dataDir).
|
||||||
WithExposedPort(8080). // JMAP
|
WithExposedPort(8080). // JMAP
|
||||||
WithExposedPort(1430). // IMAP
|
WithExposedPort(1430). // IMAP
|
||||||
@@ -594,25 +594,33 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
checkSetup := m.setup(m.checkSrc())
|
// Run format, analyze, generated-code check, and coverage in parallel —
|
||||||
|
// they all share the same setup base and have no dependencies on each other.
|
||||||
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
var analyze, mocks, coverage string
|
||||||
return "Format check failed", err
|
var checkEg errgroup.Group
|
||||||
}
|
checkEg.Go(func() error {
|
||||||
|
setup := m.setup(m.checkSrc())
|
||||||
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
_, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx)
|
||||||
if err != nil {
|
return err
|
||||||
return analyze, err
|
})
|
||||||
}
|
checkEg.Go(func() error {
|
||||||
|
setup := m.setup(m.checkSrc())
|
||||||
mocks, err := m.CheckGenerated(ctx)
|
var err error
|
||||||
if err != nil {
|
analyze, err = setup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
||||||
return mocks, err
|
return err
|
||||||
}
|
})
|
||||||
|
checkEg.Go(func() error {
|
||||||
coverage, err := m.Coverage(ctx)
|
var err error
|
||||||
if err != nil {
|
mocks, err = m.CheckGenerated(ctx)
|
||||||
return coverage, err
|
return err
|
||||||
|
})
|
||||||
|
checkEg.Go(func() error {
|
||||||
|
var err error
|
||||||
|
coverage, err = m.Coverage(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err := checkEg.Wait(); err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
|
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
|
||||||
|
|||||||
@@ -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';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
abstract class EmailRepository {
|
abstract class EmailRepository {
|
||||||
@@ -61,6 +62,12 @@ abstract class EmailRepository {
|
|||||||
/// if null) by subject, preview, and notes. Fast, works offline.
|
/// if null) by subject, preview, and notes. Fast, works offline.
|
||||||
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
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
|
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||||
/// accounts if null) whose from, to, or cc fields contain [address].
|
/// accounts if null) whose from, to, or cc fields contain [address].
|
||||||
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||||
|
|
||||||
/// A lightweight email representation used by [SieveInterpreter].
|
/// A lightweight email representation used by [SieveInterpreter].
|
||||||
/// Header names are lower-cased.
|
/// Header names are lower-cased.
|
||||||
@@ -102,18 +103,11 @@ class SieveInterpreter {
|
|||||||
return switch (matchType) {
|
return switch (matchType) {
|
||||||
':contains' => k.isEmpty || v.contains(k),
|
':contains' => k.isEmpty || v.contains(k),
|
||||||
':is' => v == k,
|
':is' => v == k,
|
||||||
':matches' => _globMatch(v, k),
|
':matches' => globMatch(v, k),
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _globMatch(String value, String pattern) {
|
|
||||||
final regexStr = RegExp.escape(
|
|
||||||
pattern,
|
|
||||||
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
|
||||||
return RegExp('^$regexStr\$').hasMatch(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
||||||
for (final action in actions) {
|
for (final action in actions) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|||||||
@@ -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'\"');
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/// Returns true if [value] matches the glob [pattern].
|
||||||
|
///
|
||||||
|
/// Supports `*` (any number of characters) and `?` (exactly one character).
|
||||||
|
/// The comparison is case-insensitive, which is appropriate for email addresses.
|
||||||
|
bool globMatch(String value, String pattern) {
|
||||||
|
final regexStr =
|
||||||
|
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||||
|
return RegExp('^$regexStr\$', caseSensitive: false).hasMatch(value);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
import 'package:sharedinbox/core/models/email.dart' as model;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
@@ -2987,6 +2988,91 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return emailRows.map(_toModel).toList();
|
return emailRows.map(_toModel).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<model.Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
FilterGroup filter,
|
||||||
|
) async {
|
||||||
|
final rows = await (_db.select(_db.emails)
|
||||||
|
..where((t) {
|
||||||
|
final fe = _filterGroup(filter, t);
|
||||||
|
if (accountId == null) return fe;
|
||||||
|
return t.accountId.equals(accountId) & fe;
|
||||||
|
})
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||||
|
..limit(100))
|
||||||
|
.get();
|
||||||
|
return rows.map(_toModel).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _filterGroup(FilterGroup group, $EmailsTable t) {
|
||||||
|
if (group.isEmpty) return const Constant(true);
|
||||||
|
final exprs = group.children.map((c) => _filterNode(c, t)).toList();
|
||||||
|
return switch (group.operator) {
|
||||||
|
FilterOperator.and_ => exprs.reduce((a, b) => a & b),
|
||||||
|
FilterOperator.or_ => exprs.reduce((a, b) => a | b),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _filterNode(FilterNode node, $EmailsTable t) =>
|
||||||
|
switch (node) {
|
||||||
|
final FilterLeaf l => _filterLeaf(l, t),
|
||||||
|
final FilterGroup g => _filterGroup(g, t),
|
||||||
|
};
|
||||||
|
|
||||||
|
Expression<bool> _filterLeaf(FilterLeaf leaf, $EmailsTable t) {
|
||||||
|
final val = leaf.value.toLowerCase();
|
||||||
|
return switch (leaf.field) {
|
||||||
|
FilterField.from_ => _jsonLike(t.fromJson, leaf.comparison, val),
|
||||||
|
FilterField.to => _jsonLike(t.toAddresses, leaf.comparison, val),
|
||||||
|
FilterField.cc => _jsonLike(t.ccJson, leaf.comparison, val),
|
||||||
|
FilterField.subject => _textLike(t.subject, leaf.comparison, val),
|
||||||
|
// Size is not stored in the local cache; skip silently.
|
||||||
|
FilterField.size => const Constant(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _jsonLike(
|
||||||
|
GeneratedColumn<String> col,
|
||||||
|
FilterComparison comp,
|
||||||
|
String val,
|
||||||
|
) =>
|
||||||
|
switch (comp) {
|
||||||
|
FilterComparison.contains => col.like('%$val%'),
|
||||||
|
FilterComparison.is_ => col.like('%"email":"$val"%'),
|
||||||
|
FilterComparison.matches => col.like(_globToLike(val)),
|
||||||
|
_ => const Constant(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
Expression<bool> _textLike(
|
||||||
|
GeneratedColumn<String> col,
|
||||||
|
FilterComparison comp,
|
||||||
|
String val,
|
||||||
|
) =>
|
||||||
|
switch (comp) {
|
||||||
|
FilterComparison.contains => col.like('%$val%'),
|
||||||
|
FilterComparison.is_ => col.like(val),
|
||||||
|
FilterComparison.matches => col.like(_globToLike(val)),
|
||||||
|
_ => const Constant(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
static String _globToLike(String glob) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < glob.length; i++) {
|
||||||
|
final ch = glob[i];
|
||||||
|
if (ch == '%' || ch == '_') {
|
||||||
|
buf.write('\\$ch');
|
||||||
|
} else if (ch == '*') {
|
||||||
|
buf.write('%');
|
||||||
|
} else if (ch == '?') {
|
||||||
|
buf.write('_');
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts a user query string into an FTS5 match expression.
|
/// Converts a user query string into an FTS5 match expression.
|
||||||
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
||||||
/// partial words still match. Special FTS5 characters are stripped.
|
/// partial words still match. Special FTS5 characters are stripped.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/note.dart';
|
|||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
@@ -208,8 +209,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
final senderEmail = header?.from.isNotEmpty == true
|
final senderEmail = header?.from.isNotEmpty == true
|
||||||
? header!.from.first.email.toLowerCase()
|
? header!.from.first.email.toLowerCase()
|
||||||
: null;
|
: null;
|
||||||
final isTrusted =
|
final isTrusted = senderEmail != null &&
|
||||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
trustedSenders.any((p) => globMatch(senderEmail, p));
|
||||||
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
|
|||||||
@@ -278,7 +278,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: _onSearchChanged,
|
onChanged: _onSearchChanged,
|
||||||
onSubmitted: _runSearch,
|
onSubmitted: (value) {
|
||||||
|
// Only run the search if results haven't settled yet via
|
||||||
|
// onChanged — prevents a second IMAP round-trip from reordering
|
||||||
|
// the already-visible results when the user presses Enter.
|
||||||
|
if (_searchResults == null && !_searchLoading) {
|
||||||
|
unawaited(_runSearch(value));
|
||||||
|
}
|
||||||
|
},
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
import 'package:sharedinbox/core/utils/logger.dart';
|
import 'package:sharedinbox/core/utils/logger.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||||
|
|
||||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
||||||
@@ -37,6 +39,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _fieldFocused = false;
|
bool _fieldFocused = false;
|
||||||
|
|
||||||
|
// Advanced (structured) search state.
|
||||||
|
bool _advancedMode = false;
|
||||||
|
FilterGroup _filterGroup = FilterGroup.empty();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -53,6 +59,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _toggleAdvanced() {
|
||||||
|
setState(() {
|
||||||
|
_advancedMode = !_advancedMode;
|
||||||
|
_results = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _onChanged(String value) {
|
void _onChanged(String value) {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
if (value.trim().length < 3) {
|
if (value.trim().length < 3) {
|
||||||
@@ -135,22 +148,47 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _searchStructured() async {
|
||||||
|
if (_filterGroup.isEmpty) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final emails = await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.searchEmailsStructured(widget.accountId, _filterGroup);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_results = _SearchResults(
|
||||||
|
mailboxes: const [],
|
||||||
|
addresses: const [],
|
||||||
|
emails: emails,
|
||||||
|
);
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Structured search failed: $e');
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: TextField(
|
title: _advancedMode
|
||||||
controller: _ctrl,
|
? const Text('Advanced Search')
|
||||||
focusNode: _focusNode,
|
: TextField(
|
||||||
autofocus: true,
|
controller: _ctrl,
|
||||||
decoration: const InputDecoration(
|
focusNode: _focusNode,
|
||||||
hintText: 'Search folders, addresses, emails…',
|
autofocus: true,
|
||||||
border: InputBorder.none,
|
decoration: const InputDecoration(
|
||||||
),
|
hintText: 'Search folders, addresses, emails…',
|
||||||
onChanged: _onChanged,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
|
onChanged: _onChanged,
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_ctrl.text.isNotEmpty)
|
if (!_advancedMode && _ctrl.text.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -158,6 +196,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
setState(() => _results = null);
|
setState(() => _results = null);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_advancedMode ? Icons.search : Icons.tune,
|
||||||
|
color:
|
||||||
|
_advancedMode ? Theme.of(context).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
tooltip: _advancedMode ? 'Simple search' : 'Advanced search',
|
||||||
|
onPressed: _toggleAdvanced,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
@@ -165,6 +212,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
|
if (_advancedMode) return _buildAdvancedBody();
|
||||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_results == null) {
|
if (_results == null) {
|
||||||
if (_fieldFocused && _ctrl.text.isEmpty) {
|
if (_fieldFocused && _ctrl.text.isEmpty) {
|
||||||
@@ -174,7 +222,54 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
final r = _results!;
|
final r = _results!;
|
||||||
if (r.isEmpty) return const Center(child: Text('No results'));
|
if (r.isEmpty) return const Center(child: Text('No results'));
|
||||||
|
return _buildResultsList(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdvancedBody() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FilterBuilderWidget(
|
||||||
|
initialValue: _filterGroup,
|
||||||
|
onChanged: (g) => setState(() {
|
||||||
|
_filterGroup = g;
|
||||||
|
_results = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _filterGroup.isEmpty ? null : _searchStructured,
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
label: const Text('Search'),
|
||||||
|
),
|
||||||
|
if (_loading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (_results != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_results!.isEmpty)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text('No results'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildResultsList(_results!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultsList(_SearchResults r) {
|
||||||
return ListView(
|
return ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
if (r.mailboxes.isNotEmpty) ...[
|
if (r.mailboxes.isNotEmpty) ...[
|
||||||
const _SectionHeader('Folders'),
|
const _SectionHeader('Folders'),
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
|
||||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
|
||||||
|
|
||||||
class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||||
const SieveScriptEditScreen({
|
const SieveScriptEditScreen({
|
||||||
@@ -27,18 +32,29 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
|||||||
_SieveScriptEditScreenState();
|
_SieveScriptEditScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
late final TextEditingController _nameController;
|
late final TextEditingController _nameController;
|
||||||
late final TextEditingController _contentController;
|
late final TextEditingController _contentController;
|
||||||
|
late final TabController _tabController;
|
||||||
|
|
||||||
bool _loadingContent = false;
|
bool _loadingContent = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
// Visual-editor state.
|
||||||
|
FilterGroup _filterGroup = FilterGroup.empty();
|
||||||
|
List<SieveAction> _actions = [];
|
||||||
|
bool _visualSupported = true;
|
||||||
|
int _visualLoadCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameController = TextEditingController(text: widget.script?.name ?? '');
|
_nameController = TextEditingController(text: widget.script?.name ?? '');
|
||||||
_contentController = TextEditingController();
|
_contentController = TextEditingController();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_tabController.addListener(_onTabChanged);
|
||||||
if (widget.script != null) {
|
if (widget.script != null) {
|
||||||
unawaited(_loadContent());
|
unawaited(_loadContent());
|
||||||
}
|
}
|
||||||
@@ -48,9 +64,40 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_contentController.dispose();
|
_contentController.dispose();
|
||||||
|
_tabController
|
||||||
|
..removeListener(_onTabChanged)
|
||||||
|
..dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onTabChanged() {
|
||||||
|
if (_tabController.indexIsChanging) return;
|
||||||
|
if (_tabController.index == 1) {
|
||||||
|
// Switched to Script tab: serialize visual state.
|
||||||
|
if (_visualSupported) {
|
||||||
|
_contentController.text =
|
||||||
|
SieveSerializer().serialize(_filterGroup, _actions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Switched to Visual tab: parse script into visual state.
|
||||||
|
_parseScriptIntoVisual();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseScriptIntoVisual() {
|
||||||
|
final result = FilterSieveConverter().parse(_contentController.text);
|
||||||
|
if (result == null) {
|
||||||
|
setState(() => _visualSupported = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_filterGroup = result.group;
|
||||||
|
_actions = List<SieveAction>.from(result.actions);
|
||||||
|
_visualSupported = true;
|
||||||
|
_visualLoadCount++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadContent() async {
|
Future<void> _loadContent() async {
|
||||||
setState(() => _loadingContent = true);
|
setState(() => _loadingContent = true);
|
||||||
try {
|
try {
|
||||||
@@ -63,6 +110,7 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_contentController.text = content;
|
_contentController.text = content;
|
||||||
|
_parseScriptIntoVisual();
|
||||||
setState(() => _loadingContent = false);
|
setState(() => _loadingContent = false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -76,6 +124,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
|
// Sync visual → script if on visual tab.
|
||||||
|
if (_tabController.index == 0 && _visualSupported) {
|
||||||
|
_contentController.text =
|
||||||
|
SieveSerializer().serialize(_filterGroup, _actions);
|
||||||
|
}
|
||||||
final name = _nameController.text.trim();
|
final name = _nameController.text.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
setState(() => _error = 'Name is required');
|
setState(() => _error = 'Name is required');
|
||||||
@@ -118,6 +171,10 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(isNew ? 'New script' : 'Edit script'),
|
title: Text(isNew ? 'New script' : 'Edit script'),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [Tab(text: 'Visual'), Tab(text: 'Script')],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_saving)
|
if (_saving)
|
||||||
const Padding(
|
const Padding(
|
||||||
@@ -163,18 +220,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TabBarView(
|
||||||
controller: _contentController,
|
controller: _tabController,
|
||||||
decoration: const InputDecoration(
|
children: [_buildVisualTab(), _buildScriptTab()],
|
||||||
labelText: 'Script',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
alignLabelWithHint: true,
|
|
||||||
),
|
|
||||||
maxLines: null,
|
|
||||||
expands: true,
|
|
||||||
textAlignVertical: TextAlignVertical.top,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace'),
|
|
||||||
enabled: !_saving,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -182,4 +230,220 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildVisualTab() {
|
||||||
|
if (!_visualSupported) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'This script uses features not supported by the visual editor.\n'
|
||||||
|
'Edit as raw Sieve on the Script tab.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FilterBuilderWidget(
|
||||||
|
key: ValueKey(_visualLoadCount),
|
||||||
|
initialValue: _filterGroup,
|
||||||
|
onChanged: (g) => setState(() => _filterGroup = g),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ActionEditor(
|
||||||
|
actions: _actions,
|
||||||
|
onChanged: (a) => setState(() => _actions = a),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScriptTab() {
|
||||||
|
return TextField(
|
||||||
|
controller: _contentController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Script',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
enabled: !_saving,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action editor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum _ActionType { keep, discard, markAsRead, fileInto }
|
||||||
|
|
||||||
|
class _ActionEditor extends StatelessWidget {
|
||||||
|
const _ActionEditor({required this.actions, required this.onChanged});
|
||||||
|
|
||||||
|
final List<SieveAction> actions;
|
||||||
|
final void Function(List<SieveAction>) onChanged;
|
||||||
|
|
||||||
|
_ActionType _typeOf(SieveAction a) => switch (a) {
|
||||||
|
KeepAction() => _ActionType.keep,
|
||||||
|
DiscardAction() => _ActionType.discard,
|
||||||
|
MarkAsSeenAction() => _ActionType.markAsRead,
|
||||||
|
FileIntoAction() => _ActionType.fileInto,
|
||||||
|
FlagAction() => _ActionType.keep,
|
||||||
|
};
|
||||||
|
|
||||||
|
SieveAction _defaultFor(_ActionType t) => switch (t) {
|
||||||
|
_ActionType.keep => KeepAction(),
|
||||||
|
_ActionType.discard => DiscardAction(),
|
||||||
|
_ActionType.markAsRead => MarkAsSeenAction(),
|
||||||
|
_ActionType.fileInto => FileIntoAction(''),
|
||||||
|
};
|
||||||
|
|
||||||
|
void _changeType(int i, _ActionType t) {
|
||||||
|
final next = List<SieveAction>.from(actions);
|
||||||
|
final current = next[i];
|
||||||
|
if (t == _ActionType.fileInto && current is FileIntoAction) return;
|
||||||
|
next[i] = _defaultFor(t);
|
||||||
|
onChanged(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _changeFolder(int i, String folder) {
|
||||||
|
final next = List<SieveAction>.from(actions);
|
||||||
|
next[i] = FileIntoAction(folder);
|
||||||
|
onChanged(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _remove(int i) {
|
||||||
|
final next = List<SieveAction>.from(actions)..removeAt(i);
|
||||||
|
onChanged(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _add() {
|
||||||
|
onChanged([...actions, KeepAction()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text('Actions', style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
),
|
||||||
|
for (var i = 0; i < actions.length; i++) _buildRow(context, i),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _add,
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('Add action'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRow(BuildContext context, int i) {
|
||||||
|
final action = actions[i];
|
||||||
|
final type = _typeOf(action);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
DropdownButton<_ActionType>(
|
||||||
|
value: type,
|
||||||
|
isDense: true,
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
onChanged: (t) {
|
||||||
|
if (t != null) _changeType(i, t);
|
||||||
|
},
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: _ActionType.keep, child: Text('Keep')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: _ActionType.discard,
|
||||||
|
child: Text('Discard'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: _ActionType.markAsRead,
|
||||||
|
child: Text('Mark as read'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: _ActionType.fileInto,
|
||||||
|
child: Text('File into'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (type == _ActionType.fileInto) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _FolderField(
|
||||||
|
value: (action as FileIntoAction).folder,
|
||||||
|
onChanged: (v) => _changeFolder(i, v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: () => _remove(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FolderField extends StatefulWidget {
|
||||||
|
const _FolderField({required this.value, required this.onChanged});
|
||||||
|
final String value;
|
||||||
|
final void Function(String) onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FolderField> createState() => _FolderFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FolderFieldState extends State<_FolderField> {
|
||||||
|
late final TextEditingController _ctrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = TextEditingController(text: widget.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_FolderField old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.value != _ctrl.text) _ctrl.text = widget.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: _ctrl,
|
||||||
|
onChanged: widget.onChanged,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'folder',
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
@@ -118,8 +119,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
final senderEmail = widget.email.from.isNotEmpty
|
final senderEmail = widget.email.from.isNotEmpty
|
||||||
? widget.email.from.first.email.toLowerCase()
|
? widget.email.from.first.email.toLowerCase()
|
||||||
: null;
|
: null;
|
||||||
final isTrusted =
|
final isTrusted = senderEmail != null &&
|
||||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
trustedSenders.any((p) => globMatch(senderEmail, p));
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Allowed addresses for images')),
|
appBar: AppBar(title: const Text('Allowed addresses for images')),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
tooltip: 'Add address',
|
||||||
|
onPressed: () => _showAddDialog(context, ref),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
body: trustedSendersAsync.when(
|
body: trustedSendersAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, __) =>
|
error: (_, __) =>
|
||||||
@@ -26,7 +31,8 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
|||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'No addresses added yet. '
|
'No addresses added yet. '
|
||||||
'Tap "Load remote images" in an email to add the sender.',
|
'Tap + to add an address or pattern (e.g. *@example.com), '
|
||||||
|
'or tap "Load remote images" in an email to add the sender automatically.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -60,4 +66,61 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx, setState) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Add allowed address'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email address or pattern',
|
||||||
|
hintText: '*@example.com',
|
||||||
|
helperText: '* matches any characters, e.g. *@example.com',
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
onSubmitted: (value) {
|
||||||
|
if (value.trim().isNotEmpty) {
|
||||||
|
_addSender(ref, value);
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: controller.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
_addSender(ref, controller.text);
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addSender(WidgetRef ref, String value) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.addTrustedImageSender(value.trim()),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
|
||||||
|
/// A widget that lets the user build a structured [FilterGroup] interactively.
|
||||||
|
///
|
||||||
|
/// Use a [ValueKey] on this widget when replacing [initialValue] from the
|
||||||
|
/// outside (e.g., after loading a Sieve script) to force a full rebuild.
|
||||||
|
class FilterBuilderWidget extends StatefulWidget {
|
||||||
|
const FilterBuilderWidget({
|
||||||
|
super.key,
|
||||||
|
required this.initialValue,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterGroup initialValue;
|
||||||
|
final void Function(FilterGroup) onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FilterBuilderWidget> createState() => _FilterBuilderWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterBuilderWidgetState extends State<FilterBuilderWidget> {
|
||||||
|
late FilterGroup _group;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_group = widget.initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _update(FilterGroup g) {
|
||||||
|
setState(() => _group = g);
|
||||||
|
widget.onChanged(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _GroupEditor(
|
||||||
|
group: _group,
|
||||||
|
onChanged: _update,
|
||||||
|
depth: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group editor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _GroupEditor extends StatelessWidget {
|
||||||
|
const _GroupEditor({
|
||||||
|
super.key,
|
||||||
|
required this.group,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.depth,
|
||||||
|
this.onRemoveGroup,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterGroup group;
|
||||||
|
final void Function(FilterGroup) onChanged;
|
||||||
|
final int depth;
|
||||||
|
final VoidCallback? onRemoveGroup;
|
||||||
|
|
||||||
|
static const _maxDepth = 1;
|
||||||
|
|
||||||
|
void _setOperator(FilterOperator op) =>
|
||||||
|
onChanged(group.copyWith(operator: op));
|
||||||
|
|
||||||
|
void _addLeaf() {
|
||||||
|
final leaf = FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: '',
|
||||||
|
);
|
||||||
|
onChanged(group.copyWith(children: [...group.children, leaf]));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addSubGroup() {
|
||||||
|
final sub = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [],
|
||||||
|
);
|
||||||
|
onChanged(group.copyWith(children: [...group.children, sub]));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceChild(int index, FilterNode node) {
|
||||||
|
final next = List<FilterNode>.from(group.children);
|
||||||
|
next[index] = node;
|
||||||
|
onChanged(group.copyWith(children: next));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeChild(int index) {
|
||||||
|
final next = List<FilterNode>.from(group.children)..removeAt(index);
|
||||||
|
onChanged(group.copyWith(children: next));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isRoot = depth == 0;
|
||||||
|
final content = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_OperatorRow(
|
||||||
|
operator: group.operator,
|
||||||
|
onChanged: _setOperator,
|
||||||
|
onRemove: onRemoveGroup,
|
||||||
|
),
|
||||||
|
for (var i = 0; i < group.children.length; i++) _buildChild(context, i),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _addLeaf,
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('Add condition'),
|
||||||
|
),
|
||||||
|
if (depth < _maxDepth)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _addSubGroup,
|
||||||
|
icon: const Icon(Icons.playlist_add, size: 16),
|
||||||
|
label: const Text('Add group'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (isRoot) return content;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChild(BuildContext context, int i) {
|
||||||
|
final child = group.children[i];
|
||||||
|
return switch (child) {
|
||||||
|
final FilterLeaf leaf => _LeafRow(
|
||||||
|
key: ValueKey(i),
|
||||||
|
leaf: leaf,
|
||||||
|
onChanged: (l) => _replaceChild(i, l),
|
||||||
|
onDelete: () => _removeChild(i),
|
||||||
|
),
|
||||||
|
final FilterGroup sub => _GroupEditor(
|
||||||
|
key: ValueKey(i),
|
||||||
|
group: sub,
|
||||||
|
onChanged: (g) => _replaceChild(i, g),
|
||||||
|
depth: depth + 1,
|
||||||
|
onRemoveGroup: () => _removeChild(i),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Operator row (AND / OR toggle)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _OperatorRow extends StatelessWidget {
|
||||||
|
const _OperatorRow({
|
||||||
|
required this.operator,
|
||||||
|
required this.onChanged,
|
||||||
|
this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterOperator operator;
|
||||||
|
final void Function(FilterOperator) onChanged;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SegmentedButton<FilterOperator>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: FilterOperator.and_, label: Text('AND')),
|
||||||
|
ButtonSegment(value: FilterOperator.or_, label: Text('OR')),
|
||||||
|
],
|
||||||
|
selected: {operator},
|
||||||
|
onSelectionChanged: (s) => onChanged(s.first),
|
||||||
|
style: const ButtonStyle(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (onRemove != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
tooltip: 'Remove group',
|
||||||
|
onPressed: onRemove,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Leaf row (field | comparison | value | delete)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _LeafRow extends StatefulWidget {
|
||||||
|
const _LeafRow({
|
||||||
|
super.key,
|
||||||
|
required this.leaf,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterLeaf leaf;
|
||||||
|
final void Function(FilterLeaf) onChanged;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_LeafRow> createState() => _LeafRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LeafRowState extends State<_LeafRow> {
|
||||||
|
late final TextEditingController _ctrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = TextEditingController(text: widget.leaf.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_LeafRow old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.leaf.value != _ctrl.text) {
|
||||||
|
_ctrl.text = widget.leaf.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFieldChanged(FilterField? f) {
|
||||||
|
if (f == null) return;
|
||||||
|
final allowed = f.allowedComparisons;
|
||||||
|
final comp = allowed.contains(widget.leaf.comparison)
|
||||||
|
? widget.leaf.comparison
|
||||||
|
: allowed.first;
|
||||||
|
widget.onChanged(widget.leaf.copyWith(field: f, comparison: comp));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCompChanged(FilterComparison? c) {
|
||||||
|
if (c == null) return;
|
||||||
|
widget.onChanged(widget.leaf.copyWith(comparison: c));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
DropdownButton<FilterField>(
|
||||||
|
value: widget.leaf.field,
|
||||||
|
onChanged: _onFieldChanged,
|
||||||
|
isDense: true,
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
items: FilterField.values
|
||||||
|
.map(
|
||||||
|
(f) => DropdownMenuItem(value: f, child: Text(f.label)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
DropdownButton<FilterComparison>(
|
||||||
|
value: widget.leaf.comparison,
|
||||||
|
onChanged: _onCompChanged,
|
||||||
|
isDense: true,
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
items: widget.leaf.field.allowedComparisons
|
||||||
|
.map(
|
||||||
|
(c) => DropdownMenuItem(value: c, child: Text(c.label)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _ctrl,
|
||||||
|
onChanged: (v) =>
|
||||||
|
widget.onChanged(widget.leaf.copyWith(value: v)),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'value',
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: widget.onDelete,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -675,10 +675,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1104,26 +1104,26 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.31.0"
|
version: "1.30.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.17"
|
version: "0.6.16"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const _excluded = {
|
|||||||
'lib/ui/widgets/email_thread_tile.dart',
|
'lib/ui/widgets/email_thread_tile.dart',
|
||||||
'lib/ui/screens/trusted_image_senders_screen.dart',
|
'lib/ui/screens/trusted_image_senders_screen.dart',
|
||||||
'lib/data/repositories/note_repository_impl.dart',
|
'lib/data/repositories/note_repository_impl.dart',
|
||||||
|
'lib/ui/widgets/filter_builder.dart',
|
||||||
'lib/ui/widgets/thread_tile.dart',
|
'lib/ui/widgets/thread_tile.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
@@ -272,6 +273,13 @@ class _FakeEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? a,
|
||||||
|
FilterGroup f,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/services.dart' show MissingPluginException;
|
import 'package:flutter/services.dart' show MissingPluginException;
|
||||||
import 'package:mockito/annotations.dart';
|
import 'package:mockito/annotations.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
@@ -137,6 +138,12 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||||
@override
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? a,
|
||||||
|
FilterGroup f,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
||||||
@override
|
@override
|
||||||
Future<List<EmailAddress>> searchAddresses(
|
Future<List<EmailAddress>> searchAddresses(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'dart:async' as _i5;
|
|||||||
|
|
||||||
import 'package:mockito/mockito.dart' as _i1;
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
import 'package:mockito/src/dummies.dart' as _i7;
|
import 'package:mockito/src/dummies.dart' as _i7;
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i10;
|
||||||
import 'package:sharedinbox/core/models/account.dart' as _i6;
|
import 'package:sharedinbox/core/models/account.dart' as _i6;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as _i3;
|
import 'package:sharedinbox/core/models/email.dart' as _i3;
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
|
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
|
||||||
@@ -545,6 +546,22 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||||
) as _i5.Future<List<_i3.Email>>);
|
) as _i5.Future<List<_i3.Email>>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i5.Future<List<_i3.Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
_i10.FilterGroup? filter,
|
||||||
|
) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#searchEmailsStructured,
|
||||||
|
[
|
||||||
|
accountId,
|
||||||
|
filter,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||||
|
) as _i5.Future<List<_i3.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.Email>> getEmailsByAddress(
|
_i5.Future<List<_i3.Email>> getEmailsByAddress(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('FilterGroup', () {
|
||||||
|
test('empty() creates an empty group', () {
|
||||||
|
final g = FilterGroup.empty();
|
||||||
|
expect(g.isEmpty, isTrue);
|
||||||
|
expect(g.children, isEmpty);
|
||||||
|
expect(g.operator, FilterOperator.and_);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-empty group is not isEmpty', () {
|
||||||
|
final g = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'test',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(g.isEmpty, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith changes operator', () {
|
||||||
|
final g = FilterGroup.empty().copyWith(operator: FilterOperator.or_);
|
||||||
|
expect(g.operator, FilterOperator.or_);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith changes children', () {
|
||||||
|
final leaf = FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'hello',
|
||||||
|
);
|
||||||
|
final g = FilterGroup.empty().copyWith(children: [leaf]);
|
||||||
|
expect(g.children, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FilterLeaf', () {
|
||||||
|
test('copyWith changes field', () {
|
||||||
|
final leaf = FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'x',
|
||||||
|
);
|
||||||
|
final updated = leaf.copyWith(field: FilterField.to);
|
||||||
|
expect(updated.field, FilterField.to);
|
||||||
|
expect(updated.comparison, FilterComparison.contains);
|
||||||
|
expect(updated.value, 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith changes value', () {
|
||||||
|
final leaf = FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.is_,
|
||||||
|
value: 'old',
|
||||||
|
);
|
||||||
|
final updated = leaf.copyWith(value: 'new');
|
||||||
|
expect(updated.value, 'new');
|
||||||
|
expect(updated.field, FilterField.subject);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('size field allows over/under comparisons', () {
|
||||||
|
expect(
|
||||||
|
FilterField.size.allowedComparisons,
|
||||||
|
containsAll([FilterComparison.over, FilterComparison.under]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('address fields do not allow over/under', () {
|
||||||
|
for (final f in [FilterField.from_, FilterField.to, FilterField.cc]) {
|
||||||
|
expect(f.allowedComparisons, isNot(contains(FilterComparison.over)));
|
||||||
|
expect(f.allowedComparisons, isNot(contains(FilterComparison.under)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SieveSerializer', () {
|
||||||
|
final ser = SieveSerializer();
|
||||||
|
|
||||||
|
test('empty filter with keep action', () {
|
||||||
|
final script = ser.serialize(FilterGroup.empty(), [KeepAction()]);
|
||||||
|
expect(script, contains('keep;'));
|
||||||
|
expect(script, isNot(contains('if ')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single from-contains condition', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'alice',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [FileIntoAction('Work')]);
|
||||||
|
expect(script, contains('require'));
|
||||||
|
expect(script, contains('fileinto'));
|
||||||
|
expect(script, contains('"Work"'));
|
||||||
|
expect(script, contains(':contains'));
|
||||||
|
expect(script, contains('"from"'));
|
||||||
|
expect(script, contains('"alice"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AND group serialises as allof', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'invoice',
|
||||||
|
),
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'supplier',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [KeepAction()]);
|
||||||
|
expect(script, contains('allof'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OR group serialises as anyof', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.or_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'a',
|
||||||
|
),
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'b',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [DiscardAction()]);
|
||||||
|
expect(script, contains('anyof'));
|
||||||
|
expect(script, contains('discard;'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('size over condition', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.size,
|
||||||
|
comparison: FilterComparison.over,
|
||||||
|
value: '1000000',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [DiscardAction()]);
|
||||||
|
expect(script, contains('size :over 1000000'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mark-as-seen action emits setflag', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'newsletter',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [MarkAsSeenAction()]);
|
||||||
|
expect(script, contains('setflag'));
|
||||||
|
expect(script, contains(r'\Seen'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes quotes in values', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'say "hello"',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [KeepAction()]);
|
||||||
|
expect(script, contains(r'say \"hello\"'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FilterSieveConverter', () {
|
||||||
|
final conv = FilterSieveConverter();
|
||||||
|
|
||||||
|
test('returns null for empty script', () {
|
||||||
|
expect(conv.parse(''), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses simple address test', () {
|
||||||
|
const script = '''
|
||||||
|
if address :contains "from" "alice@example.com" {
|
||||||
|
keep;
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.group.children, hasLength(1));
|
||||||
|
final leaf = result.group.children.first as FilterLeaf;
|
||||||
|
expect(leaf.field, FilterField.from_);
|
||||||
|
expect(leaf.comparison, FilterComparison.contains);
|
||||||
|
expect(leaf.value, 'alice@example.com');
|
||||||
|
expect(result.actions, hasLength(1));
|
||||||
|
expect(result.actions.first, isA<KeepAction>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses subject header test', () {
|
||||||
|
const script = '''
|
||||||
|
if header :is "subject" "Hello" {
|
||||||
|
fileinto "Inbox";
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
final leaf = result!.group.children.first as FilterLeaf;
|
||||||
|
expect(leaf.field, FilterField.subject);
|
||||||
|
expect(leaf.comparison, FilterComparison.is_);
|
||||||
|
expect(leaf.value, 'Hello');
|
||||||
|
final action = result.actions.first as FileIntoAction;
|
||||||
|
expect(action.folder, 'Inbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses allof group as AND', () {
|
||||||
|
const script = '''
|
||||||
|
if allof(
|
||||||
|
address :contains "from" "alice",
|
||||||
|
header :contains "subject" "invoice"
|
||||||
|
) {
|
||||||
|
keep;
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.group.operator, FilterOperator.and_);
|
||||||
|
expect(result.group.children, hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses anyof group as OR', () {
|
||||||
|
const script = '''
|
||||||
|
if anyof(
|
||||||
|
address :contains "from" "a",
|
||||||
|
address :contains "from" "b"
|
||||||
|
) {
|
||||||
|
discard;
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.group.operator, FilterOperator.or_);
|
||||||
|
expect(result.actions.first, isA<DiscardAction>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses size over test', () {
|
||||||
|
const script = '''
|
||||||
|
if size :over 500000 {
|
||||||
|
discard;
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
final leaf = result!.group.children.first as FilterLeaf;
|
||||||
|
expect(leaf.field, FilterField.size);
|
||||||
|
expect(leaf.comparison, FilterComparison.over);
|
||||||
|
expect(leaf.value, '500000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses setflag \\\\Seen as MarkAsSeenAction', () {
|
||||||
|
const script = r'''
|
||||||
|
if header :contains "subject" "newsletter" {
|
||||||
|
setflag "\\Seen";
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.actions.first, isA<MarkAsSeenAction>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for unsupported test', () {
|
||||||
|
const script = '''
|
||||||
|
if exists "X-Custom-Header" {
|
||||||
|
keep;
|
||||||
|
}''';
|
||||||
|
expect(conv.parse(script), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips through serializer', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'alice@example.com',
|
||||||
|
),
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'invoice',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final actions = <SieveAction>[FileIntoAction('Work')];
|
||||||
|
final script = SieveSerializer().serialize(group, actions);
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.group.operator, FilterOperator.and_);
|
||||||
|
expect(result.group.children, hasLength(2));
|
||||||
|
expect(result.actions, hasLength(1));
|
||||||
|
expect((result.actions.first as FileIntoAction).folder, 'Work');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses require block and ignores it', () {
|
||||||
|
const script = '''
|
||||||
|
require ["fileinto"];
|
||||||
|
if address :contains "from" "bob" {
|
||||||
|
fileinto "Archive";
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
final leaf = result!.group.children.first as FilterLeaf;
|
||||||
|
expect(leaf.value, 'bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('globMatch', () {
|
||||||
|
test('exact match (no wildcards)', () {
|
||||||
|
expect(globMatch('alice@example.com', 'alice@example.com'), isTrue);
|
||||||
|
expect(globMatch('alice@example.com', 'bob@example.com'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('* matches any domain wildcard', () {
|
||||||
|
expect(globMatch('alice@example.com', '*@example.com'), isTrue);
|
||||||
|
expect(globMatch('bob@example.com', '*@example.com'), isTrue);
|
||||||
|
expect(globMatch('alice@other.com', '*@example.com'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('* matches zero or more characters', () {
|
||||||
|
expect(
|
||||||
|
globMatch('newsletter@news.example.com', '*@*.example.com'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(globMatch('alice@example.com', 'alice*'), isTrue);
|
||||||
|
expect(globMatch('alice@example.com', '*example*'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('? matches exactly one character', () {
|
||||||
|
expect(globMatch('alice@example.com', 'alice@exampl?.com'), isTrue);
|
||||||
|
expect(globMatch('alice@example.com', 'alice@exampl??.com'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive comparison', () {
|
||||||
|
expect(globMatch('Alice@Example.COM', '*@example.com'), isTrue);
|
||||||
|
expect(globMatch('alice@example.com', '*@EXAMPLE.COM'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no wildcards — mismatch is false', () {
|
||||||
|
expect(globMatch('alice@example.com', 'alice@other.com'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bare * matches everything', () {
|
||||||
|
expect(globMatch('alice@example.com', '*'), isTrue);
|
||||||
|
expect(globMatch('', '*'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty pattern only matches empty string', () {
|
||||||
|
expect(globMatch('', ''), isTrue);
|
||||||
|
expect(globMatch('alice@example.com', ''), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
// checked the _running flag (only true after start() is called).
|
// checked the _running flag (only true after start() is called).
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
@@ -144,6 +145,12 @@ class _FakeEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||||
@override
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? a,
|
||||||
|
FilterGroup f,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
||||||
@override
|
@override
|
||||||
Future<List<EmailAddress>> searchAddresses(
|
Future<List<EmailAddress>> searchAddresses(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:fake_async/fake_async.dart';
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
@@ -140,6 +141,12 @@ class _CountingEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||||
@override
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? a,
|
||||||
|
FilterGroup f,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
||||||
@override
|
@override
|
||||||
Future<List<EmailAddress>> searchAddresses(
|
Future<List<EmailAddress>> searchAddresses(
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import 'dart:async' as _i4;
|
|||||||
|
|
||||||
import 'package:mockito/mockito.dart' as _i1;
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
import 'package:mockito/src/dummies.dart' as _i5;
|
import 'package:mockito/src/dummies.dart' as _i5;
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i6;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart' as _i7;
|
import 'package:sharedinbox/core/models/undo_action.dart' as _i8;
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
|
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i6;
|
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i7;
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
// ignore_for_file: avoid_redundant_argument_values
|
// ignore_for_file: avoid_redundant_argument_values
|
||||||
@@ -342,6 +343,22 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
) as _i4.Future<List<_i2.Email>>);
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<List<_i2.Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
_i6.FilterGroup? filter,
|
||||||
|
) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#searchEmailsStructured,
|
||||||
|
[
|
||||||
|
accountId,
|
||||||
|
filter,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
@@ -558,13 +575,13 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
/// A class which mocks [UndoRepository].
|
/// A class which mocks [UndoRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
|
class MockUndoRepository extends _i1.Mock implements _i7.UndoRepository {
|
||||||
MockUndoRepository() {
|
MockUndoRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> saveAction(_i7.UndoAction? action) => (super.noSuchMethod(
|
_i4.Future<void> saveAction(_i8.UndoAction? action) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#saveAction,
|
#saveAction,
|
||||||
[action],
|
[action],
|
||||||
@@ -584,15 +601,15 @@ class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
|
|||||||
) as _i4.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<List<_i7.UndoAction>> getHistory({int? limit = 10}) =>
|
_i4.Future<List<_i8.UndoAction>> getHistory({int? limit = 10}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getHistory,
|
#getHistory,
|
||||||
[],
|
[],
|
||||||
{#limit: limit},
|
{#limit: limit},
|
||||||
),
|
),
|
||||||
returnValue: _i4.Future<List<_i7.UndoAction>>.value(<_i7.UndoAction>[]),
|
returnValue: _i4.Future<List<_i8.UndoAction>>.value(<_i8.UndoAction>[]),
|
||||||
) as _i4.Future<List<_i7.UndoAction>>);
|
) as _i4.Future<List<_i8.UndoAction>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> clearHistory() => (super.noSuchMethod(
|
_i4.Future<void> clearHistory() => (super.noSuchMethod(
|
||||||
|
|||||||
@@ -798,6 +798,67 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'pressing Enter after search settles does not reorder results',
|
||||||
|
(tester) async {
|
||||||
|
// Reproduces: user types a query → onChanged fires → results settle.
|
||||||
|
// Then user presses Enter → onSubmitted fires a second search → the
|
||||||
|
// second IMAP response may return results in a different order, so the
|
||||||
|
// tile the user is about to tap is no longer the email they expect.
|
||||||
|
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Foo');
|
||||||
|
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Foo');
|
||||||
|
var callCount = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
onSearch: (_) async {
|
||||||
|
callCount++;
|
||||||
|
// First call: [Alpha, Beta]. Second call: reversed.
|
||||||
|
return callCount == 1 ? [email1, email2] : [email2, email1];
|
||||||
|
},
|
||||||
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Typing triggers onChanged → first search → results settle.
|
||||||
|
await tester.enterText(find.byType(TextField), 'foo');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Alpha Foo'), findsOneWidget);
|
||||||
|
expect(find.text('Beta Foo'), findsOneWidget);
|
||||||
|
// Alpha must appear above Beta (it is first in the list).
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.text('Alpha Foo')).dy,
|
||||||
|
lessThan(tester.getTopLeft(find.text('Beta Foo')).dy),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pressing Enter triggers onSubmitted — must NOT re-run the search.
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Order must be unchanged: pressing Enter must not reorder results.
|
||||||
|
expect(find.text('Alpha Foo'), findsOneWidget);
|
||||||
|
expect(find.text('Beta Foo'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.text('Alpha Foo')).dy,
|
||||||
|
lessThan(tester.getTopLeft(find.text('Beta Foo')).dy),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('shows preview snippet when email has preview', (tester) async {
|
testWidgets('shows preview snippet when email has preview', (tester) async {
|
||||||
final email = Email(
|
final email = Email(
|
||||||
id: 'acc-1:99',
|
id: 'acc-1:99',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/discovery_result.dart';
|
import 'package:sharedinbox/core/models/discovery_result.dart';
|
||||||
import 'package:sharedinbox/core/models/draft.dart';
|
import 'package:sharedinbox/core/models/draft.dart';
|
||||||
@@ -43,6 +44,7 @@ import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/search_screen.dart';
|
import 'package:sharedinbox/ui/screens/search_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -365,6 +367,13 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
) async =>
|
) async =>
|
||||||
_searchResults;
|
_searchResults;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
FilterGroup filter,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(
|
Future<List<Email>> getEmailsByAddress(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
@@ -476,6 +485,12 @@ Widget buildApp({
|
|||||||
path: 'preferences',
|
path: 'preferences',
|
||||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'trusted-senders',
|
||||||
|
builder: (ctx, state) => TrustedImageSendersScreen(
|
||||||
|
highlightedSender: state.extra as String?,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/edit',
|
path: ':accountId/edit',
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
@@ -688,6 +703,9 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
|||||||
AfterMailViewAction afterMailViewAction;
|
AfterMailViewAction afterMailViewAction;
|
||||||
final List<String> _trustedImageSenders;
|
final List<String> _trustedImageSenders;
|
||||||
|
|
||||||
|
List<String> get trustedImageSendersForTest =>
|
||||||
|
List.unmodifiable(_trustedImageSenders);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||||
UserPreferences(
|
UserPreferences(
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TrustedImageSendersScreen', () {
|
||||||
|
testWidgets('shows empty state with glob hint when no senders', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/trusted-senders',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('*@example.com'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.add), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('lists existing senders', (tester) async {
|
||||||
|
final repo = FakeUserPreferencesRepository(
|
||||||
|
trustedImageSenders: ['alice@example.com', '*@work.com'],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/trusted-senders',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
userPreferences: repo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('alice@example.com'), findsOneWidget);
|
||||||
|
expect(find.text('*@work.com'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('add dialog shows glob hint text', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/trusted-senders',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add allowed address'), findsOneWidget);
|
||||||
|
expect(find.textContaining('*@example.com'), findsWidgets);
|
||||||
|
expect(find.textContaining('* matches any characters'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Add button is disabled when input is empty', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/trusted-senders',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final addButton = find.widgetWithText(TextButton, 'Add');
|
||||||
|
final button = tester.widget<TextButton>(addButton);
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('typing in dialog enables Add button and adds sender', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repo = FakeUserPreferencesRepository();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/trusted-senders',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
userPreferences: repo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '*@example.com');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final addButton = find.widgetWithText(TextButton, 'Add');
|
||||||
|
final button = tester.widget<TextButton>(addButton);
|
||||||
|
expect(button.onPressed, isNotNull);
|
||||||
|
|
||||||
|
await tester.tap(addButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repo.trustedImageSendersForTest, contains('*@example.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cancel closes dialog without adding', (tester) async {
|
||||||
|
final repo = FakeUserPreferencesRepository();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/trusted-senders',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
userPreferences: repo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'someone@test.com');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsNothing);
|
||||||
|
expect(repo.trustedImageSendersForTest, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delete button removes a sender', (tester) async {
|
||||||
|
final repo = FakeUserPreferencesRepository(
|
||||||
|
trustedImageSenders: ['alice@example.com'],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/trusted-senders',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
userPreferences: repo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repo.trustedImageSendersForTest, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('lists existing glob patterns', (tester) async {
|
||||||
|
final repo = FakeUserPreferencesRepository(
|
||||||
|
trustedImageSenders: ['*@example.com', 'alice@other.com'],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/trusted-senders',
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
userPreferences: repo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('*@example.com'), findsOneWidget);
|
||||||
|
expect(find.text('alice@other.com'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user