@remit/web-client 0.0.62 → 0.0.64
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 +1 -1
- package/src/components/mail/MessageList.tsx +15 -0
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +1 -0
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +14 -6
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +1 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +15 -10
- package/src/components/mail/organize/OrganizePanel.progress.test.ts +113 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +32 -2
- package/src/components/mail/organize/OrganizePanel.tsx +58 -20
- package/src/components/mail/organize/smart-organize.stories.tsx +94 -5
- package/src/components/self-update/SelfUpdateOverlay.tsx +45 -0
- package/src/components/settings/AdvancedNavIcon.tsx +23 -0
- package/src/components/settings/SelfUpdatePanel.tsx +47 -0
- package/src/hooks/use-system-update.test.ts +342 -0
- package/src/hooks/use-system-update.ts +215 -0
- package/src/hooks/useOrganizeWiden.test.ts +151 -0
- package/src/hooks/useOrganizeWiden.ts +104 -0
- package/src/lib/organize/organize-copy.test.ts +41 -0
- package/src/lib/organize/organize-copy.ts +29 -0
- package/src/lib/organize/sender-fallback.test.ts +56 -0
- package/src/lib/organize/sender-fallback.ts +64 -0
- package/src/lib/self-update-state.test.ts +517 -0
- package/src/lib/self-update-state.ts +564 -0
- package/src/routes/__root.tsx +13 -8
- package/src/routes/settings/advanced.tsx +7 -4
- package/src/routes/settings.tsx +3 -9
- package/src/hooks/useOrganizePreview.ts +0 -43
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widen step's two-phase preview. On a semantic-capable deployment it runs
|
|
3
|
+
* the anchor preview once and stops. On a deployment that reports
|
|
4
|
+
* `semanticUnavailable` (no vector pipeline — semantic-capability.ts) it
|
|
5
|
+
* re-previews with sender-derived literal clauses — one `From` clause per
|
|
6
|
+
* distinct sender, combined with `Or`, no anchor — and reports the literal match
|
|
7
|
+
* count, so every commit scope acts on the sender-matched set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import { createElement, useEffect } from "react";
|
|
13
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
14
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
15
|
+
import { useOrganizeWiden } from "./useOrganizeWiden";
|
|
16
|
+
|
|
17
|
+
let harness: DomHarness | undefined;
|
|
18
|
+
let http: HttpMock | undefined;
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
harness?.close();
|
|
22
|
+
harness = undefined;
|
|
23
|
+
http?.restore();
|
|
24
|
+
http = undefined;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
type Widen = ReturnType<typeof useOrganizeWiden>;
|
|
28
|
+
type Responder = Parameters<typeof mockFetch>[0];
|
|
29
|
+
|
|
30
|
+
const PREVIEW_PATH = "/organize/preview";
|
|
31
|
+
|
|
32
|
+
const mount = (senders: string[], responder: Responder) => {
|
|
33
|
+
http = mockFetch(responder);
|
|
34
|
+
harness = createDomHarness();
|
|
35
|
+
const holder: { current: Widen | undefined } = { current: undefined };
|
|
36
|
+
function Probe() {
|
|
37
|
+
const widen = useOrganizeWiden("acc-1", "msg-1", senders);
|
|
38
|
+
holder.current = widen;
|
|
39
|
+
const { preview } = widen;
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
preview();
|
|
42
|
+
}, [preview]);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
harness.renderApp(createElement(Probe));
|
|
46
|
+
return holder;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
async function settle(minCalls: number): Promise<void> {
|
|
50
|
+
const dom = harness;
|
|
51
|
+
const mock = http;
|
|
52
|
+
if (!dom || !mock) throw new Error("not mounted");
|
|
53
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
54
|
+
await dom.flush();
|
|
55
|
+
if (mock.to(PREVIEW_PATH).length >= minCalls) {
|
|
56
|
+
await dom.flush();
|
|
57
|
+
await dom.wait(1);
|
|
58
|
+
await dom.flush();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await dom.wait(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("useOrganizeWiden — semantic-capable deployment", () => {
|
|
66
|
+
it("previews the anchor once and reports the semantic match, no fallback", async () => {
|
|
67
|
+
const holder = mount([], () => ({
|
|
68
|
+
matchedCount: 42,
|
|
69
|
+
messageIds: ["a", "b"],
|
|
70
|
+
semanticUnavailable: false,
|
|
71
|
+
}));
|
|
72
|
+
await settle(1);
|
|
73
|
+
|
|
74
|
+
assert.equal(http?.to(PREVIEW_PATH).length, 1);
|
|
75
|
+
const [call] = http?.to(PREVIEW_PATH) ?? [];
|
|
76
|
+
assert.equal(call.body?.anchorMessageId, "msg-1");
|
|
77
|
+
assert.deepEqual(call.body?.literalClauses, []);
|
|
78
|
+
|
|
79
|
+
const widen = holder.current;
|
|
80
|
+
assert.equal(widen?.matchedCount, 42);
|
|
81
|
+
assert.equal(widen?.semanticUnavailable, false);
|
|
82
|
+
assert.equal(widen?.senderFallback, false);
|
|
83
|
+
assert.deepEqual(widen?.matchPredicate, {
|
|
84
|
+
anchorMessageId: "msg-1",
|
|
85
|
+
matchOperator: "And",
|
|
86
|
+
literalClauses: [],
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("useOrganizeWiden — no vector pipeline, senders present", () => {
|
|
92
|
+
it("re-previews with distinct sender From clauses combined with Or and no anchor", async () => {
|
|
93
|
+
const holder = mount(
|
|
94
|
+
["npm@github.com", "npm@github.com", "a@x.com"],
|
|
95
|
+
(call) =>
|
|
96
|
+
call.body?.anchorMessageId
|
|
97
|
+
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
98
|
+
: {
|
|
99
|
+
matchedCount: 128,
|
|
100
|
+
messageIds: ["m"],
|
|
101
|
+
semanticUnavailable: false,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
await settle(2);
|
|
105
|
+
|
|
106
|
+
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
107
|
+
assert.equal(calls.length, 2);
|
|
108
|
+
// The second preview is the literal fallback: no anchor, Or, distinct
|
|
109
|
+
// sender From clauses.
|
|
110
|
+
const second = calls[1];
|
|
111
|
+
assert.equal(second.body?.anchorMessageId, undefined);
|
|
112
|
+
assert.equal(second.body?.matchOperator, "Or");
|
|
113
|
+
assert.deepEqual(second.body?.literalClauses, [
|
|
114
|
+
{ field: "From", value: "npm@github.com" },
|
|
115
|
+
{ field: "From", value: "a@x.com" },
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const widen = holder.current;
|
|
119
|
+
assert.equal(widen?.matchedCount, 128);
|
|
120
|
+
assert.equal(widen?.semanticUnavailable, true);
|
|
121
|
+
assert.equal(widen?.senderFallback, true);
|
|
122
|
+
assert.deepEqual(widen?.senders, ["npm@github.com", "a@x.com"]);
|
|
123
|
+
assert.deepEqual(widen?.matchPredicate, {
|
|
124
|
+
matchOperator: "Or",
|
|
125
|
+
literalClauses: [
|
|
126
|
+
{ field: "From", value: "npm@github.com" },
|
|
127
|
+
{ field: "From", value: "a@x.com" },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("useOrganizeWiden — no vector pipeline, no senders", () => {
|
|
134
|
+
it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
|
|
135
|
+
const holder = mount([], () => ({
|
|
136
|
+
matchedCount: 0,
|
|
137
|
+
messageIds: [],
|
|
138
|
+
semanticUnavailable: true,
|
|
139
|
+
}));
|
|
140
|
+
await settle(1);
|
|
141
|
+
// Give any spurious second preview a chance to fire before asserting none did.
|
|
142
|
+
await harness?.flush();
|
|
143
|
+
|
|
144
|
+
assert.equal(http?.to(PREVIEW_PATH).length, 1);
|
|
145
|
+
const widen = holder.current;
|
|
146
|
+
assert.equal(widen?.semanticUnavailable, true);
|
|
147
|
+
assert.equal(widen?.senderFallback, false);
|
|
148
|
+
assert.deepEqual(widen?.senders, []);
|
|
149
|
+
assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
5
|
+
import {
|
|
6
|
+
buildSenderFallbackDraft,
|
|
7
|
+
deriveSenderClauses,
|
|
8
|
+
distinctSenders,
|
|
9
|
+
type OrganizeMatchPredicate,
|
|
10
|
+
} from "@/lib/organize/sender-fallback";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "Select similar messages" (the widen step) with a sender-derived fallback for
|
|
14
|
+
* deployments without the vector pipeline.
|
|
15
|
+
*
|
|
16
|
+
* The first preview runs the semantic anchor (POST /organize/preview). When the
|
|
17
|
+
* server reports `semanticUnavailable` — no vector pipeline on this deployment
|
|
18
|
+
* (semantic-capability.ts) — the widen re-previews with the literal vocabulary
|
|
19
|
+
* that matches vector-free: one `From` clause per distinct sender in the
|
|
20
|
+
* selection, combined with `Or`, no anchor. The counts shown, the one-time
|
|
21
|
+
* back-apply, and any standing filter then all carry that same literal
|
|
22
|
+
* predicate, so the previewed set equals the set every commit scope acts on.
|
|
23
|
+
*
|
|
24
|
+
* A semantic-capable deployment never fires the second preview and keeps exactly
|
|
25
|
+
* the anchor behaviour.
|
|
26
|
+
*/
|
|
27
|
+
export const useOrganizeWiden = (
|
|
28
|
+
accountId: string | undefined,
|
|
29
|
+
anchorMessageId: string | undefined,
|
|
30
|
+
senders: readonly string[],
|
|
31
|
+
) => {
|
|
32
|
+
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
33
|
+
const { mutate, reset: mutationReset } = mutation;
|
|
34
|
+
const [fellBack, setFellBack] = useState(false);
|
|
35
|
+
|
|
36
|
+
const distinct = useMemo(() => distinctSenders(senders), [senders]);
|
|
37
|
+
|
|
38
|
+
const preview = useCallback(() => {
|
|
39
|
+
if (!accountId || !anchorMessageId) return;
|
|
40
|
+
setFellBack(false);
|
|
41
|
+
mutate({
|
|
42
|
+
path: { accountId },
|
|
43
|
+
body: buildOrganizeInput({
|
|
44
|
+
anchorMessageId,
|
|
45
|
+
matchOperator: "And",
|
|
46
|
+
literalClauses: [],
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
}, [accountId, anchorMessageId, mutate]);
|
|
50
|
+
|
|
51
|
+
const reset = useCallback(() => {
|
|
52
|
+
setFellBack(false);
|
|
53
|
+
mutationReset();
|
|
54
|
+
}, [mutationReset]);
|
|
55
|
+
|
|
56
|
+
const data = mutation.data;
|
|
57
|
+
// The first preview came back capability-absent and there are senders to
|
|
58
|
+
// match on: re-preview with the literal clauses before showing any count, so
|
|
59
|
+
// the count the user sees is the sender-match count, never a flash of the
|
|
60
|
+
// empty semantic result.
|
|
61
|
+
const willFallBack =
|
|
62
|
+
data?.semanticUnavailable === true && !fellBack && distinct.length > 0;
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!willFallBack || !accountId) return;
|
|
66
|
+
setFellBack(true);
|
|
67
|
+
mutate({
|
|
68
|
+
path: { accountId },
|
|
69
|
+
body: buildOrganizeInput(buildSenderFallbackDraft(distinct)),
|
|
70
|
+
});
|
|
71
|
+
}, [willFallBack, accountId, distinct, mutate]);
|
|
72
|
+
|
|
73
|
+
// The literal re-preview has no anchor, so its response reports
|
|
74
|
+
// `semanticUnavailable: false`; `fellBack` is what remembers the capability
|
|
75
|
+
// was absent once the fallback has run.
|
|
76
|
+
const capabilityAbsent = fellBack || (data?.semanticUnavailable ?? false);
|
|
77
|
+
const isPending = mutation.isPending || willFallBack;
|
|
78
|
+
|
|
79
|
+
const matchPredicate: OrganizeMatchPredicate = fellBack
|
|
80
|
+
? { matchOperator: "Or", literalClauses: deriveSenderClauses(distinct) }
|
|
81
|
+
: {
|
|
82
|
+
...(anchorMessageId ? { anchorMessageId } : {}),
|
|
83
|
+
matchOperator: "And",
|
|
84
|
+
literalClauses: [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
preview,
|
|
89
|
+
reset,
|
|
90
|
+
matchedCount: isPending ? undefined : data?.matchedCount,
|
|
91
|
+
messageIds: isPending ? undefined : data?.messageIds,
|
|
92
|
+
// The deployment ships no vector pipeline — the widen matched on senders
|
|
93
|
+
// (or, with no senders, on nothing) rather than semantic similarity.
|
|
94
|
+
semanticUnavailable: capabilityAbsent,
|
|
95
|
+
// The widen fell back to sender matching and produced a usable literal
|
|
96
|
+
// match set: the commit scopes reach it, and the copy names the senders.
|
|
97
|
+
senderFallback: fellBack,
|
|
98
|
+
senders: distinct,
|
|
99
|
+
matchPredicate,
|
|
100
|
+
isPending,
|
|
101
|
+
isError: mutation.isError,
|
|
102
|
+
error: mutation.error,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
@@ -3,7 +3,9 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
commitButtonLabel,
|
|
5
5
|
commitDisabledReason,
|
|
6
|
+
formatSenderList,
|
|
6
7
|
scopeActionCount,
|
|
8
|
+
senderFallbackSummary,
|
|
7
9
|
} from "./organize-copy";
|
|
8
10
|
import type { OrganizeDraft } from "./organize-model";
|
|
9
11
|
|
|
@@ -91,3 +93,42 @@ describe("scopeActionCount", () => {
|
|
|
91
93
|
assert.equal(scopeActionCount("standing", 3, 48), 48);
|
|
92
94
|
});
|
|
93
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("formatSenderList", () => {
|
|
98
|
+
it("names a single sender bare", () => {
|
|
99
|
+
assert.equal(formatSenderList(["npm@github.com"]), "npm@github.com");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("joins a couple with 'and'", () => {
|
|
103
|
+
assert.equal(
|
|
104
|
+
formatSenderList(["a@x.com", "b@y.com"]),
|
|
105
|
+
"a@x.com and b@y.com",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("lists up to three in full", () => {
|
|
110
|
+
assert.equal(
|
|
111
|
+
formatSenderList(["a@x.com", "b@y.com", "c@z.com"]),
|
|
112
|
+
"a@x.com, b@y.com and c@z.com",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("truncates past three, summing the rest", () => {
|
|
117
|
+
assert.equal(
|
|
118
|
+
formatSenderList(["a@x.com", "b@y.com", "c@z.com", "d@w.com"]),
|
|
119
|
+
"a@x.com, b@y.com, c@z.com and 1 other",
|
|
120
|
+
);
|
|
121
|
+
assert.equal(
|
|
122
|
+
formatSenderList(["a@x.com", "b@y.com", "c@z.com", "d@w.com", "e@v.com"]),
|
|
123
|
+
"a@x.com, b@y.com, c@z.com and 2 others",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("senderFallbackSummary", () => {
|
|
129
|
+
it("states it is matching all mail from the senders, never 'similar'", () => {
|
|
130
|
+
const summary = senderFallbackSummary(["npm@github.com"]);
|
|
131
|
+
assert.match(summary, /isn't available on this server/);
|
|
132
|
+
assert.match(summary, /matching all mail from npm@github\.com instead/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -59,3 +59,32 @@ export const scopeActionCount = (
|
|
|
59
59
|
selectionCount: number,
|
|
60
60
|
matchedCount: number,
|
|
61
61
|
): number => (scope === "just-these" ? selectionCount : matchedCount);
|
|
62
|
+
|
|
63
|
+
/** How many sender addresses are named before the rest are summed as "N others". */
|
|
64
|
+
const MAX_SENDERS_SHOWN = 3;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The sender addresses read out in prose: all of them when there are a few, or
|
|
68
|
+
* the first {@link MAX_SENDERS_SHOWN} and a count of the rest so a long selection
|
|
69
|
+
* stays legible in the sheet.
|
|
70
|
+
*/
|
|
71
|
+
export const formatSenderList = (senders: readonly string[]): string => {
|
|
72
|
+
if (senders.length === 0) return "these senders";
|
|
73
|
+
if (senders.length === 1) return senders[0];
|
|
74
|
+
if (senders.length <= MAX_SENDERS_SHOWN) {
|
|
75
|
+
return `${senders.slice(0, -1).join(", ")} and ${senders[senders.length - 1]}`;
|
|
76
|
+
}
|
|
77
|
+
const shown = senders.slice(0, MAX_SENDERS_SHOWN);
|
|
78
|
+
const rest = senders.length - MAX_SENDERS_SHOWN;
|
|
79
|
+
return `${shown.join(", ")} and ${rest} other${rest === 1 ? "" : "s"}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The heading when the widen fell back to sender matching (no vector pipeline on
|
|
84
|
+
* this server). States the actual semantics — matching every mail from these
|
|
85
|
+
* senders — and never claims semantic similarity.
|
|
86
|
+
*/
|
|
87
|
+
export const senderFallbackSummary = (senders: readonly string[]): string =>
|
|
88
|
+
`Similar-mail matching isn't available on this server — matching all mail from ${formatSenderList(
|
|
89
|
+
senders,
|
|
90
|
+
)} instead.`;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildSenderFallbackDraft,
|
|
5
|
+
deriveSenderClauses,
|
|
6
|
+
distinctSenders,
|
|
7
|
+
} from "./sender-fallback";
|
|
8
|
+
|
|
9
|
+
describe("distinctSenders", () => {
|
|
10
|
+
it("drops empties and blanks, trimming what remains", () => {
|
|
11
|
+
assert.deepEqual(
|
|
12
|
+
distinctSenders([" npm@github.com ", "", " ", "a@x.com"]),
|
|
13
|
+
["npm@github.com", "a@x.com"],
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("de-duplicates case-insensitively, keeping first-seen casing and order", () => {
|
|
18
|
+
assert.deepEqual(
|
|
19
|
+
distinctSenders([
|
|
20
|
+
"NPM@github.com",
|
|
21
|
+
"a@x.com",
|
|
22
|
+
"npm@GITHUB.com",
|
|
23
|
+
"a@x.com",
|
|
24
|
+
]),
|
|
25
|
+
["NPM@github.com", "a@x.com"],
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("deriveSenderClauses", () => {
|
|
31
|
+
it("emits one From clause per distinct sender", () => {
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
|
|
34
|
+
[
|
|
35
|
+
{ field: "From", value: "npm@github.com" },
|
|
36
|
+
{ field: "From", value: "a@x.com" },
|
|
37
|
+
],
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("is empty when no sender survives", () => {
|
|
42
|
+
assert.deepEqual(deriveSenderClauses(["", " "]), []);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("buildSenderFallbackDraft", () => {
|
|
47
|
+
it("combines the sender clauses with Or and carries no anchor", () => {
|
|
48
|
+
const draft = buildSenderFallbackDraft(["npm@github.com", "a@x.com"]);
|
|
49
|
+
assert.equal(draft.matchOperator, "Or");
|
|
50
|
+
assert.equal(draft.anchorMessageId, undefined);
|
|
51
|
+
assert.deepEqual(draft.literalClauses, [
|
|
52
|
+
{ field: "From", value: "npm@github.com" },
|
|
53
|
+
{ field: "From", value: "a@x.com" },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapFilterClause,
|
|
3
|
+
RemitImapOrganizeInput,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import type { OrganizeDraft } from "./organize-model";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The widen fallback for a deployment that ships no vector pipeline (self-host
|
|
9
|
+
* sqlite — semantic-capability.ts). The semantic anchor matches nothing there,
|
|
10
|
+
* so a widen degrades to the literal vocabulary RFC 031 already matches
|
|
11
|
+
* vector-free: one `From` clause per distinct sender in the selection, combined
|
|
12
|
+
* with `Or`, no anchor. The same predicate matches at index time (RFC 034), so a
|
|
13
|
+
* standing filter built from it keeps working on future mail.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Distinct sender addresses from the selection, trimmed, empties dropped, and
|
|
18
|
+
* de-duplicated case-insensitively while preserving first-seen casing and order.
|
|
19
|
+
*/
|
|
20
|
+
export const distinctSenders = (senders: readonly string[]): string[] => {
|
|
21
|
+
const seen = new Set<string>();
|
|
22
|
+
const out: string[] = [];
|
|
23
|
+
for (const raw of senders) {
|
|
24
|
+
const value = raw.trim();
|
|
25
|
+
if (value === "") continue;
|
|
26
|
+
const key = value.toLowerCase();
|
|
27
|
+
if (seen.has(key)) continue;
|
|
28
|
+
seen.add(key);
|
|
29
|
+
out.push(value);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One `From` literal clause per distinct sender address. A `From` clause matches
|
|
36
|
+
* the sender address or display name (match.ts `clauseMatches`), so the address
|
|
37
|
+
* is the precise, stable key.
|
|
38
|
+
*/
|
|
39
|
+
export const deriveSenderClauses = (
|
|
40
|
+
senders: readonly string[],
|
|
41
|
+
): RemitImapFilterClause[] =>
|
|
42
|
+
distinctSenders(senders).map((value) => ({ field: "From", value }));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The literal predicate that stands in for the semantic anchor: the sender `From`
|
|
46
|
+
* clauses combined with `Or` and no anchor. The preview, the one-time back-apply,
|
|
47
|
+
* and the standing filter all carry exactly this.
|
|
48
|
+
*/
|
|
49
|
+
export const buildSenderFallbackDraft = (
|
|
50
|
+
senders: readonly string[],
|
|
51
|
+
): OrganizeDraft => ({
|
|
52
|
+
matchOperator: "Or",
|
|
53
|
+
literalClauses: deriveSenderClauses(senders),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The predicate the widen previewed, handed to the organize sentence so the set
|
|
58
|
+
* it previews equals the set every commit scope acts on. Either the semantic
|
|
59
|
+
* anchor or the sender-derived literal fallback, never both.
|
|
60
|
+
*/
|
|
61
|
+
export type OrganizeMatchPredicate = Pick<
|
|
62
|
+
RemitImapOrganizeInput,
|
|
63
|
+
"anchorMessageId" | "matchOperator" | "literalClauses"
|
|
64
|
+
>;
|