@zhin.js/plugin-calendar 0.0.0-stage → 0.1.1
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/README.md +189 -2
- package/commands/calendar/auth/index.js +26 -0
- package/commands/calendar/auth/index.ts +29 -0
- package/commands/calendar/logout/index.js +18 -0
- package/commands/calendar/logout/index.ts +18 -0
- package/commands/calendar/status/index.js +10 -0
- package/commands/calendar/status/index.ts +10 -0
- package/lib/caldav.d.ts +18 -0
- package/lib/caldav.d.ts.map +1 -0
- package/lib/caldav.js +167 -0
- package/lib/caldav.js.map +1 -0
- package/lib/chat-auth.d.ts +49 -0
- package/lib/chat-auth.d.ts.map +1 -0
- package/lib/chat-auth.js +233 -0
- package/lib/chat-auth.js.map +1 -0
- package/lib/cli/auth.d.ts +3 -0
- package/lib/cli/auth.d.ts.map +1 -0
- package/lib/cli/auth.js +182 -0
- package/lib/cli/auth.js.map +1 -0
- package/lib/config.d.ts +87 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +95 -0
- package/lib/config.js.map +1 -0
- package/lib/credential-store.d.ts +30 -0
- package/lib/credential-store.d.ts.map +1 -0
- package/lib/credential-store.js +42 -0
- package/lib/credential-store.js.map +1 -0
- package/lib/free-time.d.ts +13 -0
- package/lib/free-time.d.ts.map +1 -0
- package/lib/free-time.js +96 -0
- package/lib/free-time.js.map +1 -0
- package/lib/http-callback.d.ts +13 -0
- package/lib/http-callback.d.ts.map +1 -0
- package/lib/http-callback.js +26 -0
- package/lib/http-callback.js.map +1 -0
- package/lib/index.d.ts +32 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +122 -0
- package/lib/index.js.map +1 -0
- package/lib/oauth-auth.d.ts +37 -0
- package/lib/oauth-auth.d.ts.map +1 -0
- package/lib/oauth-auth.js +91 -0
- package/lib/oauth-auth.js.map +1 -0
- package/lib/oauth-session.d.ts +27 -0
- package/lib/oauth-session.d.ts.map +1 -0
- package/lib/oauth-session.js +43 -0
- package/lib/oauth-session.js.map +1 -0
- package/lib/oauth.d.ts +28 -0
- package/lib/oauth.d.ts.map +1 -0
- package/lib/oauth.js +200 -0
- package/lib/oauth.js.map +1 -0
- package/lib/runtime.d.ts +11 -0
- package/lib/runtime.d.ts.map +1 -0
- package/lib/runtime.js +3 -0
- package/lib/runtime.js.map +1 -0
- package/lib/secrets.d.ts +3 -0
- package/lib/secrets.d.ts.map +1 -0
- package/lib/secrets.js +6 -0
- package/lib/secrets.js.map +1 -0
- package/lib/time.d.ts +37 -0
- package/lib/time.d.ts.map +1 -0
- package/lib/time.js +118 -0
- package/lib/time.js.map +1 -0
- package/lib/token-manager.d.ts +71 -0
- package/lib/token-manager.d.ts.map +1 -0
- package/lib/token-manager.js +239 -0
- package/lib/token-manager.js.map +1 -0
- package/lib/tool-errors.d.ts +6 -0
- package/lib/tool-errors.d.ts.map +1 -0
- package/lib/tool-errors.js +17 -0
- package/lib/tool-errors.js.map +1 -0
- package/lib/types.d.ts +28 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/package.json +112 -3
- package/plugin.js +72 -0
- package/skills/calendar-scheduling/SKILL.md +38 -0
- package/skills/calendar-scheduling/tools/create_event/index.js +38 -0
- package/skills/calendar-scheduling/tools/create_event/index.ts +41 -0
- package/skills/calendar-scheduling/tools/find_free_time/index.js +46 -0
- package/skills/calendar-scheduling/tools/find_free_time/index.ts +50 -0
- package/skills/calendar-scheduling/tools/list_events/index.js +32 -0
- package/skills/calendar-scheduling/tools/list_events/index.ts +42 -0
- package/src/caldav.ts +198 -0
- package/src/chat-auth.ts +336 -0
- package/src/cli/auth.ts +235 -0
- package/src/config.ts +108 -0
- package/src/credential-store.ts +79 -0
- package/src/free-time.ts +128 -0
- package/src/http-callback.ts +55 -0
- package/src/index.ts +193 -0
- package/src/oauth-auth.ts +145 -0
- package/src/oauth-session.ts +65 -0
- package/src/oauth.ts +298 -0
- package/src/runtime.ts +16 -0
- package/src/secrets.ts +5 -0
- package/src/time.ts +167 -0
- package/src/token-manager.ts +358 -0
- package/src/tool-errors.ts +18 -0
- package/src/types.ts +37 -0
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar and Microsoft Graph providers backed by OAuthTokenManager
|
|
3
|
+
* (refresh_token flow; access tokens are never stored in config).
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
CalendarEvent,
|
|
7
|
+
CalendarProvider,
|
|
8
|
+
CreateEventInput,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
import { formatInZone } from "./time.js";
|
|
11
|
+
import {
|
|
12
|
+
OAuthTokenManager,
|
|
13
|
+
type AuthorizationLostListener,
|
|
14
|
+
type RefreshTokenStore,
|
|
15
|
+
type TokenManagerLogger,
|
|
16
|
+
} from "./token-manager.js";
|
|
17
|
+
|
|
18
|
+
export interface OAuthCalendarOptions {
|
|
19
|
+
readonly clientId: string;
|
|
20
|
+
readonly clientSecret?: string;
|
|
21
|
+
/** Fallback refresh token from config/env. */
|
|
22
|
+
readonly refreshToken?: string;
|
|
23
|
+
readonly tokenStore?: RefreshTokenStore;
|
|
24
|
+
readonly onAuthorizationLost?: AuthorizationLostListener;
|
|
25
|
+
/** Use an existing manager (shared with the chat auth flow) instead of creating one. */
|
|
26
|
+
readonly tokenManager?: OAuthTokenManager;
|
|
27
|
+
readonly calendarId: string;
|
|
28
|
+
readonly timezone: string;
|
|
29
|
+
readonly tenant?: string;
|
|
30
|
+
/** Optional JSON file to persist rotated refresh tokens. */
|
|
31
|
+
readonly tokenFile?: string;
|
|
32
|
+
readonly fetchImpl?: typeof fetch;
|
|
33
|
+
readonly logger?: TokenManagerLogger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mapGoogleEvent(
|
|
37
|
+
item: Record<string, unknown>,
|
|
38
|
+
timezone: string
|
|
39
|
+
): CalendarEvent {
|
|
40
|
+
const start = (item.start as { dateTime?: string; date?: string }) ?? {};
|
|
41
|
+
const end = (item.end as { dateTime?: string; date?: string }) ?? {};
|
|
42
|
+
const startDate = new Date(start.dateTime ?? start.date ?? "");
|
|
43
|
+
const endDate = new Date(end.dateTime ?? end.date ?? "");
|
|
44
|
+
return {
|
|
45
|
+
id: String(item.id ?? ""),
|
|
46
|
+
title: String(item.summary ?? "(无标题)"),
|
|
47
|
+
start: formatInZone(startDate, timezone),
|
|
48
|
+
end: formatInZone(endDate, timezone),
|
|
49
|
+
location: item.location ? String(item.location) : undefined,
|
|
50
|
+
description: item.description ? String(item.description) : undefined,
|
|
51
|
+
all_day: Boolean(start.date && !start.dateTime),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mapMicrosoftEvent(
|
|
56
|
+
item: Record<string, unknown>,
|
|
57
|
+
timezone: string
|
|
58
|
+
): CalendarEvent {
|
|
59
|
+
const start = (item.start as { dateTime?: string }) ?? {};
|
|
60
|
+
const end = (item.end as { dateTime?: string }) ?? {};
|
|
61
|
+
const startRaw = start.dateTime ?? "";
|
|
62
|
+
const endRaw = end.dateTime ?? "";
|
|
63
|
+
const startDate = new Date(
|
|
64
|
+
/[zZ]|[+-]\d{2}:?\d{2}$/.test(startRaw) ? startRaw : `${startRaw}Z`
|
|
65
|
+
);
|
|
66
|
+
const endDate = new Date(
|
|
67
|
+
/[zZ]|[+-]\d{2}:?\d{2}$/.test(endRaw) ? endRaw : `${endRaw}Z`
|
|
68
|
+
);
|
|
69
|
+
return {
|
|
70
|
+
id: String(item.id ?? ""),
|
|
71
|
+
title: String(item.subject ?? "(无标题)"),
|
|
72
|
+
start: formatInZone(startDate, timezone),
|
|
73
|
+
end: formatInZone(endDate, timezone),
|
|
74
|
+
location: item.location
|
|
75
|
+
? String((item.location as { displayName?: string }).displayName ?? "")
|
|
76
|
+
: undefined,
|
|
77
|
+
description: item.bodyPreview ? String(item.bodyPreview) : undefined,
|
|
78
|
+
all_day: Boolean(item.isAllDay),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function withBearer(
|
|
83
|
+
manager: OAuthTokenManager,
|
|
84
|
+
fetchImpl: typeof fetch,
|
|
85
|
+
url: string,
|
|
86
|
+
init: RequestInit,
|
|
87
|
+
extraHeaders?: Record<string, string>
|
|
88
|
+
): Promise<Response> {
|
|
89
|
+
const token = await manager.getAccessToken();
|
|
90
|
+
const headers = {
|
|
91
|
+
...(init.headers as Record<string, string> | undefined),
|
|
92
|
+
...extraHeaders,
|
|
93
|
+
authorization: `Bearer ${token}`,
|
|
94
|
+
};
|
|
95
|
+
let response = await fetchImpl(url, { ...init, headers });
|
|
96
|
+
if (response.status === 401) {
|
|
97
|
+
const fresh = await manager.forceRefresh();
|
|
98
|
+
response = await fetchImpl(url, {
|
|
99
|
+
...init,
|
|
100
|
+
headers: { ...headers, authorization: `Bearer ${fresh}` },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return response;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createGoogleCalendarProvider(
|
|
107
|
+
options: OAuthCalendarOptions
|
|
108
|
+
): CalendarProvider {
|
|
109
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
110
|
+
const manager =
|
|
111
|
+
options.tokenManager ??
|
|
112
|
+
new OAuthTokenManager({
|
|
113
|
+
provider: "google",
|
|
114
|
+
clientId: options.clientId,
|
|
115
|
+
clientSecret: options.clientSecret,
|
|
116
|
+
initialRefreshToken: options.refreshToken,
|
|
117
|
+
tokenFile: options.tokenFile,
|
|
118
|
+
fetchImpl,
|
|
119
|
+
logger: options.logger,
|
|
120
|
+
tokenStore: options.tokenStore,
|
|
121
|
+
onAuthorizationLost: options.onAuthorizationLost,
|
|
122
|
+
});
|
|
123
|
+
const calendarId = encodeURIComponent(options.calendarId.trim() || "primary");
|
|
124
|
+
let hydrated = false;
|
|
125
|
+
async function ensureHydrated(): Promise<void> {
|
|
126
|
+
if (hydrated) return;
|
|
127
|
+
await manager.hydrate();
|
|
128
|
+
hydrated = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
name: "google",
|
|
133
|
+
async listEvents(from, to, signal) {
|
|
134
|
+
await ensureHydrated();
|
|
135
|
+
const url = new URL(
|
|
136
|
+
`https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events`
|
|
137
|
+
);
|
|
138
|
+
url.searchParams.set("timeMin", from.toISOString());
|
|
139
|
+
url.searchParams.set("timeMax", to.toISOString());
|
|
140
|
+
url.searchParams.set("singleEvents", "true");
|
|
141
|
+
url.searchParams.set("orderBy", "startTime");
|
|
142
|
+
const response = await withBearer(manager, fetchImpl, url.toString(), {
|
|
143
|
+
signal,
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Google Calendar 列表失败:HTTP ${response.status} ${detail}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const data = (await response.json()) as {
|
|
152
|
+
items?: Record<string, unknown>[];
|
|
153
|
+
};
|
|
154
|
+
return (data.items ?? []).map((item) =>
|
|
155
|
+
mapGoogleEvent(item, options.timezone)
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
async createEvent(input, signal) {
|
|
159
|
+
await ensureHydrated();
|
|
160
|
+
const body = {
|
|
161
|
+
summary: input.title,
|
|
162
|
+
description: input.description,
|
|
163
|
+
location: input.location,
|
|
164
|
+
start: input.all_day
|
|
165
|
+
? { date: input.start.toISOString().slice(0, 10) }
|
|
166
|
+
: { dateTime: input.start.toISOString(), timeZone: options.timezone },
|
|
167
|
+
end: input.all_day
|
|
168
|
+
? { date: input.end.toISOString().slice(0, 10) }
|
|
169
|
+
: { dateTime: input.end.toISOString(), timeZone: options.timezone },
|
|
170
|
+
};
|
|
171
|
+
const response = await withBearer(
|
|
172
|
+
manager,
|
|
173
|
+
fetchImpl,
|
|
174
|
+
`https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events`,
|
|
175
|
+
{
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "content-type": "application/json" },
|
|
178
|
+
body: JSON.stringify(body),
|
|
179
|
+
signal,
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
if (!response.ok)
|
|
183
|
+
throw new Error(`Google Calendar 创建失败:HTTP ${response.status}`);
|
|
184
|
+
return mapGoogleEvent(
|
|
185
|
+
(await response.json()) as Record<string, unknown>,
|
|
186
|
+
options.timezone
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Default calendar when id empty / primary / me. */
|
|
193
|
+
export function microsoftCalendarBase(calendarId: string): string {
|
|
194
|
+
const id = calendarId.trim();
|
|
195
|
+
if (!id || id === "primary" || id === "me") {
|
|
196
|
+
return "https://graph.microsoft.com/v1.0/me/calendar";
|
|
197
|
+
}
|
|
198
|
+
return `https://graph.microsoft.com/v1.0/me/calendars/${encodeURIComponent(
|
|
199
|
+
id
|
|
200
|
+
)}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function createMicrosoftCalendarProvider(
|
|
204
|
+
options: OAuthCalendarOptions
|
|
205
|
+
): CalendarProvider {
|
|
206
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
207
|
+
const manager =
|
|
208
|
+
options.tokenManager ??
|
|
209
|
+
new OAuthTokenManager({
|
|
210
|
+
provider: "microsoft",
|
|
211
|
+
clientId: options.clientId,
|
|
212
|
+
clientSecret: options.clientSecret,
|
|
213
|
+
tenant: options.tenant,
|
|
214
|
+
initialRefreshToken: options.refreshToken,
|
|
215
|
+
tokenFile: options.tokenFile,
|
|
216
|
+
fetchImpl,
|
|
217
|
+
logger: options.logger,
|
|
218
|
+
tokenStore: options.tokenStore,
|
|
219
|
+
onAuthorizationLost: options.onAuthorizationLost,
|
|
220
|
+
});
|
|
221
|
+
const base = microsoftCalendarBase(options.calendarId);
|
|
222
|
+
let hydrated = false;
|
|
223
|
+
async function ensureHydrated(): Promise<void> {
|
|
224
|
+
if (hydrated) return;
|
|
225
|
+
await manager.hydrate();
|
|
226
|
+
hydrated = true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
name: "microsoft",
|
|
231
|
+
async listEvents(from, to, signal) {
|
|
232
|
+
await ensureHydrated();
|
|
233
|
+
const url = new URL(`${base}/calendarView`);
|
|
234
|
+
url.searchParams.set("startDateTime", from.toISOString());
|
|
235
|
+
url.searchParams.set("endDateTime", to.toISOString());
|
|
236
|
+
url.searchParams.set("$orderby", "start/dateTime");
|
|
237
|
+
url.searchParams.set("$top", "50");
|
|
238
|
+
const response = await withBearer(
|
|
239
|
+
manager,
|
|
240
|
+
fetchImpl,
|
|
241
|
+
url.toString(),
|
|
242
|
+
{ signal },
|
|
243
|
+
{
|
|
244
|
+
prefer: `outlook.timezone="${options.timezone}"`,
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
249
|
+
throw new Error(
|
|
250
|
+
`Microsoft Graph 列表失败:HTTP ${response.status} ${detail}`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const data = (await response.json()) as {
|
|
254
|
+
value?: Record<string, unknown>[];
|
|
255
|
+
};
|
|
256
|
+
return (data.value ?? []).map((item) =>
|
|
257
|
+
mapMicrosoftEvent(item, options.timezone)
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
async createEvent(input, signal) {
|
|
261
|
+
await ensureHydrated();
|
|
262
|
+
const body = {
|
|
263
|
+
subject: input.title,
|
|
264
|
+
body: input.description
|
|
265
|
+
? { contentType: "Text", content: input.description }
|
|
266
|
+
: undefined,
|
|
267
|
+
location: input.location ? { displayName: input.location } : undefined,
|
|
268
|
+
start: {
|
|
269
|
+
dateTime: input.start.toISOString().replace(/\.\d{3}Z$/, ""),
|
|
270
|
+
timeZone: "UTC",
|
|
271
|
+
},
|
|
272
|
+
end: {
|
|
273
|
+
dateTime: input.end.toISOString().replace(/\.\d{3}Z$/, ""),
|
|
274
|
+
timeZone: "UTC",
|
|
275
|
+
},
|
|
276
|
+
isAllDay: Boolean(input.all_day),
|
|
277
|
+
};
|
|
278
|
+
const response = await withBearer(
|
|
279
|
+
manager,
|
|
280
|
+
fetchImpl,
|
|
281
|
+
`${base}/events`,
|
|
282
|
+
{
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: { "content-type": "application/json" },
|
|
285
|
+
body: JSON.stringify(body),
|
|
286
|
+
signal,
|
|
287
|
+
},
|
|
288
|
+
{ prefer: `outlook.timezone="${options.timezone}"` }
|
|
289
|
+
);
|
|
290
|
+
if (!response.ok)
|
|
291
|
+
throw new Error(`Microsoft Graph 创建失败:HTTP ${response.status}`);
|
|
292
|
+
return mapMicrosoftEvent(
|
|
293
|
+
(await response.json()) as Record<string, unknown>,
|
|
294
|
+
options.timezone
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createToken } from "zhin.js";
|
|
2
|
+
import type { CalendarConfig } from "./config.js";
|
|
3
|
+
import type { CalendarProvider } from "./types.js";
|
|
4
|
+
import type { CalendarAuthService } from "./chat-auth.js";
|
|
5
|
+
|
|
6
|
+
export interface CalendarRuntime {
|
|
7
|
+
readonly config: CalendarConfig;
|
|
8
|
+
readonly provider: CalendarProvider | undefined;
|
|
9
|
+
/** Chat authorization flow used by the `calendar` commands. */
|
|
10
|
+
readonly auth: CalendarAuthService<unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const calendarRuntimeToken = createToken<CalendarRuntime>(
|
|
14
|
+
"zhin.calendar.runtime",
|
|
15
|
+
"Owner-scoped calendar provider and config"
|
|
16
|
+
);
|
package/src/secrets.ts
ADDED
package/src/time.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timezone helpers built on Intl (no dependencies).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ZonedParts {
|
|
6
|
+
year: number;
|
|
7
|
+
month: number;
|
|
8
|
+
day: number;
|
|
9
|
+
hour: number;
|
|
10
|
+
minute: number;
|
|
11
|
+
second: number;
|
|
12
|
+
weekday: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
|
16
|
+
|
|
17
|
+
function formatterFor(timeZone: string): Intl.DateTimeFormat {
|
|
18
|
+
let formatter = formatterCache.get(timeZone);
|
|
19
|
+
if (!formatter) {
|
|
20
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
21
|
+
timeZone,
|
|
22
|
+
hourCycle: "h23",
|
|
23
|
+
year: "numeric",
|
|
24
|
+
month: "numeric",
|
|
25
|
+
day: "numeric",
|
|
26
|
+
hour: "numeric",
|
|
27
|
+
minute: "numeric",
|
|
28
|
+
second: "numeric",
|
|
29
|
+
weekday: "short",
|
|
30
|
+
});
|
|
31
|
+
formatterCache.set(timeZone, formatter);
|
|
32
|
+
}
|
|
33
|
+
return formatter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const WEEKDAYS: Readonly<Record<string, number>> = {
|
|
37
|
+
Sun: 0,
|
|
38
|
+
Mon: 1,
|
|
39
|
+
Tue: 2,
|
|
40
|
+
Wed: 3,
|
|
41
|
+
Thu: 4,
|
|
42
|
+
Fri: 5,
|
|
43
|
+
Sat: 6,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Throws a readable error for unknown IANA zones. */
|
|
47
|
+
export function assertTimeZone(timeZone: string): void {
|
|
48
|
+
try {
|
|
49
|
+
formatterFor(timeZone);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`未知时区:${timeZone}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wall-clock parts of `date` in `timeZone`. */
|
|
56
|
+
export function zonedParts(date: Date, timeZone: string): ZonedParts {
|
|
57
|
+
const parts: Record<string, string> = {};
|
|
58
|
+
for (const part of formatterFor(timeZone).formatToParts(date))
|
|
59
|
+
parts[part.type] = part.value;
|
|
60
|
+
return {
|
|
61
|
+
year: Number(parts.year),
|
|
62
|
+
month: Number(parts.month),
|
|
63
|
+
day: Number(parts.day),
|
|
64
|
+
hour: Number(parts.hour),
|
|
65
|
+
minute: Number(parts.minute),
|
|
66
|
+
second: Number(parts.second),
|
|
67
|
+
weekday: WEEKDAYS[parts.weekday ?? "Sun"] ?? 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Offset (ms) of `timeZone` from UTC at instant `date`. */
|
|
72
|
+
export function timeZoneOffsetMs(date: Date, timeZone: string): number {
|
|
73
|
+
const parts = zonedParts(date, timeZone);
|
|
74
|
+
const asUtc = Date.UTC(
|
|
75
|
+
parts.year,
|
|
76
|
+
parts.month - 1,
|
|
77
|
+
parts.day,
|
|
78
|
+
parts.hour,
|
|
79
|
+
parts.minute,
|
|
80
|
+
parts.second
|
|
81
|
+
);
|
|
82
|
+
return asUtc - Math.floor(date.getTime() / 1000) * 1000;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Converts a wall-clock time in `timeZone` to a UTC instant. */
|
|
86
|
+
export function zonedTimeToUtc(
|
|
87
|
+
wall: {
|
|
88
|
+
year: number;
|
|
89
|
+
month: number;
|
|
90
|
+
day: number;
|
|
91
|
+
hour: number;
|
|
92
|
+
minute: number;
|
|
93
|
+
second?: number;
|
|
94
|
+
},
|
|
95
|
+
timeZone: string
|
|
96
|
+
): Date {
|
|
97
|
+
const guess = Date.UTC(
|
|
98
|
+
wall.year,
|
|
99
|
+
wall.month - 1,
|
|
100
|
+
wall.day,
|
|
101
|
+
wall.hour,
|
|
102
|
+
wall.minute,
|
|
103
|
+
wall.second ?? 0
|
|
104
|
+
);
|
|
105
|
+
const first = guess - timeZoneOffsetMs(new Date(guess), timeZone);
|
|
106
|
+
const second = guess - timeZoneOffsetMs(new Date(first), timeZone);
|
|
107
|
+
return new Date(second);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Formats an instant as `YYYY-MM-DD HH:mm` in `timeZone`. */
|
|
111
|
+
export function formatInZone(date: Date, timeZone: string): string {
|
|
112
|
+
const p = zonedParts(date, timeZone);
|
|
113
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
114
|
+
return `${p.year}-${pad(p.month)}-${pad(p.day)} ${pad(p.hour)}:${pad(
|
|
115
|
+
p.minute
|
|
116
|
+
)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parses an ISO-8601 date-time. Without an explicit offset/`Z`, the value is
|
|
121
|
+
* interpreted as wall-clock time in `timeZone`.
|
|
122
|
+
*/
|
|
123
|
+
export function parseDateTimeInZone(value: string, timeZone: string): Date {
|
|
124
|
+
const text = value.trim();
|
|
125
|
+
if (/([zZ]|[+-]\d{2}:?\d{2})$/.test(text)) {
|
|
126
|
+
const date = new Date(text);
|
|
127
|
+
if (Number.isNaN(date.getTime())) throw new Error(`无法解析时间:${value}`);
|
|
128
|
+
return date;
|
|
129
|
+
}
|
|
130
|
+
const match =
|
|
131
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(text);
|
|
132
|
+
if (!match)
|
|
133
|
+
throw new Error(`无法解析时间:${value}(请用 YYYY-MM-DDTHH:mm 格式)`);
|
|
134
|
+
return zonedTimeToUtc(
|
|
135
|
+
{
|
|
136
|
+
year: Number(match[1]),
|
|
137
|
+
month: Number(match[2]),
|
|
138
|
+
day: Number(match[3]),
|
|
139
|
+
hour: Number(match[4] ?? 0),
|
|
140
|
+
minute: Number(match[5] ?? 0),
|
|
141
|
+
second: Number(match[6] ?? 0),
|
|
142
|
+
},
|
|
143
|
+
timeZone
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Parses durations like `10m`, `2h30m`, `1d`, `90s`. */
|
|
148
|
+
export function parseDuration(value: string): number {
|
|
149
|
+
const text = value.trim().toLowerCase();
|
|
150
|
+
const pattern = /(\d+)\s*(d|h|m|s)/g;
|
|
151
|
+
let total = 0;
|
|
152
|
+
let consumed = "";
|
|
153
|
+
for (const match of text.matchAll(pattern)) {
|
|
154
|
+
const amount = Number(match[1]);
|
|
155
|
+
const unit = match[2]!;
|
|
156
|
+
total +=
|
|
157
|
+
amount *
|
|
158
|
+
{ d: 86400000, h: 3600000, m: 60000, s: 1000 }[
|
|
159
|
+
unit as "d" | "h" | "m" | "s"
|
|
160
|
+
];
|
|
161
|
+
consumed += match[0];
|
|
162
|
+
}
|
|
163
|
+
if (total <= 0 || consumed.replace(/\s/g, "") !== text.replace(/\s/g, "")) {
|
|
164
|
+
throw new Error(`无法解析时长:${value}(示例:10m、2h30m、1d)`);
|
|
165
|
+
}
|
|
166
|
+
return total;
|
|
167
|
+
}
|