@remit/web-client 0.0.139 → 0.0.140
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.140",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* the caret and the sheet, the same shape `MailViewChrome` gives the mailbox and
|
|
14
14
|
* Starred views. The category and the chips narrow the grouped sections
|
|
15
15
|
* themselves, and the phone search takeover reads the same selection, so a
|
|
16
|
-
* filter set on one surface holds on the other.
|
|
16
|
+
* filter set on one surface holds on the other. Under a query the chips are
|
|
17
|
+
* terms of that query — see `briefChipFilters` — so what narrows the list is
|
|
18
|
+
* readable and editable in the search field.
|
|
17
19
|
*
|
|
18
20
|
* Multi-select is the mailbox list's, not a copy of it: the same
|
|
19
21
|
* `ThreadListInteraction` cursor and `useSelection` state, and the same
|
|
@@ -39,7 +41,11 @@ import {
|
|
|
39
41
|
BriefEmpty,
|
|
40
42
|
type BriefFilterId,
|
|
41
43
|
BriefSections,
|
|
44
|
+
briefChipCategory,
|
|
45
|
+
briefChipFilters,
|
|
42
46
|
briefFilterConfig,
|
|
47
|
+
briefFilterHasTerm,
|
|
48
|
+
clearBriefFiltersInQuery,
|
|
43
49
|
FilterPanelProvider,
|
|
44
50
|
type FilterSheetProps,
|
|
45
51
|
type FilterSheetSource,
|
|
@@ -50,8 +56,10 @@ import {
|
|
|
50
56
|
type SearchResult,
|
|
51
57
|
SelectionTopBar,
|
|
52
58
|
SpamResultsOffer,
|
|
59
|
+
setBriefCategoryInQuery,
|
|
53
60
|
type ThreadRowData,
|
|
54
61
|
type ThreadSection,
|
|
62
|
+
toggleBriefFilterInQuery,
|
|
55
63
|
} from "@remit/ui";
|
|
56
64
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
57
65
|
import { useNavigate } from "@tanstack/react-router";
|
|
@@ -69,6 +77,7 @@ import {
|
|
|
69
77
|
useInitialSyncProgress,
|
|
70
78
|
} from "@/hooks/useInitialSyncProgress";
|
|
71
79
|
import { useLabelList } from "@/hooks/useLabels";
|
|
80
|
+
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
72
81
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
73
82
|
import { useRefreshControl } from "@/hooks/useRefreshControl";
|
|
74
83
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
@@ -89,6 +98,7 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
|
|
|
89
98
|
import { useMailContext } from "@/lib/mail-context";
|
|
90
99
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
91
100
|
import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
|
|
101
|
+
import { showInlineSearchResults } from "@/lib/search-surface";
|
|
92
102
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
93
103
|
import { spamOfferForResults } from "@/lib/spam-offer";
|
|
94
104
|
import {
|
|
@@ -394,9 +404,11 @@ export function DailyBrief({
|
|
|
394
404
|
onTriageContextChange,
|
|
395
405
|
onDeleteMessages,
|
|
396
406
|
}: DailyBriefProps) {
|
|
397
|
-
const { searchQuery, searchInput, resultFolderIndex } =
|
|
407
|
+
const { searchQuery, searchInput, resultFolderIndex, onSearchChange } =
|
|
408
|
+
useMailContext();
|
|
398
409
|
const tokenContext = useSearchTokenContext();
|
|
399
410
|
const isDesktop = useIsDesktop();
|
|
411
|
+
const tier = useLayoutTier();
|
|
400
412
|
const wizard = useSelectionWizard();
|
|
401
413
|
const navigate = useNavigate();
|
|
402
414
|
|
|
@@ -430,14 +442,54 @@ export function DailyBrief({
|
|
|
430
442
|
}, []);
|
|
431
443
|
const clearFilters = useCallback(() => setActiveFilters(new Set()), []);
|
|
432
444
|
|
|
433
|
-
// A query owns the pane: the filter panel and the search's own affordance
|
|
434
|
-
// narrow the same list from the same place, so the panel stands down for as
|
|
435
|
-
// long as something is being searched. Its state survives, so clearing the
|
|
436
|
-
// query brings it back with the same category and chips. The header caret is
|
|
437
|
-
// gone under a query, and a panel left open with nothing to collapse it is
|
|
438
|
-
// what makes this load-bearing rather than tidy.
|
|
439
445
|
const searching = searchInput.trim().length > 0;
|
|
440
446
|
|
|
447
|
+
// Under a query the chips and the query are one state: a chip writes its term
|
|
448
|
+
// into the query — `is:unread`, `has:attachment`, `category:newsletter` — and
|
|
449
|
+
// a term typed by hand ticks its chip. What narrows the rows is then legible
|
|
450
|
+
// in the field, editable there, and gone when the term is deleted. The
|
|
451
|
+
// panel's own set survives a search and comes back with it, and carries the
|
|
452
|
+
// two chips the vocabulary cannot spell (see `briefChipFilters`).
|
|
453
|
+
const chipFilters = useMemo(
|
|
454
|
+
() => briefChipFilters({ query: searchInput, ownFilters: activeFilters }),
|
|
455
|
+
[searchInput, activeFilters],
|
|
456
|
+
);
|
|
457
|
+
const chipCategory = useMemo(
|
|
458
|
+
() =>
|
|
459
|
+
briefChipCategory({ query: searchInput, ownCategory: selectedCategory }),
|
|
460
|
+
[searchInput, selectedCategory],
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const toggleChip = useCallback(
|
|
464
|
+
(id: BriefFilterId) => {
|
|
465
|
+
if (!searching || !briefFilterHasTerm(id)) {
|
|
466
|
+
toggleFilter(id);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const next = toggleBriefFilterInQuery(searchInput, id);
|
|
470
|
+
if (next !== undefined) onSearchChange(next);
|
|
471
|
+
},
|
|
472
|
+
[searching, searchInput, onSearchChange, toggleFilter],
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
const selectChipCategory = useCallback(
|
|
476
|
+
(category: BriefCategoryFilter) => {
|
|
477
|
+
if (!searching) {
|
|
478
|
+
setSelectedCategory(category);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
onSearchChange(setBriefCategoryInQuery(searchInput, category));
|
|
482
|
+
},
|
|
483
|
+
[searching, searchInput, onSearchChange],
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const clearChips = useCallback(() => {
|
|
487
|
+
setSelectedCategory("all");
|
|
488
|
+
setSelectedAccountId("all");
|
|
489
|
+
clearFilters();
|
|
490
|
+
if (searching) onSearchChange(clearBriefFiltersInQuery(searchInput));
|
|
491
|
+
}, [searching, searchInput, onSearchChange, clearFilters]);
|
|
492
|
+
|
|
441
493
|
// --- Unified threads query ---
|
|
442
494
|
const {
|
|
443
495
|
data: threadsData,
|
|
@@ -647,11 +699,11 @@ export function DailyBrief({
|
|
|
647
699
|
filteredRows
|
|
648
700
|
.filter(
|
|
649
701
|
(t) =>
|
|
650
|
-
(
|
|
651
|
-
matchesBriefFilters(t,
|
|
702
|
+
(chipCategory === "all" || t.category === chipCategory) &&
|
|
703
|
+
matchesBriefFilters(t, chipFilters),
|
|
652
704
|
)
|
|
653
705
|
.map((row) => rowToSearchResult(row, resultFolderIndex)),
|
|
654
|
-
[filteredRows,
|
|
706
|
+
[filteredRows, chipCategory, chipFilters, resultFolderIndex],
|
|
655
707
|
);
|
|
656
708
|
|
|
657
709
|
// "Related" (semantic) spans every account here — the brief is the
|
|
@@ -688,28 +740,25 @@ export function DailyBrief({
|
|
|
688
740
|
filters: preset.filters,
|
|
689
741
|
sources: preset.sources,
|
|
690
742
|
sourcesNote: mutedCount > 0 ? `+${mutedCount} muted` : undefined,
|
|
691
|
-
selectedCategory,
|
|
692
|
-
activeFilters,
|
|
743
|
+
selectedCategory: chipCategory,
|
|
744
|
+
activeFilters: chipFilters,
|
|
693
745
|
expanded: filterExpanded,
|
|
694
746
|
onExpandedChange: setFilterExpanded,
|
|
695
747
|
onSelectCategory: (id: string) =>
|
|
696
|
-
|
|
748
|
+
selectChipCategory(id as BriefCategoryFilter),
|
|
697
749
|
onSelectSource: setSelectedAccountId,
|
|
698
|
-
onToggleFilter: (id: string) =>
|
|
699
|
-
onClear:
|
|
700
|
-
setSelectedCategory("all");
|
|
701
|
-
setSelectedAccountId("all");
|
|
702
|
-
clearFilters();
|
|
703
|
-
},
|
|
750
|
+
onToggleFilter: (id: string) => toggleChip(id as BriefFilterId),
|
|
751
|
+
onClear: clearChips,
|
|
704
752
|
};
|
|
705
753
|
}, [
|
|
706
754
|
accountSources,
|
|
707
755
|
mutedCount,
|
|
708
|
-
|
|
709
|
-
|
|
756
|
+
chipCategory,
|
|
757
|
+
chipFilters,
|
|
710
758
|
filterExpanded,
|
|
711
|
-
|
|
712
|
-
|
|
759
|
+
toggleChip,
|
|
760
|
+
selectChipCategory,
|
|
761
|
+
clearChips,
|
|
713
762
|
]);
|
|
714
763
|
|
|
715
764
|
// The brief is genuinely empty (caught up) only when nothing is narrowing the
|
|
@@ -753,6 +802,18 @@ export function DailyBrief({
|
|
|
753
802
|
// rather than opening nothing over a skeleton or an empty state.
|
|
754
803
|
const showsRows = !isLoading && !isError && !caughtUp;
|
|
755
804
|
|
|
805
|
+
// A search does not take the panel down — the chips compose into the query
|
|
806
|
+
// rather than competing with it. The one window where the brief's own body is
|
|
807
|
+
// not on screen is the two-engine results panel, which owns the pane while a
|
|
808
|
+
// first query is still being typed; the caret stands down for exactly that,
|
|
809
|
+
// on the same answer the header swaps the body on.
|
|
810
|
+
const resultsPanelOwnsBody = showInlineSearchResults({
|
|
811
|
+
tier,
|
|
812
|
+
hasLiveInput: searching,
|
|
813
|
+
hasCommittedQuery: searchQuery.trim().length > 0,
|
|
814
|
+
bodyRendersCommittedResults: true,
|
|
815
|
+
});
|
|
816
|
+
|
|
756
817
|
const stateBody = showsRows ? (
|
|
757
818
|
<div className="flex h-full min-h-0 flex-col">
|
|
758
819
|
{briefSpamOffer && (
|
|
@@ -774,18 +835,17 @@ export function DailyBrief({
|
|
|
774
835
|
<div className="min-h-0 flex-1">
|
|
775
836
|
<BriefSections
|
|
776
837
|
sections={sections}
|
|
777
|
-
briefCategory={
|
|
838
|
+
briefCategory={chipCategory}
|
|
778
839
|
Row={MessageRow}
|
|
779
840
|
selectedThreadId={selectedMessageId}
|
|
780
841
|
onSelectThread={openRow}
|
|
781
|
-
onSelectBriefCategory={
|
|
842
|
+
onSelectBriefCategory={selectChipCategory}
|
|
782
843
|
sources={accountSources}
|
|
783
844
|
sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
|
|
784
845
|
onSelectSource={setSelectedAccountId}
|
|
785
|
-
activeFilters={
|
|
786
|
-
onToggleFilter={
|
|
787
|
-
onClearFilters={
|
|
788
|
-
hideChrome={searching}
|
|
846
|
+
activeFilters={chipFilters}
|
|
847
|
+
onToggleFilter={toggleChip}
|
|
848
|
+
onClearFilters={clearChips}
|
|
789
849
|
/>
|
|
790
850
|
</div>
|
|
791
851
|
</div>
|
|
@@ -822,7 +882,7 @@ export function DailyBrief({
|
|
|
822
882
|
// same pane for the same reason — the caret is in the header, the panel is
|
|
823
883
|
// above the rows.
|
|
824
884
|
return (
|
|
825
|
-
<FilterPanelProvider hasSheet={showsRows && !
|
|
885
|
+
<FilterPanelProvider hasSheet={showsRows && !resultsPanelOwnsBody}>
|
|
826
886
|
<ThreadListInteraction
|
|
827
887
|
selectedMessageId={selectedMessageId}
|
|
828
888
|
onOpen={openRow}
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
isConvertible,
|
|
46
46
|
MakeFilterAction,
|
|
47
47
|
MobileSearchView,
|
|
48
|
+
makeFilterBlockedCopy,
|
|
48
49
|
SearchBar,
|
|
49
50
|
type SearchCaretRequest,
|
|
50
51
|
type SearchFieldSuggest,
|
|
@@ -368,7 +369,9 @@ export function MailListHeader({
|
|
|
368
369
|
},
|
|
369
370
|
blockedReason: isConvertible(conversion)
|
|
370
371
|
? undefined
|
|
371
|
-
:
|
|
372
|
+
: makeFilterBlockedCopy(
|
|
373
|
+
conversion.droppedFacets.map((facet) => facet.label),
|
|
374
|
+
),
|
|
372
375
|
}
|
|
373
376
|
: undefined;
|
|
374
377
|
// Handed to the bar rather than rendered here: the bar knows whether rows
|
|
@@ -484,6 +487,7 @@ export function MailListHeader({
|
|
|
484
487
|
aria-label="Close search"
|
|
485
488
|
className="shrink-0"
|
|
486
489
|
/>
|
|
490
|
+
<FilterToggle />
|
|
487
491
|
</>
|
|
488
492
|
),
|
|
489
493
|
}),
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { ThreadRowData } from "@remit/ui";
|
|
4
|
+
import {
|
|
5
|
+
briefQueryCategory,
|
|
6
|
+
briefQueryFilters,
|
|
7
|
+
setBriefCategoryInQuery,
|
|
8
|
+
toggleBriefFilterInQuery,
|
|
9
|
+
} from "@remit/ui";
|
|
10
|
+
import { matchesSearchTokens } from "./brief.js";
|
|
11
|
+
import { parseSearchTokens } from "./search-tokens.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The brief's chips and the token parser read one query, and this checks them
|
|
15
|
+
* against each other rather than against a second copy of either: a chip may
|
|
16
|
+
* only read ticked for a facet the parser actually applies, and a term a chip
|
|
17
|
+
* writes must narrow the rows the parser narrows.
|
|
18
|
+
*
|
|
19
|
+
* The spellings here are the ones a hand-typed query carries and a whitespace
|
|
20
|
+
* splitter got wrong.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const row = (overrides: Partial<ThreadRowData> = {}): ThreadRowData => ({
|
|
24
|
+
id: "m1",
|
|
25
|
+
accountId: "acc_1",
|
|
26
|
+
fromName: "Odido",
|
|
27
|
+
fromEmail: "info@odido.example",
|
|
28
|
+
subject: "Je factuur van juni",
|
|
29
|
+
snippet: "Snippet",
|
|
30
|
+
timeLabel: "09:00",
|
|
31
|
+
isRead: false,
|
|
32
|
+
hasAttachment: false,
|
|
33
|
+
starred: false,
|
|
34
|
+
category: "newsletter",
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const applies = (query: string, thread: ThreadRowData): boolean =>
|
|
39
|
+
matchesSearchTokens(thread, parseSearchTokens(query).tokens);
|
|
40
|
+
|
|
41
|
+
describe("the chips agree with the parser on what a query applies", () => {
|
|
42
|
+
it("leaves a facet spelled inside a quoted value to that value", () => {
|
|
43
|
+
const query = 'subject:"a is:unread b"';
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
parseSearchTokens(query).tokens.map((token) => token.type),
|
|
46
|
+
["subject"],
|
|
47
|
+
);
|
|
48
|
+
assert.equal(briefQueryFilters(query).has("unread"), false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ticks the chip for a facet whose own value is quoted", () => {
|
|
52
|
+
const query = 'is:"unread"';
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
parseSearchTokens(query).tokens.map((token) => token.type),
|
|
55
|
+
["isUnread"],
|
|
56
|
+
);
|
|
57
|
+
assert.equal(briefQueryFilters(query).has("unread"), true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("scopes to the category a quoted value names", () => {
|
|
61
|
+
const query = 'category:"Newsletter"';
|
|
62
|
+
assert.equal(briefQueryCategory(query), "newsletter");
|
|
63
|
+
assert.equal(applies(query, row()), true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("a term a chip writes narrows the rows", () => {
|
|
68
|
+
it("hides a read row once Unread is ticked", () => {
|
|
69
|
+
const query = toggleBriefFilterInQuery("Odido", "unread");
|
|
70
|
+
assert.ok(query);
|
|
71
|
+
assert.equal(applies(query, row({ isRead: false })), true);
|
|
72
|
+
assert.equal(applies(query, row({ isRead: true })), false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Facet tokens are ANDed, so a category pill that left the previous term in
|
|
76
|
+
// place matched nothing at all — an empty list one click from a full one.
|
|
77
|
+
it("keeps a category pill from emptying the list it was picked from", () => {
|
|
78
|
+
const query = setBriefCategoryInQuery('category:"Newsletter"', "marketing");
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
parseSearchTokens(query).tokens.map((token) => token.type),
|
|
81
|
+
["category"],
|
|
82
|
+
);
|
|
83
|
+
assert.equal(applies(query, row({ category: "marketing" })), true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import { isConvertible } from "@remit/ui";
|
|
3
|
+
import { isConvertible, makeFilterBlockedCopy } from "@remit/ui";
|
|
4
4
|
import { parseSearchTokens, type SearchTokenContext } from "../search-tokens";
|
|
5
5
|
import { convertSearchToRule, searchRuleAccountId } from "./search-to-rule";
|
|
6
6
|
|
|
@@ -123,6 +123,20 @@ describe("convertSearchToRule — nothing left to make a filter from", () => {
|
|
|
123
123
|
it("yields a clause once a term rides along with the folder scope", () => {
|
|
124
124
|
assert.equal(isConvertible(convert("in:archive receipts")), true);
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
// The brief's chips write their terms into the query, so a query can be made
|
|
128
|
+
// entirely of them. What is in the way is then the facets themselves, and the
|
|
129
|
+
// reason names them rather than asking for something that was just supplied.
|
|
130
|
+
it("names the facets a query composed only of chips is made of", () => {
|
|
131
|
+
const conversion = convert("is:unread category:newsletter");
|
|
132
|
+
assert.equal(isConvertible(conversion), false);
|
|
133
|
+
assert.equal(
|
|
134
|
+
makeFilterBlockedCopy(
|
|
135
|
+
conversion.droppedFacets.map((facet) => facet.label),
|
|
136
|
+
),
|
|
137
|
+
"Unread and Category: Newsletter aren't filter conditions — add a sender or words to filter on",
|
|
138
|
+
);
|
|
139
|
+
});
|
|
126
140
|
});
|
|
127
141
|
|
|
128
142
|
describe("searchRuleAccountId — whose account the filter belongs to", () => {
|
package/src/lib/search-tokens.ts
CHANGED
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
* that can't actually apply. `category:` resolves the same way against a fixed
|
|
30
30
|
* vocabulary, so `category:nonsense` stays free text too.
|
|
31
31
|
*
|
|
32
|
-
* Token names are case-insensitive.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* Token names are case-insensitive. Terms are cut from the query by
|
|
33
|
+
* `search-query-words.ts` in the kit, which the brief's chips also write and
|
|
34
|
+
* remove terms with, so a quoted value is one term to both.
|
|
35
35
|
*
|
|
36
36
|
* Pure functions only: no React, no fetch — the name index is injected, not
|
|
37
37
|
* fetched here. `MailListHeader` renders the chips and the callers that issue
|
|
@@ -42,6 +42,23 @@
|
|
|
42
42
|
|
|
43
43
|
import type { RemitImapMessageCategory } from "@remit/api-http-client/types.gen.ts";
|
|
44
44
|
import { MessageCategory } from "@remit/domain-enums";
|
|
45
|
+
import {
|
|
46
|
+
quoteSearchTokenValue,
|
|
47
|
+
type SearchQueryWord,
|
|
48
|
+
type SearchTermParts,
|
|
49
|
+
searchTokenTerm,
|
|
50
|
+
splitSearchTerm,
|
|
51
|
+
splitSearchWords,
|
|
52
|
+
} from "@remit/ui";
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
quoteSearchTokenValue,
|
|
56
|
+
type SearchQueryWord,
|
|
57
|
+
type SearchTermParts,
|
|
58
|
+
searchTokenTerm,
|
|
59
|
+
splitSearchTerm,
|
|
60
|
+
splitSearchWords,
|
|
61
|
+
};
|
|
45
62
|
|
|
46
63
|
export type SearchToken =
|
|
47
64
|
| { type: "from"; raw: string; value: string }
|
|
@@ -186,89 +203,6 @@ export const searchTokenSpec = (name: string): SearchTokenSpec | undefined =>
|
|
|
186
203
|
|
|
187
204
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
188
205
|
|
|
189
|
-
const needsQuotes = (value: string): boolean => /[\s"]/.test(value);
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* A value as it is written in a query: quoted when it carries whitespace, bare
|
|
193
|
-
* otherwise. The inverse of the unquoting the parser does, so a suggestion the
|
|
194
|
-
* user picks parses back to the value it was built from.
|
|
195
|
-
*/
|
|
196
|
-
export const quoteSearchTokenValue = (value: string): string =>
|
|
197
|
-
needsQuotes(value) ? `"${value.replace(/"/g, "")}"` : value;
|
|
198
|
-
|
|
199
|
-
/** `name:value`, quoted as needed — the text a query carries for one token. */
|
|
200
|
-
export const searchTokenTerm = (name: string, value: string): string =>
|
|
201
|
-
`${name}:${quoteSearchTokenValue(value)}`;
|
|
202
|
-
|
|
203
|
-
const unquote = (value: string): string => {
|
|
204
|
-
if (!value.startsWith('"')) return value;
|
|
205
|
-
const inner = value.slice(1);
|
|
206
|
-
return inner.endsWith('"') ? inner.slice(0, -1) : inner;
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
/** One whitespace-separated term of a query, with where it sits in the input. */
|
|
210
|
-
export interface SearchQueryWord {
|
|
211
|
-
/** The term exactly as typed, quotes included. */
|
|
212
|
-
raw: string;
|
|
213
|
-
/** Index of the term's first character in the query. */
|
|
214
|
-
start: number;
|
|
215
|
-
/** Index just past the term's last character. */
|
|
216
|
-
end: number;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Split a query into terms on whitespace, except inside double quotes, so
|
|
221
|
-
* `in:"Sent Items"` is one term. An unterminated quote runs to the end of the
|
|
222
|
-
* input — a half-typed value is still one term, not a broken one.
|
|
223
|
-
*/
|
|
224
|
-
export function splitSearchWords(query: string): SearchQueryWord[] {
|
|
225
|
-
const words: SearchQueryWord[] = [];
|
|
226
|
-
let start = -1;
|
|
227
|
-
let quoted = false;
|
|
228
|
-
for (let i = 0; i < query.length; i++) {
|
|
229
|
-
const char = query[i] as string;
|
|
230
|
-
if (char === '"') {
|
|
231
|
-
quoted = !quoted;
|
|
232
|
-
if (start < 0) start = i;
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
if (!quoted && /\s/.test(char)) {
|
|
236
|
-
if (start >= 0) words.push({ raw: query.slice(start, i), start, end: i });
|
|
237
|
-
start = -1;
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
if (start < 0) start = i;
|
|
241
|
-
}
|
|
242
|
-
if (start >= 0) {
|
|
243
|
-
words.push({ raw: query.slice(start), start, end: query.length });
|
|
244
|
-
}
|
|
245
|
-
return words;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** A term split at its first colon: the token name and the value as typed. */
|
|
249
|
-
export interface SearchTermParts {
|
|
250
|
-
name: string;
|
|
251
|
-
/** The value with its quotes removed. */
|
|
252
|
-
value: string;
|
|
253
|
-
/** The value exactly as typed, quotes included. */
|
|
254
|
-
rawValue: string;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Split `name:value` at the first colon. A term with no colon, or one starting
|
|
259
|
-
* with a colon, is not a token attempt and returns `undefined`.
|
|
260
|
-
*/
|
|
261
|
-
export function splitSearchTerm(word: string): SearchTermParts | undefined {
|
|
262
|
-
const colon = word.indexOf(":");
|
|
263
|
-
if (colon <= 0) return undefined;
|
|
264
|
-
const rawValue = word.slice(colon + 1);
|
|
265
|
-
return {
|
|
266
|
-
name: word.slice(0, colon).toLowerCase(),
|
|
267
|
-
value: unquote(rawValue),
|
|
268
|
-
rawValue,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
206
|
function parseDateToken(
|
|
273
207
|
type: "before" | "after",
|
|
274
208
|
raw: string,
|