Skip to content

Commit 2a66fd8

Browse files
committed
chore: migrate to zod for parsing
1 parent 947bb30 commit 2a66fd8

File tree

4 files changed

+116
-165
lines changed

4 files changed

+116
-165
lines changed

plugin/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"snakify-ts": "^2.3.0",
2626
"tslib": "^2.4.1",
2727
"yaml": "^2.1.3",
28+
"zod": "^3.24.1",
2829
"zustand": "^4.5.2"
2930
},
3031
"devDependencies": {
@@ -43,4 +44,4 @@
4344
"vite-tsconfig-paths": "^4.3.2",
4445
"vitest": "^1.2.2"
4546
}
46-
}
47+
}

plugin/src/query/parser.ts

Lines changed: 96 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { t } from "@/i18n";
22
import { GroupVariant, type Query, ShowMetadataVariant, SortingVariant } from "@/query/query";
33
import YAML from "yaml";
4+
import { z } from "zod";
45

56
export class ParsingError extends Error {
7+
messages: string[];
68
inner: unknown | undefined;
79

8-
constructor(msg: string, inner: unknown | undefined = undefined) {
9-
super(msg);
10+
constructor(msgs: string[], inner: unknown | undefined = undefined) {
11+
super(msgs.join("\n"));
1012
this.inner = inner;
13+
this.messages = msgs;
1114
}
1215

1316
public toString(): string {
@@ -22,7 +25,7 @@ export class ParsingError extends Error {
2225
export type QueryWarning = string;
2326

2427
export function parseQuery(raw: string): [Query, QueryWarning[]] {
25-
let obj: Record<string, unknown>;
28+
let obj: Record<string, unknown> | null = null;
2629
const warnings: QueryWarning[] = [];
2730

2831
try {
@@ -32,11 +35,15 @@ export function parseQuery(raw: string): [Query, QueryWarning[]] {
3235
try {
3336
obj = tryParseAsYaml(raw);
3437
} catch (e) {
35-
throw new ParsingError("Unable to parse as YAML or JSON");
38+
throw new ParsingError(["Unable to parse as YAML or JSON"]);
3639
}
3740
}
3841

39-
const [query, parsingWarnings] = parseObject(obj);
42+
if (obj === null) {
43+
obj = {};
44+
}
45+
46+
const [query, parsingWarnings] = parseObjectZod(obj);
4047
warnings.push(...parsingWarnings);
4148

4249
return [query, warnings];
@@ -46,169 +53,25 @@ function tryParseAsJson(raw: string): Record<string, unknown> {
4653
try {
4754
return JSON.parse(raw);
4855
} catch (e) {
49-
throw new ParsingError("Invalid JSON", e);
56+
throw new ParsingError(["Invalid JSON"], e);
5057
}
5158
}
5259

5360
function tryParseAsYaml(raw: string): Record<string, unknown> {
5461
try {
5562
return YAML.parse(raw);
5663
} catch (e) {
57-
throw new ParsingError("Invalid YAML", e);
58-
}
59-
}
60-
61-
const validQueryKeys = new Set(["name", "filter", "autorefresh", "sorting", "show", "groupBy"]);
62-
63-
function parseObject(query: Record<string, unknown>): [Query, QueryWarning[]] {
64-
const warnings: QueryWarning[] = [];
65-
66-
for (const key of Object.keys(query)) {
67-
if (!validQueryKeys.has(key)) {
68-
warnings.push(t().query.warning.unknownKey(key));
69-
}
70-
}
71-
72-
return [
73-
{
74-
name: stringField(query, "name") ?? "",
75-
filter: asRequired("filter", stringField(query, "filter")),
76-
autorefresh: numberField(query, "autorefresh", { isPositive: true }) ?? 0,
77-
sorting: optionsArrayField(query, "sorting", sortingLookup) ?? [SortingVariant.Order],
78-
show: new Set(
79-
optionsArrayField(query, "show", showMetadataVariantLookup) ??
80-
Object.values(showMetadataVariantLookup),
81-
),
82-
groupBy: optionField(query, "groupBy", groupByVariantLookup) ?? GroupVariant.None,
83-
},
84-
warnings,
85-
];
86-
}
87-
88-
function asRequired<T>(key: string, val: T | undefined): T {
89-
if (val === undefined) {
90-
throw new ParsingError(`Field ${key} must be text`);
91-
}
92-
93-
return val as T;
94-
}
95-
96-
function stringField(obj: Record<string, unknown>, key: string): string | undefined {
97-
const value = obj[key];
98-
99-
if (value === undefined) {
100-
return undefined;
101-
}
102-
103-
if (typeof value !== "string") {
104-
throw new ParsingError(`Field ${key} must be text`);
105-
}
106-
107-
return value as string;
108-
}
109-
110-
function numberField(
111-
obj: Record<string, unknown>,
112-
key: string,
113-
options?: { isPositive: boolean },
114-
): number | undefined {
115-
const value = obj[key];
116-
117-
if (value === undefined) {
118-
return undefined;
119-
}
120-
121-
if (typeof value !== "number") {
122-
throw new ParsingError(`Field ${key} must be a number`);
123-
}
124-
125-
const num = value as number;
126-
127-
if (Number.isNaN(num)) {
128-
throw new ParsingError(`Field ${key} must be a number`);
129-
}
130-
131-
if ((options?.isPositive ?? false) && num < 0) {
132-
throw new ParsingError(`Field ${key} must be a positive number`);
133-
}
134-
135-
return num;
136-
}
137-
138-
function booleanField(obj: Record<string, unknown>, key: string): boolean | undefined {
139-
const value = obj[key];
140-
141-
if (value === undefined) {
142-
return undefined;
143-
}
144-
145-
if (typeof value !== "boolean") {
146-
throw new ParsingError(`Field ${key} must be a boolean.`);
64+
throw new ParsingError(["Invalid YAML"], e);
14765
}
148-
149-
return value as boolean;
15066
}
15167

152-
function optionsArrayField<T>(
153-
obj: Record<string, unknown>,
154-
key: string,
155-
lookup: Record<string, T>,
156-
): T[] | undefined {
157-
const value = obj[key];
158-
159-
if (value === undefined) {
160-
return undefined;
161-
}
162-
163-
const opts = Object.keys(lookup).join(", ");
164-
if (!Array.isArray(value)) {
165-
throw new ParsingError(`Field ${key} must be an array from values: ${opts}`);
166-
}
167-
168-
const elems = value as Record<string, unknown>[];
169-
const parsedElems = [];
170-
171-
for (const ele of elems) {
172-
if (typeof ele !== "string") {
173-
throw new ParsingError(`Field ${key} must be an array from values: ${opts}`);
174-
}
175-
176-
const lookupValue = lookup[ele];
177-
if (lookupValue === undefined) {
178-
throw new ParsingError(`Field ${key} must be an array from values: ${opts}`);
179-
}
180-
181-
parsedElems.push(lookupValue);
182-
}
183-
184-
return parsedElems;
185-
}
186-
187-
function optionField<T>(
188-
obj: Record<string, unknown>,
189-
key: string,
190-
lookup: Record<string, T>,
191-
): T | undefined {
192-
const value = obj[key];
193-
194-
if (value === undefined) {
195-
return undefined;
196-
}
197-
198-
const opts = Object.keys(lookup).join(", ");
199-
if (typeof value !== "string") {
200-
throw new ParsingError(`Field ${key} must be one of: ${opts}`);
201-
}
202-
203-
const lookupValue = lookup[value];
204-
if (lookupValue === undefined) {
205-
throw new ParsingError(`Field ${key} must be one of: ${opts}`);
206-
}
207-
208-
return lookupValue;
209-
}
68+
const lookupToEnum = <T>(lookup: Record<string, T>) => {
69+
const keys = Object.keys(lookup);
70+
//@ts-ignore: There is at least one element for these.
71+
return z.enum(keys).transform((key) => lookup[key]);
72+
};
21073

211-
const sortingLookup: Record<string, SortingVariant> = {
74+
const sortingSchema = lookupToEnum({
21275
priority: SortingVariant.Priority,
21376
priorityAscending: SortingVariant.PriorityAscending,
21477
priorityDescending: SortingVariant.Priority,
@@ -219,21 +82,93 @@ const sortingLookup: Record<string, SortingVariant> = {
21982
dateAdded: SortingVariant.DateAdded,
22083
dateAddedAscending: SortingVariant.DateAdded,
22184
dateAddedDescending: SortingVariant.DateAddedDescending,
222-
};
85+
});
22386

224-
const showMetadataVariantLookup: Record<string, ShowMetadataVariant> = {
87+
const showSchema = lookupToEnum({
22588
due: ShowMetadataVariant.Due,
22689
date: ShowMetadataVariant.Due,
22790
description: ShowMetadataVariant.Description,
22891
labels: ShowMetadataVariant.Labels,
22992
project: ShowMetadataVariant.Project,
230-
};
93+
});
23194

232-
const groupByVariantLookup: Record<string, GroupVariant> = {
95+
const groupBySchema = lookupToEnum({
23396
project: GroupVariant.Project,
23497
section: GroupVariant.Section,
23598
priority: GroupVariant.Priority,
23699
due: GroupVariant.Date,
237100
date: GroupVariant.Date,
238101
labels: GroupVariant.Label,
102+
});
103+
104+
const defaults = {
105+
name: "",
106+
autorefresh: 0,
107+
sorting: [SortingVariant.Order],
108+
show: [
109+
ShowMetadataVariant.Due,
110+
ShowMetadataVariant.Description,
111+
ShowMetadataVariant.Labels,
112+
ShowMetadataVariant.Project,
113+
],
114+
groupBy: GroupVariant.None,
239115
};
116+
117+
const querySchema = z.object({
118+
name: z.string().optional().default(""),
119+
filter: z.string(),
120+
autorefresh: z.number().nonnegative().optional().default(0),
121+
sorting: z
122+
.array(sortingSchema)
123+
.optional()
124+
.transform((val) => val ?? defaults.sorting),
125+
show: z
126+
.array(showSchema)
127+
.optional()
128+
.transform((val) => val ?? defaults.show),
129+
groupBy: groupBySchema.optional().transform((val) => val ?? defaults.groupBy),
130+
});
131+
132+
const validQueryKeys: string[] = querySchema.keyof().options;
133+
134+
function parseObjectZod(query: Record<string, unknown>): [Query, QueryWarning[]] {
135+
const warnings: QueryWarning[] = [];
136+
137+
for (const key of Object.keys(query)) {
138+
if (!validQueryKeys.includes(key)) {
139+
warnings.push(t().query.warning.unknownKey(key));
140+
}
141+
}
142+
143+
const out = querySchema.safeParse(query);
144+
145+
if (!out.success) {
146+
throw new ParsingError(formatZodError(out.error));
147+
}
148+
149+
return [
150+
{
151+
name: out.data.name,
152+
filter: out.data.filter,
153+
autorefresh: out.data.autorefresh,
154+
sorting: out.data.sorting,
155+
show: new Set(out.data.show),
156+
groupBy: out.data.groupBy,
157+
},
158+
warnings,
159+
];
160+
}
161+
162+
function formatZodError(error: z.ZodError): string[] {
163+
return error.errors.map((err) => {
164+
const field = err.path[0];
165+
switch (err.code) {
166+
case "invalid_type":
167+
return `Field '${field}' is ${err.received === "undefined" ? "required" : `must be a ${err.expected}`}`;
168+
case "invalid_enum_value":
169+
return `Field '${field}' has invalid value '${err.received}'. Valid options are: ${err.options?.join(", ")}`;
170+
default:
171+
return `Field '${field}': ${err.message}`;
172+
}
173+
});
174+
}

plugin/src/ui/query/QueryError.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { t } from "@/i18n";
2+
import { ParsingError } from "@/query/parser";
23
import { Callout } from "@/ui/components/callout";
34
import type React from "react";
45

@@ -14,14 +15,18 @@ export const QueryError: React.FC<Props> = ({ error }) => {
1415
className="todoist-query-error"
1516
title={i18n.header}
1617
iconId="lucide-alert-triangle"
17-
contents={[getErrorMessage(error) ?? i18n.unknownErrorMessage]}
18+
contents={getErrorMessages(error) ?? [i18n.unknownErrorMessage]}
1819
/>
1920
);
2021
};
2122

22-
const getErrorMessage = (error: unknown): string | undefined => {
23+
const getErrorMessages = (error: unknown): string[] | undefined => {
24+
if (error instanceof ParsingError) {
25+
return error.messages;
26+
}
27+
2328
if (error instanceof Error) {
24-
return error.message;
29+
return [error.message];
2530
}
2631

2732
return;

0 commit comments

Comments
 (0)