@remit/ui 0.0.56 → 0.0.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/components/app-shell-types.ts +8 -1
- package/src/components/auto-moved-badge.stories.tsx +3 -21
- package/src/components/auto-moved-badge.tsx +8 -17
- package/src/components/brief-empty.stories.tsx +51 -0
- package/src/components/brief-empty.tsx +60 -0
- package/src/components/dialog.tsx +10 -5
- package/src/components/filter-clause-chip.tsx +39 -1
- package/src/components/filter-rule-editor.stories.tsx +163 -1
- package/src/components/filter-rule-editor.tsx +47 -0
- package/src/components/filter-rule.render.test.ts +33 -0
- package/src/components/filter-rule.ts +141 -0
- package/src/components/input.tsx +7 -0
- package/src/components/mail-header.tsx +10 -0
- package/src/components/message-row.tsx +41 -3
- package/src/components/mobile-search-view.render.test.ts +31 -0
- package/src/components/mobile-search-view.stories.tsx +115 -6
- package/src/components/mobile-search-view.tsx +34 -8
- package/src/components/password-input.render.test.ts +149 -0
- package/src/components/password-input.tsx +45 -0
- package/src/components/primitives.stories.tsx +28 -0
- package/src/components/search-bar.tsx +9 -0
- package/src/components/search-chip-input.tsx +85 -3
- package/src/components/search-result-row.tsx +68 -82
- package/src/components/search-results.stories.tsx +12 -0
- package/src/components/search-results.tsx +20 -8
- package/src/components/suggest-list.render.test.ts +59 -0
- package/src/components/suggest-list.tsx +96 -0
- package/src/index.ts +55 -0
- package/src/lib/property-prefill.test.ts +173 -0
- package/src/lib/property-prefill.ts +151 -0
- package/src/lib/search-rule.test.ts +73 -0
- package/src/lib/search-rule.ts +96 -0
- package/src/lib/sender-derivation.test.ts +106 -0
- package/src/lib/sender-derivation.ts +85 -0
- package/src/lib/suggest-keys.test.ts +101 -0
- package/src/lib/suggest-keys.ts +63 -0
- package/src/lib/use-long-press.ts +70 -37
- package/src/lib/use-suggest-list.test.ts +169 -0
- package/src/lib/use-suggest-list.ts +124 -0
package/src/index.ts
CHANGED
|
@@ -71,6 +71,11 @@ export {
|
|
|
71
71
|
BottomSheet,
|
|
72
72
|
type BottomSheetProps,
|
|
73
73
|
} from "./components/bottom-sheet.js";
|
|
74
|
+
export {
|
|
75
|
+
BriefEmpty,
|
|
76
|
+
type BriefEmptyProps,
|
|
77
|
+
type BriefSyncProgress,
|
|
78
|
+
} from "./components/brief-empty.js";
|
|
74
79
|
export {
|
|
75
80
|
BriefSection,
|
|
76
81
|
type BriefSectionProps,
|
|
@@ -143,23 +148,31 @@ export {
|
|
|
143
148
|
clauseFieldOrder,
|
|
144
149
|
commitBlockedReason,
|
|
145
150
|
commitLabel,
|
|
151
|
+
demoClauseSuggestions,
|
|
146
152
|
demoFolders,
|
|
147
153
|
demoLabels,
|
|
148
154
|
demoRule,
|
|
149
155
|
demoSenderFallbackRule,
|
|
156
|
+
demoSubjectPrefillRule,
|
|
150
157
|
demoVocabularyRule,
|
|
151
158
|
type FilterRule,
|
|
152
159
|
type FolderOption,
|
|
160
|
+
hasActiveWiden,
|
|
153
161
|
type LabelOption,
|
|
154
162
|
type MatchOperator,
|
|
163
|
+
matchesBodyText,
|
|
155
164
|
matchJoinWord,
|
|
165
|
+
matchModeHint,
|
|
166
|
+
matchModeLabel,
|
|
156
167
|
matchOperatorLabel,
|
|
157
168
|
type PreviewCount,
|
|
158
169
|
previewCountSummary,
|
|
159
170
|
type RuleClause,
|
|
171
|
+
type RuleMatchMode,
|
|
160
172
|
type RuleScope,
|
|
161
173
|
type RuleWiden,
|
|
162
174
|
scopeLabel,
|
|
175
|
+
unreadableBodyClauses,
|
|
163
176
|
widenChipLabel,
|
|
164
177
|
} from "./components/filter-rule.js";
|
|
165
178
|
export {
|
|
@@ -302,6 +315,10 @@ export {
|
|
|
302
315
|
type OutboxStatusBadgeProps,
|
|
303
316
|
outboxStatusConfig,
|
|
304
317
|
} from "./components/outbox-status-badge.js";
|
|
318
|
+
export {
|
|
319
|
+
PasswordInput,
|
|
320
|
+
type PasswordInputProps,
|
|
321
|
+
} from "./components/password-input.js";
|
|
305
322
|
export {
|
|
306
323
|
PopoverMenu,
|
|
307
324
|
type PopoverMenuItem,
|
|
@@ -397,9 +414,11 @@ export {
|
|
|
397
414
|
} from "./components/row-actions.js";
|
|
398
415
|
export { SearchBar, type SearchBarProps } from "./components/search-bar.js";
|
|
399
416
|
export {
|
|
417
|
+
type SearchCaretRequest,
|
|
400
418
|
type SearchChip,
|
|
401
419
|
SearchChipInput,
|
|
402
420
|
type SearchChipInputProps,
|
|
421
|
+
type SearchFieldSuggest,
|
|
403
422
|
} from "./components/search-chip-input.js";
|
|
404
423
|
export {
|
|
405
424
|
DROPPED_SEMANTIC_COPY,
|
|
@@ -419,6 +438,8 @@ export {
|
|
|
419
438
|
type SearchResultTone,
|
|
420
439
|
} from "./components/search-result-row.js";
|
|
421
440
|
export {
|
|
441
|
+
MakeFilterAction,
|
|
442
|
+
type MakeFilterActionProps,
|
|
422
443
|
partitionSpamResults,
|
|
423
444
|
type SearchResultSection,
|
|
424
445
|
SearchResults,
|
|
@@ -523,6 +544,11 @@ export {
|
|
|
523
544
|
SpamResultsOffer,
|
|
524
545
|
type SpamResultsOfferProps,
|
|
525
546
|
} from "./components/spam-results-offer.js";
|
|
547
|
+
export {
|
|
548
|
+
type Suggestion,
|
|
549
|
+
SuggestList,
|
|
550
|
+
type SuggestListProps,
|
|
551
|
+
} from "./components/suggest-list.js";
|
|
526
552
|
export {
|
|
527
553
|
commitPeek,
|
|
528
554
|
SwipeableRow,
|
|
@@ -572,6 +598,11 @@ export {
|
|
|
572
598
|
labelColorOptions,
|
|
573
599
|
labelDotClass,
|
|
574
600
|
} from "./lib/label-color.js";
|
|
601
|
+
export {
|
|
602
|
+
derivePropertyClauses,
|
|
603
|
+
normalizeSubject,
|
|
604
|
+
sharedSubjectFragment,
|
|
605
|
+
} from "./lib/property-prefill.js";
|
|
575
606
|
export {
|
|
576
607
|
LIST_ROW_ATTRIBUTE,
|
|
577
608
|
LIST_ROW_SELECTOR,
|
|
@@ -580,8 +611,32 @@ export {
|
|
|
580
611
|
type UseRovingFocusOptions,
|
|
581
612
|
useRovingFocus,
|
|
582
613
|
} from "./lib/roving-focus.js";
|
|
614
|
+
export {
|
|
615
|
+
buildSearchRule,
|
|
616
|
+
type DroppedFacet,
|
|
617
|
+
type DroppedFacetType,
|
|
618
|
+
isConvertible,
|
|
619
|
+
type SearchConversion,
|
|
620
|
+
} from "./lib/search-rule.js";
|
|
621
|
+
export {
|
|
622
|
+
collapsibleDomain,
|
|
623
|
+
deriveSenderClauses,
|
|
624
|
+
distinctSenders,
|
|
625
|
+
senderDomain,
|
|
626
|
+
} from "./lib/sender-derivation.js";
|
|
627
|
+
export {
|
|
628
|
+
type SuggestAction,
|
|
629
|
+
type SuggestKeyState,
|
|
630
|
+
suggestKeyAction,
|
|
631
|
+
} from "./lib/suggest-keys.js";
|
|
583
632
|
export {
|
|
584
633
|
type UseLongPressOptions,
|
|
585
634
|
type UseLongPressResult,
|
|
586
635
|
useLongPress,
|
|
587
636
|
} from "./lib/use-long-press.js";
|
|
637
|
+
export {
|
|
638
|
+
type ComboboxProps,
|
|
639
|
+
type SuggestListState,
|
|
640
|
+
type UseSuggestListInput,
|
|
641
|
+
useSuggestList,
|
|
642
|
+
} from "./lib/use-suggest-list.js";
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opening clauses a properties-only rule offers (RFC 038 D2/D4). The
|
|
3
|
+
* prefill is a starting point the user edits, so the contract worth holding is
|
|
4
|
+
* the order of evidence — sender before subject, and nothing at all rather than
|
|
5
|
+
* a guess that matches half a mailbox.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import {
|
|
11
|
+
derivePropertyClauses,
|
|
12
|
+
normalizeSubject,
|
|
13
|
+
sharedSubjectFragment,
|
|
14
|
+
} from "./property-prefill.js";
|
|
15
|
+
|
|
16
|
+
describe("normalizeSubject", () => {
|
|
17
|
+
it("strips stacked reply, forward, and list decorations", () => {
|
|
18
|
+
assert.equal(
|
|
19
|
+
normalizeSubject("Re: Fwd: [ops] Invoice 1841"),
|
|
20
|
+
"Invoice 1841",
|
|
21
|
+
);
|
|
22
|
+
assert.equal(normalizeSubject("RE[2]: Invoice 1841"), "Invoice 1841");
|
|
23
|
+
assert.equal(normalizeSubject("AW: SV: Invoice 1841"), "Invoice 1841");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("collapses whitespace and trims", () => {
|
|
27
|
+
assert.equal(normalizeSubject(" Invoice 1841 "), "Invoice 1841");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("leaves a subject that is only decoration empty", () => {
|
|
31
|
+
assert.equal(normalizeSubject("Re: "), "");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("sharedSubjectFragment", () => {
|
|
36
|
+
it("takes the longest run of whole words every subject carries", () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
sharedSubjectFragment([
|
|
39
|
+
"Invoice 1841",
|
|
40
|
+
"Invoice 1902",
|
|
41
|
+
"Re: Invoice 2003",
|
|
42
|
+
]),
|
|
43
|
+
"Invoice",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("matches case-insensitively and answers in the first subject's casing", () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
sharedSubjectFragment(["Invoice 1841", "INVOICE 1902"]),
|
|
50
|
+
"Invoice",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("compares whole words, never a partial one", () => {
|
|
55
|
+
// "Invoice 18" is a shared character run and not a shared word run.
|
|
56
|
+
assert.equal(
|
|
57
|
+
sharedSubjectFragment(["Invoice 1841", "Invoice 1892"]),
|
|
58
|
+
"Invoice",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps a single subject as its own fragment", () => {
|
|
63
|
+
assert.equal(sharedSubjectFragment(["Invoice 1841"]), "Invoice 1841");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects a single subject that is nothing but filler", () => {
|
|
67
|
+
assert.equal(sharedSubjectFragment(["for you"]), undefined);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects a shared run of filler words", () => {
|
|
71
|
+
assert.equal(
|
|
72
|
+
sharedSubjectFragment(["the report", "the summary"]),
|
|
73
|
+
undefined,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("rejects a shared run too short to be worth matching", () => {
|
|
78
|
+
assert.equal(
|
|
79
|
+
sharedSubjectFragment(["Q3 numbers", "Q3 results"]),
|
|
80
|
+
undefined,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("is undefined when the subjects share nothing", () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
sharedSubjectFragment(["Invoice 1841", "Standup notes"]),
|
|
87
|
+
undefined,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("is undefined when no subject survives normalizing", () => {
|
|
92
|
+
assert.equal(sharedSubjectFragment(["Re:", " "]), undefined);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("derivePropertyClauses", () => {
|
|
97
|
+
it("matches on the sender when the whole selection is from one address", () => {
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
derivePropertyClauses(
|
|
100
|
+
["npm@github.com", "npm@github.com", "npm@github.com"],
|
|
101
|
+
["Invoice 1841", "Standup notes", "Deploy failed"],
|
|
102
|
+
),
|
|
103
|
+
[{ field: "From", value: "npm@github.com" }],
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("matches on the domain when several senders share one", () => {
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
derivePropertyClauses(
|
|
110
|
+
["npm@github.com", "notifications@github.com", "ci@sub.github.com"],
|
|
111
|
+
["Invoice 1841", "Standup notes", "Deploy failed"],
|
|
112
|
+
),
|
|
113
|
+
[{ field: "FromDomain", value: "github.com" }],
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("prefers the sender over the subject even when the subjects share a run", () => {
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
derivePropertyClauses(
|
|
120
|
+
["npm@github.com", "npm@github.com"],
|
|
121
|
+
["Invoice 1841", "Invoice 1902"],
|
|
122
|
+
),
|
|
123
|
+
[{ field: "From", value: "npm@github.com" }],
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("falls back to what mixed senders' subjects have in common", () => {
|
|
128
|
+
assert.deepEqual(
|
|
129
|
+
derivePropertyClauses(
|
|
130
|
+
["billing@acme.test", "accounts@globex.test", "ap@initech.test"],
|
|
131
|
+
["Invoice 1841", "Invoice 1902", "Re: Invoice 2003"],
|
|
132
|
+
),
|
|
133
|
+
[{ field: "Subject", value: "Invoice" }],
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("offers one sender chip each when mixed senders share no subject either", () => {
|
|
138
|
+
assert.deepEqual(
|
|
139
|
+
derivePropertyClauses(
|
|
140
|
+
["billing@acme.test", "accounts@globex.test"],
|
|
141
|
+
["Invoice 1841", "Standup notes"],
|
|
142
|
+
),
|
|
143
|
+
[
|
|
144
|
+
{ field: "From", value: "billing@acme.test" },
|
|
145
|
+
{ field: "From", value: "accounts@globex.test" },
|
|
146
|
+
],
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("refuses a filler-only subject run rather than prefilling a wide match", () => {
|
|
151
|
+
assert.deepEqual(
|
|
152
|
+
derivePropertyClauses(
|
|
153
|
+
["billing@acme.test", "accounts@globex.test"],
|
|
154
|
+
["the report", "the summary"],
|
|
155
|
+
),
|
|
156
|
+
[
|
|
157
|
+
{ field: "From", value: "billing@acme.test" },
|
|
158
|
+
{ field: "From", value: "accounts@globex.test" },
|
|
159
|
+
],
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("reads a single message as its own sender", () => {
|
|
164
|
+
assert.deepEqual(
|
|
165
|
+
derivePropertyClauses(["billing@acme.test"], ["Invoice 1841"]),
|
|
166
|
+
[{ field: "From", value: "billing@acme.test" }],
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("offers nothing when the selection carries no sender and no shared subject", () => {
|
|
171
|
+
assert.deepEqual(derivePropertyClauses([], ["Re:", " "]), []);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { RuleClause } from "../components/filter-rule.js";
|
|
2
|
+
import { deriveSenderClauses, distinctSenders } from "./sender-derivation.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The opening clauses for a rule matched on properties alone — no semantic
|
|
6
|
+
* anchor (RFC 038 D2/D4). The selection is the only evidence available, so the
|
|
7
|
+
* prefill reads it in the order the evidence is strongest:
|
|
8
|
+
*
|
|
9
|
+
* 1. One sender across the whole selection, or several that collapse to one
|
|
10
|
+
* registrable domain — the sender derivation (#251, #262) already decides
|
|
11
|
+
* between a `From` and a `FromDomain` clause, and this reuses it rather than
|
|
12
|
+
* deciding again.
|
|
13
|
+
* 2. Mixed senders — what the messages share is their subject, so match on the
|
|
14
|
+
* part the subjects have in common instead.
|
|
15
|
+
* 3. Neither — no clauses. The editor opens empty and asks for one; a wrong
|
|
16
|
+
* guess costs more than an absent one.
|
|
17
|
+
*
|
|
18
|
+
* Every clause it produces is an ordinary chip: visible, editable, removable.
|
|
19
|
+
* It is where the user starts, never what the rule is.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Reply, forward, and list-tag decorations that carry no meaning for a match. */
|
|
23
|
+
const SUBJECT_PREFIX =
|
|
24
|
+
/^\s*(?:(?:re|aw|fwd?|vs|sv|antw|res|enc|tr)\s*(?:\[\d+\])?\s*:|\[[^\]]{1,32}\])\s*/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A subject reduced to the part worth matching on: reply and forward markers
|
|
28
|
+
* and leading list tags stripped (repeatedly — real threads stack them), and
|
|
29
|
+
* whitespace collapsed.
|
|
30
|
+
*/
|
|
31
|
+
export const normalizeSubject = (subject: string): string => {
|
|
32
|
+
let value = subject.replace(/\s+/g, " ").trim();
|
|
33
|
+
for (;;) {
|
|
34
|
+
const stripped = value.replace(SUBJECT_PREFIX, "");
|
|
35
|
+
if (stripped === value) return value.trim();
|
|
36
|
+
value = stripped;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Words too common to be a rule on their own. A single shared word from this
|
|
42
|
+
* list matches half a mailbox, which is worse than offering nothing.
|
|
43
|
+
*/
|
|
44
|
+
const WEAK_WORDS = new Set([
|
|
45
|
+
"a",
|
|
46
|
+
"an",
|
|
47
|
+
"and",
|
|
48
|
+
"are",
|
|
49
|
+
"as",
|
|
50
|
+
"at",
|
|
51
|
+
"be",
|
|
52
|
+
"by",
|
|
53
|
+
"for",
|
|
54
|
+
"from",
|
|
55
|
+
"has",
|
|
56
|
+
"have",
|
|
57
|
+
"in",
|
|
58
|
+
"is",
|
|
59
|
+
"it",
|
|
60
|
+
"me",
|
|
61
|
+
"my",
|
|
62
|
+
"new",
|
|
63
|
+
"of",
|
|
64
|
+
"on",
|
|
65
|
+
"or",
|
|
66
|
+
"our",
|
|
67
|
+
"the",
|
|
68
|
+
"this",
|
|
69
|
+
"to",
|
|
70
|
+
"was",
|
|
71
|
+
"we",
|
|
72
|
+
"with",
|
|
73
|
+
"you",
|
|
74
|
+
"your",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/** The shortest fragment worth prefilling as a `Subject` clause. */
|
|
78
|
+
const MIN_FRAGMENT_LENGTH = 3;
|
|
79
|
+
|
|
80
|
+
const words = (subject: string): string[] =>
|
|
81
|
+
subject.split(" ").filter((word) => word !== "");
|
|
82
|
+
|
|
83
|
+
const isUseful = (fragment: string[]): boolean => {
|
|
84
|
+
if (fragment.length === 0) return false;
|
|
85
|
+
if (fragment.join(" ").length < MIN_FRAGMENT_LENGTH) return false;
|
|
86
|
+
return fragment.some((word) => !WEAK_WORDS.has(word.toLowerCase()));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const runAt = (haystack: string[], needle: string[]): number =>
|
|
90
|
+
haystack.findIndex((_, start) =>
|
|
91
|
+
needle.every(
|
|
92
|
+
(word, offset) =>
|
|
93
|
+
haystack[start + offset]?.toLowerCase() === word.toLowerCase(),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The longest run of whole words every subject carries, compared
|
|
99
|
+
* case-insensitively and returned in the casing the first subject uses. Whole
|
|
100
|
+
* words rather than raw characters, so "Invoice 1841" and "Invoice 1902" share
|
|
101
|
+
* "Invoice" and not "Invoice 18".
|
|
102
|
+
*
|
|
103
|
+
* `undefined` when the subjects share nothing worth matching on — a single
|
|
104
|
+
* filler word does not count.
|
|
105
|
+
*/
|
|
106
|
+
export const sharedSubjectFragment = (
|
|
107
|
+
subjects: readonly string[],
|
|
108
|
+
): string | undefined => {
|
|
109
|
+
const normalized = subjects
|
|
110
|
+
.map(normalizeSubject)
|
|
111
|
+
.filter((subject) => subject !== "");
|
|
112
|
+
if (normalized.length === 0) return undefined;
|
|
113
|
+
const [first, ...rest] = normalized;
|
|
114
|
+
if (rest.length === 0) return isUseful(words(first)) ? first : undefined;
|
|
115
|
+
|
|
116
|
+
const firstWords = words(first);
|
|
117
|
+
for (let length = firstWords.length; length > 0; length -= 1) {
|
|
118
|
+
for (let start = 0; start + length <= firstWords.length; start += 1) {
|
|
119
|
+
const candidate = firstWords.slice(start, start + length);
|
|
120
|
+
if (!isUseful(candidate)) continue;
|
|
121
|
+
if (rest.every((subject) => runAt(words(subject), candidate) >= 0)) {
|
|
122
|
+
return candidate.join(" ");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The clauses a properties-only rule opens on, derived from the selected
|
|
131
|
+
* messages' senders and subjects. Empty when the selection gives nothing to go
|
|
132
|
+
* on — the editor then holds the commit until the user adds a clause, which is
|
|
133
|
+
* the honest state.
|
|
134
|
+
*/
|
|
135
|
+
export const derivePropertyClauses = (
|
|
136
|
+
senders: readonly string[],
|
|
137
|
+
subjects: readonly string[],
|
|
138
|
+
): Omit<RuleClause, "id">[] => {
|
|
139
|
+
const senderClauses = deriveSenderClauses(senders);
|
|
140
|
+
// One sender across the selection, or several on one registrable domain:
|
|
141
|
+
// a single sharp clause, and sharper than any subject fragment.
|
|
142
|
+
if (distinctSenders(senders).length === 1) return senderClauses;
|
|
143
|
+
if (senderClauses.length === 1 && senderClauses[0].field === "FromDomain")
|
|
144
|
+
return senderClauses;
|
|
145
|
+
|
|
146
|
+
const fragment = sharedSubjectFragment(subjects);
|
|
147
|
+
if (fragment !== undefined) return [{ field: "Subject", value: fragment }];
|
|
148
|
+
// Mixed senders with nothing shared in their subjects: one `From` chip each,
|
|
149
|
+
// the sender derivation's own answer (#251).
|
|
150
|
+
return senderClauses;
|
|
151
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildSearchRule,
|
|
5
|
+
isConvertible,
|
|
6
|
+
type SearchConversion,
|
|
7
|
+
} from "./search-rule.js";
|
|
8
|
+
|
|
9
|
+
const conversion = (
|
|
10
|
+
overrides: Partial<SearchConversion> = {},
|
|
11
|
+
): SearchConversion => ({
|
|
12
|
+
clauses: [],
|
|
13
|
+
matchOperator: "all",
|
|
14
|
+
droppedFacets: [],
|
|
15
|
+
keptTerms: false,
|
|
16
|
+
droppedSemantic: false,
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("isConvertible", () => {
|
|
21
|
+
it("is false when the search yields no clause", () => {
|
|
22
|
+
assert.equal(
|
|
23
|
+
isConvertible(
|
|
24
|
+
conversion({
|
|
25
|
+
droppedFacets: [{ type: "hasAttachment", label: "Has attachment" }],
|
|
26
|
+
scopedOut: { mailboxId: "mbx-archive", label: "archive" },
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
false,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("is true once a term or sender is present", () => {
|
|
34
|
+
assert.equal(
|
|
35
|
+
isConvertible(
|
|
36
|
+
conversion({
|
|
37
|
+
clauses: [{ field: "HasWords", value: "receipts" }],
|
|
38
|
+
keptTerms: true,
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
true,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("buildSearchRule", () => {
|
|
47
|
+
it("builds a standing rule with stable clause ids, no widen, empty name", () => {
|
|
48
|
+
const rule = buildSearchRule(
|
|
49
|
+
conversion({
|
|
50
|
+
clauses: [
|
|
51
|
+
{ field: "From", value: "a@b.com" },
|
|
52
|
+
{ field: "HasWords", value: "nightly" },
|
|
53
|
+
],
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
assert.equal(rule.scope, "standing");
|
|
57
|
+
assert.equal(rule.widen, undefined);
|
|
58
|
+
assert.equal(rule.name, "");
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
rule.clauses.map((clause) => clause.id),
|
|
61
|
+
["search-0", "search-1"],
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("honors an explicit scope and move target", () => {
|
|
66
|
+
const rule = buildSearchRule(
|
|
67
|
+
conversion({ clauses: [{ field: "HasWords", value: "nightly" }] }),
|
|
68
|
+
{ scope: "once", moveMailboxId: "mbx-archive" },
|
|
69
|
+
);
|
|
70
|
+
assert.equal(rule.scope, "once");
|
|
71
|
+
assert.equal(rule.moveMailboxId, "mbx-archive");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a search converts to, and the rule built from it (RFC 038 D5). The shape
|
|
3
|
+
* carries the clauses alongside everything the search held that a filter cannot,
|
|
4
|
+
* so a conversion can never drop a facet without saying so; `search-conversion.ts`
|
|
5
|
+
* beside it owns the copy that states it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ClauseField,
|
|
10
|
+
FilterRule,
|
|
11
|
+
MatchOperator,
|
|
12
|
+
RuleScope,
|
|
13
|
+
} from "../components/filter-rule.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A search facet a filter has no clause for. Attachment, read state, starred,
|
|
17
|
+
* category and the date bounds are attributes of a message, not text a clause
|
|
18
|
+
* matches on.
|
|
19
|
+
*/
|
|
20
|
+
export type DroppedFacetType =
|
|
21
|
+
| "hasAttachment"
|
|
22
|
+
| "isUnread"
|
|
23
|
+
| "isRead"
|
|
24
|
+
| "isStarred"
|
|
25
|
+
| "category"
|
|
26
|
+
| "before"
|
|
27
|
+
| "after";
|
|
28
|
+
|
|
29
|
+
export interface DroppedFacet {
|
|
30
|
+
type: DroppedFacetType;
|
|
31
|
+
/** What was dropped, named for the user (e.g. "Has attachment", "Before 2026-01-01"). */
|
|
32
|
+
label: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ScopedOutFolder {
|
|
36
|
+
mailboxId: string;
|
|
37
|
+
/** The folder the search was limited to. */
|
|
38
|
+
label: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SearchConversion {
|
|
42
|
+
/** Clauses derived from the search — `From` (a `from:` facet) and `HasWords` (the free text). */
|
|
43
|
+
clauses: { field: ClauseField; value: string }[];
|
|
44
|
+
matchOperator: MatchOperator;
|
|
45
|
+
/** A folder an `in:` facet scoped the search to — kept OUT of the rule (never silently unscoped). */
|
|
46
|
+
scopedOut?: ScopedOutFolder;
|
|
47
|
+
/** Facets with no clause equivalent, each named. */
|
|
48
|
+
droppedFacets: DroppedFacet[];
|
|
49
|
+
/** The account an `account:` facet targets — the filter is created for it. */
|
|
50
|
+
targetAccountId?: string;
|
|
51
|
+
/** The search carried free-text terms, kept as a `HasWords` clause. */
|
|
52
|
+
keptTerms: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The filter this conversion builds is always literal-only — free text has no
|
|
55
|
+
* anchor message for a semantic widen — so the search's semantic "similar mail"
|
|
56
|
+
* reach is dropped whenever the search had one (RFC 038 D5). True exactly when
|
|
57
|
+
* free text was kept AND the search surfaced semantically-similar mail; on a
|
|
58
|
+
* deployment with no semantic reach there was nothing to drop, so no note.
|
|
59
|
+
*/
|
|
60
|
+
droppedSemantic: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether the conversion yields a rule with something to match. A search of only
|
|
65
|
+
* dropped facets or a bare folder scope converts to no clauses, so there is no
|
|
66
|
+
* filter to open — the entry point offers nothing rather than an empty editor.
|
|
67
|
+
*/
|
|
68
|
+
export const isConvertible = (conversion: SearchConversion): boolean =>
|
|
69
|
+
conversion.clauses.length > 0;
|
|
70
|
+
|
|
71
|
+
interface BuildRuleOptions {
|
|
72
|
+
scope?: RuleScope;
|
|
73
|
+
moveMailboxId?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The rule the editor opens on, from a conversion. A search-derived rule defaults
|
|
78
|
+
* to a standing filter — "make this a filter" is a request to keep applying it —
|
|
79
|
+
* and the editor lets the user drop it back to a one-time apply. It carries no
|
|
80
|
+
* widen: a free-text query has no message anchor, so the semantic chip is not
|
|
81
|
+
* offered on this surface (its loss is stated in the conversion notice instead).
|
|
82
|
+
*/
|
|
83
|
+
export const buildSearchRule = (
|
|
84
|
+
conversion: SearchConversion,
|
|
85
|
+
{ scope = "standing", moveMailboxId }: BuildRuleOptions = {},
|
|
86
|
+
): FilterRule => ({
|
|
87
|
+
clauses: conversion.clauses.map((clause, index) => ({
|
|
88
|
+
id: `search-${index}`,
|
|
89
|
+
field: clause.field,
|
|
90
|
+
value: clause.value,
|
|
91
|
+
})),
|
|
92
|
+
matchOperator: conversion.matchOperator,
|
|
93
|
+
moveMailboxId,
|
|
94
|
+
scope,
|
|
95
|
+
name: "",
|
|
96
|
+
});
|