@remit/web-client 0.0.229 → 0.0.231
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/calendar/CalendarComposePane.stories.tsx +25 -0
- package/src/components/calendar/CalendarComposePane.tsx +34 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +81 -0
- package/src/components/calendar/CalendarEventPane.tsx +67 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +197 -0
- package/src/components/calendar/CalendarWorkspace.tsx +162 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +39 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
- package/src/components/config-import/ConfigImportWizard.flow.test.ts +338 -0
- package/src/components/config-import/ConfigImportWizard.tsx +720 -0
- package/src/components/config-import/config-import.fixtures.ts +229 -0
- package/src/components/config-import/config-import.stories.tsx +449 -0
- package/src/components/config-import/steps.tsx +772 -0
- package/src/components/mail/MailSidebarAdapter.tsx +17 -0
- package/src/components/onboarding/OnboardingWizard.tsx +64 -390
- package/src/components/onboarding/credential-steps.tsx +441 -0
- package/src/components/settings/ConfigExportCard.tsx +114 -0
- package/src/components/settings/ConfigTransferPanel.tsx +72 -0
- package/src/hooks/useCalendarData.ts +72 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-fixtures.ts +207 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +183 -0
- package/src/lib/config-import.test.ts +306 -0
- package/src/lib/config-import.ts +492 -0
- package/src/lib/json.ts +11 -0
- package/src/lib/search-params.test.ts +77 -0
- package/src/lib/search-params.ts +59 -0
- package/src/routeTree.gen.ts +220 -0
- package/src/router.tsx +5 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
- package/src/routes/calendar/$view.$date/index.tsx +25 -0
- package/src/routes/calendar/$view.$date/new.tsx +20 -0
- package/src/routes/calendar/$view.$date.tsx +100 -0
- package/src/routes/calendar/-calendar-tree.test.ts +267 -0
- package/src/routes/calendar/index.tsx +27 -0
- package/src/routes/calendar.tsx +98 -0
- package/src/routes/import.tsx +51 -0
- package/src/routes/onboarding.tsx +9 -2
- package/src/routes/settings/accounts.tsx +30 -8
- package/src/routes/settings/advanced.tsx +5 -1
- package/src/routing/calendar-fragment.test.ts +66 -0
- package/src/routing/calendar.ts +139 -0
- package/src/routing/fragment.ts +19 -0
- package/src/routing/index.ts +10 -0
- package/src/routing/nav-selection.ts +2 -0
- package/src/routing/sections.ts +3 -1
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a config import report means on screen (#1021).
|
|
3
|
+
*
|
|
4
|
+
* `POST /config/import` answers with one flat report: per-item verdicts, a list
|
|
5
|
+
* of blocking errors and a list of non-blocking warnings, each keyed by a code.
|
|
6
|
+
* The screens group that into sections, and turn a code into copy that says
|
|
7
|
+
* what failed and what to do about it. Nothing here touches the network or the
|
|
8
|
+
* DOM, so every branch the wizard can land in is decidable in a unit test.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ApiError,
|
|
13
|
+
RemitImapConfigImportItemReport,
|
|
14
|
+
RemitImapConfigImportReport,
|
|
15
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
16
|
+
// `ApiError` above is the wire model for an error body; this is the thrown
|
|
17
|
+
// wrapper `lib/client.ts` puts every HTTP failure in.
|
|
18
|
+
import { ApiError as ThrownApiError } from "./api.js";
|
|
19
|
+
import { getErrorStatus } from "./error-classifier.js";
|
|
20
|
+
import { safeJsonParse } from "./json.js";
|
|
21
|
+
|
|
22
|
+
export const IMPORT_SECTION_ORDER = [
|
|
23
|
+
"accounts",
|
|
24
|
+
"labels",
|
|
25
|
+
"filters",
|
|
26
|
+
"addressFlags",
|
|
27
|
+
"settings",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type ImportSection = (typeof IMPORT_SECTION_ORDER)[number];
|
|
31
|
+
|
|
32
|
+
const SECTION_TITLES: Record<ImportSection, string> = {
|
|
33
|
+
accounts: "Accounts",
|
|
34
|
+
labels: "Labels",
|
|
35
|
+
filters: "Rules & filters",
|
|
36
|
+
addressFlags: "Senders",
|
|
37
|
+
settings: "Folders & appearance",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const IMPORT_VERDICTS = [
|
|
41
|
+
"created",
|
|
42
|
+
"updated",
|
|
43
|
+
"unchanged",
|
|
44
|
+
"skipped",
|
|
45
|
+
"rejected",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
export type ImportVerdict = (typeof IMPORT_VERDICTS)[number];
|
|
49
|
+
|
|
50
|
+
export const verdictTone: Record<
|
|
51
|
+
ImportVerdict,
|
|
52
|
+
"neutral" | "positive" | "warning" | "danger" | "accent"
|
|
53
|
+
> = {
|
|
54
|
+
created: "positive",
|
|
55
|
+
updated: "accent",
|
|
56
|
+
unchanged: "neutral",
|
|
57
|
+
skipped: "warning",
|
|
58
|
+
rejected: "danger",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The wire model spells the verdict as a plain string, so a value this client
|
|
63
|
+
* does not know reads as rejected rather than as an unstyled row: an item the
|
|
64
|
+
* screen cannot vouch for is one the reader must look at.
|
|
65
|
+
*/
|
|
66
|
+
export const asVerdict = (value: string): ImportVerdict =>
|
|
67
|
+
(IMPORT_VERDICTS as readonly string[]).includes(value)
|
|
68
|
+
? (value as ImportVerdict)
|
|
69
|
+
: "rejected";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A section this client does not know is not folded into one it does. Quietly
|
|
73
|
+
* filing an unrecognised item under "settings" would tell the reader something
|
|
74
|
+
* false about what a newer Reader's file is doing to their configuration, which
|
|
75
|
+
* is the failure the whole format exists to prevent. Unknown reads as unknown,
|
|
76
|
+
* and the screen shows it under its own heading.
|
|
77
|
+
*/
|
|
78
|
+
export const UNKNOWN_SECTION = "unknown" as const;
|
|
79
|
+
|
|
80
|
+
export type ReportSectionId = ImportSection | typeof UNKNOWN_SECTION;
|
|
81
|
+
|
|
82
|
+
export const asSection = (value: string): ImportSection | undefined =>
|
|
83
|
+
(IMPORT_SECTION_ORDER as readonly string[]).includes(value)
|
|
84
|
+
? (value as ImportSection)
|
|
85
|
+
: undefined;
|
|
86
|
+
|
|
87
|
+
export interface ReportEntry {
|
|
88
|
+
id: string;
|
|
89
|
+
label: string;
|
|
90
|
+
verdict: ImportVerdict;
|
|
91
|
+
reason?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ReportSection {
|
|
95
|
+
id: ReportSectionId;
|
|
96
|
+
title: string;
|
|
97
|
+
entries: ReportEntry[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const groupReportSections = (
|
|
101
|
+
items: readonly RemitImapConfigImportItemReport[],
|
|
102
|
+
): ReportSection[] => {
|
|
103
|
+
const grouped = new Map<ReportSectionId, ReportEntry[]>();
|
|
104
|
+
items.forEach((item, index) => {
|
|
105
|
+
const section = asSection(item.section) ?? UNKNOWN_SECTION;
|
|
106
|
+
const entries = grouped.get(section) ?? [];
|
|
107
|
+
entries.push({
|
|
108
|
+
id: `${section}-${item.key}-${index}`,
|
|
109
|
+
label: item.key,
|
|
110
|
+
verdict: asVerdict(item.verdict),
|
|
111
|
+
reason:
|
|
112
|
+
section === UNKNOWN_SECTION
|
|
113
|
+
? (item.reason ??
|
|
114
|
+
`This instance does not know the "${item.section}" section, so it cannot say what this entry does.`)
|
|
115
|
+
: item.reason,
|
|
116
|
+
});
|
|
117
|
+
grouped.set(section, entries);
|
|
118
|
+
});
|
|
119
|
+
const ordered: ReportSectionId[] = [...IMPORT_SECTION_ORDER, UNKNOWN_SECTION];
|
|
120
|
+
return ordered
|
|
121
|
+
.filter((section) => grouped.has(section))
|
|
122
|
+
.map((section) => ({
|
|
123
|
+
id: section,
|
|
124
|
+
title:
|
|
125
|
+
section === UNKNOWN_SECTION
|
|
126
|
+
? "Not recognised"
|
|
127
|
+
: SECTION_TITLES[section],
|
|
128
|
+
entries: grouped.get(section) ?? [],
|
|
129
|
+
}));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type VerdictCounts = Record<ImportVerdict, number>;
|
|
133
|
+
|
|
134
|
+
export const countVerdicts = (
|
|
135
|
+
items: readonly RemitImapConfigImportItemReport[],
|
|
136
|
+
): VerdictCounts => {
|
|
137
|
+
const counts: VerdictCounts = {
|
|
138
|
+
created: 0,
|
|
139
|
+
updated: 0,
|
|
140
|
+
unchanged: 0,
|
|
141
|
+
skipped: 0,
|
|
142
|
+
rejected: 0,
|
|
143
|
+
};
|
|
144
|
+
for (const item of items) counts[asVerdict(item.verdict)] += 1;
|
|
145
|
+
return counts;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A blocking failure, worded for the person holding the file: what the reader
|
|
150
|
+
* refused, why refusing beats importing around it, and the move that fixes it.
|
|
151
|
+
* `raw` is the server's own sentence, kept verbatim so a bug report carries it.
|
|
152
|
+
*/
|
|
153
|
+
export interface FailureCopy {
|
|
154
|
+
title: string;
|
|
155
|
+
explanation: string;
|
|
156
|
+
fix: string;
|
|
157
|
+
raw: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const READ_FAILURE_COPY: Record<
|
|
161
|
+
string,
|
|
162
|
+
(error: ApiError) => Omit<FailureCopy, "raw">
|
|
163
|
+
> = {
|
|
164
|
+
NotAnObject: () => ({
|
|
165
|
+
title: "That is not a Reader config file",
|
|
166
|
+
explanation:
|
|
167
|
+
"The file was read, but it is not a configuration document — a Reader config is a JSON object carrying a kind and a schemaVersion.",
|
|
168
|
+
fix: "Export one from Settings › Advanced on the instance you are moving from, or run remit config save on its host.",
|
|
169
|
+
}),
|
|
170
|
+
WrongKind: () => ({
|
|
171
|
+
title: "That is not a Reader config file",
|
|
172
|
+
explanation:
|
|
173
|
+
"The file parses as JSON but does not declare itself a Reader configuration, so nothing here can be trusted to mean what it looks like.",
|
|
174
|
+
fix: "Export one from Settings › Advanced on the instance you are moving from, or run remit config save on its host.",
|
|
175
|
+
}),
|
|
176
|
+
UnsupportedVersion: (error) => ({
|
|
177
|
+
title: "This file was written by a newer Reader",
|
|
178
|
+
explanation:
|
|
179
|
+
error.message ||
|
|
180
|
+
"The file declares a schema version this instance does not read, so importing it would drop settings it cannot understand.",
|
|
181
|
+
fix: "Update this Reader, then import again. The file is unchanged and safe to keep.",
|
|
182
|
+
}),
|
|
183
|
+
UnknownKeys: (error) => ({
|
|
184
|
+
title: "The file contains settings this version does not know",
|
|
185
|
+
explanation:
|
|
186
|
+
"An unknown key is refused rather than quietly dropped: losing configuration without saying so is exactly what this feature exists to prevent.",
|
|
187
|
+
fix: `Update this Reader, or remove the named keys from the file and import again. ${error.message}`,
|
|
188
|
+
}),
|
|
189
|
+
CredentialPresent: () => ({
|
|
190
|
+
title: "The file carries a credential",
|
|
191
|
+
explanation:
|
|
192
|
+
"A configuration document describes which credential an account needs; it never carries one. Reader refuses the file rather than importing around it — the secret may now be in your backups, your chat history, or a pull request.",
|
|
193
|
+
fix: "Delete the field from the file and import again, then change that mailbox's password. Exports written by Reader never contain one.",
|
|
194
|
+
}),
|
|
195
|
+
Malformed: (error) => ({
|
|
196
|
+
title: "The file does not match the configuration format",
|
|
197
|
+
explanation:
|
|
198
|
+
"Every part of the document has to validate before anything is written, and this one does not.",
|
|
199
|
+
fix: `Re-export it from the instance you are moving from. ${error.message}`,
|
|
200
|
+
}),
|
|
201
|
+
duplicate_key: (error) => ({
|
|
202
|
+
title: "The file names something twice",
|
|
203
|
+
explanation:
|
|
204
|
+
"A configuration names each account, label and filter once, so a document with two of the same key has no single answer to import.",
|
|
205
|
+
fix: `Remove the duplicate and import again. ${error.message}`,
|
|
206
|
+
}),
|
|
207
|
+
unknown_label: (error) => ({
|
|
208
|
+
title: "A rule points at a label that is not here",
|
|
209
|
+
explanation:
|
|
210
|
+
"The file applies a label neither it nor this instance carries, so the rule would land doing nothing.",
|
|
211
|
+
fix: `Add the label to the file, or create it here first, then import again. ${error.message}`,
|
|
212
|
+
}),
|
|
213
|
+
unknown_account: (error) => ({
|
|
214
|
+
title: "A rule points at an account that is not here",
|
|
215
|
+
explanation:
|
|
216
|
+
"The file files mail into a folder on an account it does not carry, so the rule has no destination to bind to.",
|
|
217
|
+
fix: `Export the configuration again from an instance that holds both, then import that file. ${error.message}`,
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const UNRECOGNISED_FAILURE = (error: ApiError): Omit<FailureCopy, "raw"> => ({
|
|
222
|
+
title: "The file could not be imported",
|
|
223
|
+
explanation:
|
|
224
|
+
error.message || "The import was refused and nothing was written.",
|
|
225
|
+
fix: "Re-export the configuration from the instance you are moving from and try again. If it keeps failing, report it with the line below.",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
export const WRITE_FAILURE_CODE = "import_write_failed";
|
|
229
|
+
|
|
230
|
+
export const CONFLICT_CODE = "config_not_empty";
|
|
231
|
+
|
|
232
|
+
export interface ImportConflict {
|
|
233
|
+
message: string;
|
|
234
|
+
details?: Record<string, string>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The 409 a non-empty configuration answers with, or nothing.
|
|
239
|
+
*
|
|
240
|
+
* The endpoint's body is flat — `code`, `message`, `details` — but it does not
|
|
241
|
+
* always arrive that way: `lib/client.ts` re-wraps every HTTP failure as an
|
|
242
|
+
* `ApiError` so the fail-fast classifier can read a status off it, and that
|
|
243
|
+
* moves the body onto `error.body`. Reading the code off the error itself finds
|
|
244
|
+
* nothing there and turns the refusal into "check your connection", which is
|
|
245
|
+
* how the abort-or-merge screen became unreachable. Both shapes are checked,
|
|
246
|
+
* and the code is what decides: a status is confirmation where there is one,
|
|
247
|
+
* never the thing being matched on.
|
|
248
|
+
*/
|
|
249
|
+
export const readConflict = (error: unknown): ImportConflict | undefined => {
|
|
250
|
+
const status = getErrorStatus(error);
|
|
251
|
+
if (status !== undefined && status !== 409) return undefined;
|
|
252
|
+
const body: unknown = error instanceof ThrownApiError ? error.body : error;
|
|
253
|
+
if (typeof body !== "object" || body === null) return undefined;
|
|
254
|
+
const flat = body as {
|
|
255
|
+
code?: unknown;
|
|
256
|
+
message?: unknown;
|
|
257
|
+
details?: unknown;
|
|
258
|
+
};
|
|
259
|
+
if (flat.code !== CONFLICT_CODE) return undefined;
|
|
260
|
+
return {
|
|
261
|
+
message: typeof flat.message === "string" ? flat.message : "",
|
|
262
|
+
details:
|
|
263
|
+
typeof flat.details === "object" && flat.details !== null
|
|
264
|
+
? (flat.details as Record<string, string>)
|
|
265
|
+
: undefined,
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The blocking failure to render, when there is one. `import_write_failed` is
|
|
271
|
+
* deliberately not one of these: the import got past validation and part of it
|
|
272
|
+
* may be live, which is a different screen with a different instruction.
|
|
273
|
+
*/
|
|
274
|
+
export const readFailure = (
|
|
275
|
+
report: RemitImapConfigImportReport,
|
|
276
|
+
): FailureCopy | undefined => {
|
|
277
|
+
const error = report.errors.find((it) => it.code !== WRITE_FAILURE_CODE);
|
|
278
|
+
if (!error) return undefined;
|
|
279
|
+
const build = READ_FAILURE_COPY[error.code] ?? UNRECOGNISED_FAILURE;
|
|
280
|
+
return { ...build(error), raw: `${error.code}: ${error.message}` };
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export const writeFailure = (
|
|
284
|
+
report: RemitImapConfigImportReport,
|
|
285
|
+
): ApiError | undefined =>
|
|
286
|
+
report.errors.find((it) => it.code === WRITE_FAILURE_CODE);
|
|
287
|
+
|
|
288
|
+
export type SectionOutcome = "landed" | "failed" | "not-attempted" | "unknown";
|
|
289
|
+
|
|
290
|
+
export interface SectionResult {
|
|
291
|
+
section: ImportSection;
|
|
292
|
+
title: string;
|
|
293
|
+
state: SectionOutcome;
|
|
294
|
+
detail: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* What a stopped import left behind, section by section. The write order is
|
|
299
|
+
* fixed, so the section the failure names splits the document in three: what is
|
|
300
|
+
* before it ran, it did not, and what is after it was never reached. The
|
|
301
|
+
* server's own sentence says whether the earlier writes survived, because only
|
|
302
|
+
* the backend knows whether it had a transaction.
|
|
303
|
+
*
|
|
304
|
+
* A failure that names no section leaves that split undecidable, and every
|
|
305
|
+
* section reads `unknown` rather than `landed`. Telling someone their accounts
|
|
306
|
+
* are in when nothing said so is the one answer this screen must never give:
|
|
307
|
+
* they would stop looking.
|
|
308
|
+
*/
|
|
309
|
+
export const sectionResults = (
|
|
310
|
+
report: RemitImapConfigImportReport,
|
|
311
|
+
): SectionResult[] => {
|
|
312
|
+
const failure = writeFailure(report);
|
|
313
|
+
const named = failure?.details?.section;
|
|
314
|
+
const failedSection = named ? asSection(named) : undefined;
|
|
315
|
+
const counts = new Map<ImportSection, number>();
|
|
316
|
+
for (const item of report.items) {
|
|
317
|
+
if (asVerdict(item.verdict) === "rejected") continue;
|
|
318
|
+
const section = asSection(item.section);
|
|
319
|
+
if (!section) continue;
|
|
320
|
+
counts.set(section, (counts.get(section) ?? 0) + 1);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!failedSection) {
|
|
324
|
+
const detail = named
|
|
325
|
+
? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`
|
|
326
|
+
: "The import stopped without naming where, so it cannot say whether this section was written. Open Settings to see what is here.";
|
|
327
|
+
return IMPORT_SECTION_ORDER.map((section) => ({
|
|
328
|
+
section,
|
|
329
|
+
title: SECTION_TITLES[section],
|
|
330
|
+
state: "unknown" as const,
|
|
331
|
+
detail,
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const failedAt = IMPORT_SECTION_ORDER.indexOf(failedSection);
|
|
336
|
+
|
|
337
|
+
return IMPORT_SECTION_ORDER.map((section, index) => {
|
|
338
|
+
const title = SECTION_TITLES[section];
|
|
339
|
+
if (section === failedSection) {
|
|
340
|
+
return {
|
|
341
|
+
section,
|
|
342
|
+
title,
|
|
343
|
+
state: "failed" as const,
|
|
344
|
+
detail: failure?.message ?? "This section could not be written.",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (index > failedAt) {
|
|
348
|
+
return {
|
|
349
|
+
section,
|
|
350
|
+
title,
|
|
351
|
+
state: "not-attempted" as const,
|
|
352
|
+
detail: "Not attempted — the import stopped before this section.",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const written = counts.get(section) ?? 0;
|
|
356
|
+
return {
|
|
357
|
+
section,
|
|
358
|
+
title,
|
|
359
|
+
state: "landed" as const,
|
|
360
|
+
detail:
|
|
361
|
+
written === 0
|
|
362
|
+
? "Nothing in the file for this section."
|
|
363
|
+
: `${written} ${written === 1 ? "entry" : "entries"} written.`,
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
export const FOLDER_PENDING_CODE = "folder_not_found_yet";
|
|
369
|
+
|
|
370
|
+
export interface PendingFolder {
|
|
371
|
+
path: string;
|
|
372
|
+
accountId?: string;
|
|
373
|
+
waitingFor: string;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The folders the file names that this account does not hold yet. They are
|
|
378
|
+
* recorded rather than dropped: the binder writes each setting the moment IMAP
|
|
379
|
+
* discovery produces its folder, so the copy has to say waiting, never lost.
|
|
380
|
+
*/
|
|
381
|
+
export const pendingFolders = (
|
|
382
|
+
report: RemitImapConfigImportReport,
|
|
383
|
+
): PendingFolder[] => {
|
|
384
|
+
const seen = new Set<string>();
|
|
385
|
+
const folders: PendingFolder[] = [];
|
|
386
|
+
for (const warning of report.warnings) {
|
|
387
|
+
if (warning.code !== FOLDER_PENDING_CODE) continue;
|
|
388
|
+
const path = warning.details?.folderPath;
|
|
389
|
+
if (!path) continue;
|
|
390
|
+
const filter = warning.details?.filter;
|
|
391
|
+
const accountId = warning.details?.accountId;
|
|
392
|
+
const key = `${accountId ?? ""}:${path}:${filter ?? ""}`;
|
|
393
|
+
if (seen.has(key)) continue;
|
|
394
|
+
seen.add(key);
|
|
395
|
+
folders.push({
|
|
396
|
+
path,
|
|
397
|
+
accountId,
|
|
398
|
+
waitingFor: filter
|
|
399
|
+
? `Rule “${filter}”`
|
|
400
|
+
: "A folder role or a folder's own settings",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
return folders;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
export const anchorWarnings = (
|
|
407
|
+
report: RemitImapConfigImportReport,
|
|
408
|
+
): ApiError[] =>
|
|
409
|
+
report.warnings.filter((warning) => warning.code !== FOLDER_PENDING_CODE);
|
|
410
|
+
|
|
411
|
+
/* ------------------------------------------------------------------ */
|
|
412
|
+
/* Reading a file off disk */
|
|
413
|
+
/* ------------------------------------------------------------------ */
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A config document is small — the largest realistic export is tens of
|
|
417
|
+
* kilobytes — so anything past this is a file picked by mistake, and saying so
|
|
418
|
+
* beats posting megabytes to be told the same thing.
|
|
419
|
+
*/
|
|
420
|
+
export const MAX_CONFIG_FILE_BYTES = 2 * 1024 * 1024;
|
|
421
|
+
|
|
422
|
+
export type ConfigDocument = Record<string, unknown>;
|
|
423
|
+
|
|
424
|
+
export type FileReadResult =
|
|
425
|
+
| { ok: true; document: ConfigDocument }
|
|
426
|
+
| { ok: false; failure: FailureCopy };
|
|
427
|
+
|
|
428
|
+
const NOT_JSON = Symbol("not json");
|
|
429
|
+
|
|
430
|
+
export const readConfigText = async (
|
|
431
|
+
name: string,
|
|
432
|
+
size: number,
|
|
433
|
+
text: string,
|
|
434
|
+
): Promise<FileReadResult> => {
|
|
435
|
+
if (size > MAX_CONFIG_FILE_BYTES) {
|
|
436
|
+
return {
|
|
437
|
+
ok: false,
|
|
438
|
+
failure: {
|
|
439
|
+
title: "That file is too large to be a config file",
|
|
440
|
+
explanation: `${name} is ${Math.round(size / 1024)} kB. A Reader configuration is tens of kilobytes; a file this size is something else.`,
|
|
441
|
+
fix: "Pick the .json file written by Settings › Advanced, or by remit config save.",
|
|
442
|
+
raw: `${size} bytes > ${MAX_CONFIG_FILE_BYTES} byte limit`,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let syntaxError = "";
|
|
448
|
+
const parsed: unknown = await safeJsonParse<unknown>(text).catch(
|
|
449
|
+
(cause: unknown) => {
|
|
450
|
+
syntaxError = cause instanceof Error ? cause.message : String(cause);
|
|
451
|
+
return NOT_JSON;
|
|
452
|
+
},
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
if (parsed === NOT_JSON) {
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
failure: {
|
|
459
|
+
title: "That file is not JSON",
|
|
460
|
+
explanation: `${name} could not be read as JSON at all, so there is nothing in it to import.`,
|
|
461
|
+
fix: "Pick the .json file written by Settings › Advanced, or by remit config save.",
|
|
462
|
+
raw: syntaxError,
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
failure: {
|
|
471
|
+
title: "That is not a Reader config file",
|
|
472
|
+
explanation: `${name} is valid JSON but not a configuration document — a Reader config is a JSON object carrying a kind and a schemaVersion.`,
|
|
473
|
+
fix: "Export one from Settings › Advanced on the instance you are moving from, or run remit config save on its host.",
|
|
474
|
+
raw: `expected a JSON object, found ${Array.isArray(parsed) ? "an array" : typeof parsed}`,
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { ok: true, document: parsed as ConfigDocument };
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/* ------------------------------------------------------------------ */
|
|
483
|
+
/* The export half */
|
|
484
|
+
/* ------------------------------------------------------------------ */
|
|
485
|
+
|
|
486
|
+
export const exportFileName = (now: Date): string => {
|
|
487
|
+
const iso = now.toISOString().slice(0, 10);
|
|
488
|
+
return `reader-config.${iso}.json`;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
export const formatFileSize = (bytes: number): string =>
|
|
492
|
+
bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} kB`;
|
package/src/lib/json.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse JSON in a Promise so a malformed string surfaces as a rejection the
|
|
3
|
+
* caller can `.catch()`, instead of a synchronous throw that would need a block
|
|
4
|
+
* try/catch. Use only where an unparseable value is an expected, recoverable
|
|
5
|
+
* outcome — a file the reader picked by mistake — never to soften an internal
|
|
6
|
+
* contract, which must stay a fatal uncaught `JSON.parse`.
|
|
7
|
+
*/
|
|
8
|
+
export const safeJsonParse = <T>(raw: string): Promise<T> =>
|
|
9
|
+
new Promise((resolve) => {
|
|
10
|
+
resolve(JSON.parse(raw));
|
|
11
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spelling the address bar is written in.
|
|
3
|
+
*
|
|
4
|
+
* The router reads a repeated param as a list already; this is the other half,
|
|
5
|
+
* and the assertions are the round trip — anything written here has to come
|
|
6
|
+
* back the value it went in as.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { parseSearchWith } from "@tanstack/react-router";
|
|
11
|
+
import { stringifySearch } from "./search-params.js";
|
|
12
|
+
|
|
13
|
+
/** The router's own reader, which this module has to stay symmetrical with. */
|
|
14
|
+
const parseSearch = parseSearchWith(JSON.parse);
|
|
15
|
+
|
|
16
|
+
/** Spread, because the router's reader answers with a null-prototype object. */
|
|
17
|
+
const roundTrip = (search: Record<string, unknown>): unknown => ({
|
|
18
|
+
...parseSearch(stringifySearch(search)),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("stringifySearch", () => {
|
|
22
|
+
it("writes a list as the repeated params it is", () => {
|
|
23
|
+
assert.equal(
|
|
24
|
+
stringifySearch({ calendarId: ["cal_a", "cal_b"] }),
|
|
25
|
+
"?calendarId=cal_a&calendarId=cal_b",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("writes a list of one as one param", () => {
|
|
30
|
+
assert.equal(
|
|
31
|
+
stringifySearch({ calendarId: ["cal_a"] }),
|
|
32
|
+
"?calendarId=cal_a",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("writes nothing for an empty list, an absent value, or no params", () => {
|
|
37
|
+
assert.equal(stringifySearch({ calendarId: [] }), "");
|
|
38
|
+
assert.equal(stringifySearch({ q: undefined }), "");
|
|
39
|
+
assert.equal(stringifySearch({}), "");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("leaves an ordinary value the way the router already wrote it", () => {
|
|
43
|
+
assert.equal(stringifySearch({ q: "invoice" }), "?q=invoice");
|
|
44
|
+
assert.equal(stringifySearch({ wizard: "review" }), "?wizard=review");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("percent-encodes what a query string cannot carry raw", () => {
|
|
48
|
+
assert.equal(stringifySearch({ q: "from:ada &c" }), "?q=from%3Aada+%26c");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("what is written comes back", () => {
|
|
53
|
+
it("gives a list back as a list", () => {
|
|
54
|
+
assert.deepEqual(roundTrip({ calendarId: ["cal_a", "cal_b"] }), {
|
|
55
|
+
calendarId: ["cal_a", "cal_b"],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("gives text that reads as JSON back as text", () => {
|
|
60
|
+
// Unquoted, the reader would hand back a boolean and a number.
|
|
61
|
+
assert.deepEqual(roundTrip({ q: "true" }), { q: "true" });
|
|
62
|
+
assert.deepEqual(roundTrip({ q: "2026" }), { q: "2026" });
|
|
63
|
+
assert.deepEqual(roundTrip({ q: "[draft]" }), { q: "[draft]" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("gives ordinary text back unchanged", () => {
|
|
67
|
+
assert.deepEqual(roundTrip({ q: "from:ada invoice" }), {
|
|
68
|
+
q: "from:ada invoice",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("gives a structure back as a structure", () => {
|
|
73
|
+
assert.deepEqual(roundTrip({ filter: { unread: true } }), {
|
|
74
|
+
filter: { unread: true },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the address bar spells the query string.
|
|
3
|
+
*
|
|
4
|
+
* The router's own serializer writes one param per key, so a list of values
|
|
5
|
+
* comes out as a JSON blob — `?calendarId=%5B%22cal_a%22%2C%22cal_b%22%5D`. A
|
|
6
|
+
* list of values is what a query string has always been able to say, and
|
|
7
|
+
* saying it the ordinary way is what makes a link hand-writable, readable in a
|
|
8
|
+
* log, and usable by anything that is not this router. The router's own reader
|
|
9
|
+
* already collects repeated params into an array, so only the writing side was
|
|
10
|
+
* missing.
|
|
11
|
+
*
|
|
12
|
+
* Every other value keeps the spelling the router already gives it, including
|
|
13
|
+
* the quoting that text reading as JSON needs: the reader JSON-parses whatever
|
|
14
|
+
* it can, so `?q=true` would come back the boolean unless the string went in
|
|
15
|
+
* quoted.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether reading this text back would produce something other than the text.
|
|
20
|
+
*
|
|
21
|
+
* Decided by shape rather than by parsing it, because a block try/catch is not
|
|
22
|
+
* available and an over-quoted string still reads back as itself — the reader
|
|
23
|
+
* unquotes it — where an under-quoted one comes back the wrong type.
|
|
24
|
+
*/
|
|
25
|
+
const readsAsJson = (value: string): boolean =>
|
|
26
|
+
value === "true" ||
|
|
27
|
+
value === "false" ||
|
|
28
|
+
value === "null" ||
|
|
29
|
+
/^["[{]/.test(value) ||
|
|
30
|
+
(value !== "" && Number.isFinite(Number(value)));
|
|
31
|
+
|
|
32
|
+
/** The router's own value spelling. */
|
|
33
|
+
function stringifyValue(value: unknown): string {
|
|
34
|
+
if (typeof value === "object" && value !== null) return JSON.stringify(value);
|
|
35
|
+
if (typeof value === "string")
|
|
36
|
+
return readsAsJson(value) ? JSON.stringify(value) : value;
|
|
37
|
+
return String(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A query string, with a list written as the repeated params it is.
|
|
42
|
+
*
|
|
43
|
+
* `undefined` is absent rather than empty: a fact the view has nothing to say
|
|
44
|
+
* about does not belong in the address at all.
|
|
45
|
+
*/
|
|
46
|
+
export function stringifySearch(search: Record<string, unknown>): string {
|
|
47
|
+
const params = new URLSearchParams();
|
|
48
|
+
for (const key of Object.keys(search)) {
|
|
49
|
+
const value = search[key];
|
|
50
|
+
if (value === undefined) continue;
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
for (const entry of value) params.append(key, stringifyValue(entry));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
params.set(key, stringifyValue(value));
|
|
56
|
+
}
|
|
57
|
+
const written = params.toString();
|
|
58
|
+
return written === "" ? "" : `?${written}`;
|
|
59
|
+
}
|