dsh-calendar 0.5.4 → 0.7.0

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/lib/web.d.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * dsh-calendar 的网页端后端:给设置页里的日历面板提供数据与写操作。
3
+ *
4
+ * 与 dsh-email 的设置路由同一套安全模型,理由也一样:这个路由能读用户的
5
+ * 全部日程、还能替用户改/删日程,而宿主 webserver 有可能绑定在 0.0.0.0。
6
+ * 四道门缺一不可 ——
7
+ * 1. remoteAddress 必须是回环地址(局域网不可达);
8
+ * 2. Host 头必须是 localhost 名(挡 DNS rebinding:恶意域名解析到 127.0.0.1);
9
+ * 3. 写操作要求 application/json(跨源页面无法用简单请求绕过预检)+
10
+ * Origin/Sec-Fetch-Site 校验(挡 Origin: null 的沙箱 iframe);
11
+ * 4. 响应里永不回显凭证(服务对象只在闭包里,事件对象本来也不含密码)。
12
+ *
13
+ * 面板要的是「事件列表 + 增删改」,所以这一层不做自己的状态:每次请求都问
14
+ * CalDAV 服务器要,服务器就是唯一真相。缓存的只有 service 实例(token 复用)。
15
+ */
16
+ import { type CalendarConfig } from './config.js';
17
+ import { type CalendarSettingsValue } from './settings.js';
18
+ import type { CalendarEvent } from './ical.js';
19
+ /** 面板与浏览器说话的同源路由。 */
20
+ export declare const SETTINGS_ROUTE = "/_dsh/dsh-calendar/settings";
21
+ /** Host 头裁决:undefined = 放行,否则给出拒绝原因。 */
22
+ export declare function hostVerdict(host: unknown): string | undefined;
23
+ /** 写操作裁决:undefined = 放行,否则给出状态码与原因。 */
24
+ export declare function postVerdict(headers: Record<string, unknown>): {
25
+ status: number;
26
+ message: string;
27
+ } | undefined;
28
+ /** 面板需要的服务面;测试里可以塞假的进来。 */
29
+ export interface CalendarServiceLike {
30
+ list(start: string, end: string, options?: unknown, signal?: AbortSignal): Promise<CalendarEvent[]>;
31
+ create(fields: unknown, signal?: AbortSignal): Promise<CalendarEvent>;
32
+ update(uid: string, changes: unknown, signal?: AbortSignal): Promise<CalendarEvent>;
33
+ delete(uid: string, signal?: AbortSignal): Promise<{
34
+ uid: string;
35
+ href: string;
36
+ }>;
37
+ }
38
+ /** 面板用于读写 settings 命名空间的最小面(index.ts 在 settings 服务到位后接上)。 */
39
+ export interface CalendarSettingsFace {
40
+ /** 'settings' = 宿主命名空间;'file' = 插件自己的兜底文件。 */
41
+ kind?: 'settings' | 'file';
42
+ read(): Record<string, unknown>;
43
+ descriptor(): {
44
+ revision?: number;
45
+ user?: Record<string, unknown>;
46
+ } | undefined;
47
+ replace(value: Record<string, unknown>, revision: number): Promise<void>;
48
+ }
49
+ /**
50
+ * 由一个「宿主 settings provider + 可选 scope」拼出面板要的读写面。
51
+ *
52
+ * 注册**不在这里**:宿主的 register 内部会 `ctx.effect(...)`,必须发生在插件加载的同步阶段;
53
+ * 在请求处理里调用它会失败(此前就这样:看着「接上了」,真写的时候报 namespace is not registered)。
54
+ */
55
+ export declare function settingsFaceOf(provider: any, scope: any, ns?: string): CalendarSettingsFace;
56
+ /** 面板要读写的连接字段。空串 = 保持不变,null = 明确清除。 */
57
+ export declare const CONNECTION_KEYS: readonly ["provider", "caldavUrl", "username", "password", "host", "user", "calendar", "calendarId", "authMethod", "clientId", "clientSecret", "refreshToken", "tokenUrl", "proxyUrl"];
58
+ export interface CalendarSettingsBackendOptions {
59
+ /** 静态配置,或一个 getter(面板保存后工具应当立刻用上新配置)。 */
60
+ config?: CalendarConfig | null | (() => CalendarConfig);
61
+ /** 测试用:替换真实 CalDAV 客户端(收到的是当前/拟议配置)。 */
62
+ serviceFactory?: (config: CalendarConfig) => CalendarServiceLike;
63
+ /** 测试用:替换「保存前的连接测试」。 */
64
+ probeFactory?: (config: CalendarConfig) => Promise<{
65
+ count: number;
66
+ sample: string[];
67
+ }>;
68
+ env?: NodeJS.ProcessEnv;
69
+ settings?: CalendarSettingsFace;
70
+ /** 宿主 ctx:用来懒接入 settings 服务(不赌子 fiber 的时序)。 */
71
+ ctx?: any;
72
+ /** settings 值变化时回调(index.ts 用它把新配置喂给工具层)。 */
73
+ onSettings?: (value: Record<string, unknown>) => void;
74
+ /** 注册命名空间时的 base 层(来自 cordis.patch.yml 的配置)。 */
75
+ settingsBase?: Partial<CalendarSettingsValue>;
76
+ /** 为什么没接上宿主 settings(面板要把它显示出来,而不是一句「不可用」)。 */
77
+ settingsReason?: string;
78
+ }
79
+ /** 把「已存值 + 草稿」合并成要落盘的一版:草稿里缺席的键保留原值。 */
80
+ export declare function mergeConnection(stored: Record<string, unknown>, draft: Record<string, unknown>): Record<string, unknown>;
81
+ /**
82
+ * 浏览器端后端:把面板的四个动作翻译成 CalDAV 调用。
83
+ *
84
+ * 配置缺失不抛错:面板需要它来渲染「还没配置」的引导态,所以 status 动作
85
+ * 会回一个 configured:false,而不是让页面拿到 500。
86
+ */
87
+ export declare class CalendarSettingsBackend {
88
+ private readonly options;
89
+ private cachedKey;
90
+ private cachedService;
91
+ private settings;
92
+ constructor(options?: CalendarSettingsBackendOptions);
93
+ /** settings 服务到位后接上(传 undefined 摘掉)—— 面板据此从只读变成可保存。 */
94
+ attachSettings(face: CalendarSettingsFace | undefined): void;
95
+ /**
96
+ * 兜底接入:注册由 index.ts 在插件加载时同步完成(那里能拿到活动作用域)。
97
+ * 这里只在「还没接上」时搭一次既有注册的车 —— 例如插件被重复 apply 时第二个实例,
98
+ * 它没资格注册,但可以照常读写(值从 describe() 描述符来,写走 provider.replace)。
99
+ */
100
+ private ensureSettings;
101
+ /**
102
+ * 当前生效配置:patch 行配置(或 getter 现取的那份)+ 面板写进 settings 的字段。
103
+ *
104
+ * 面板自己也要合并一遍,而不是只依赖传入的 getter —— 否则「面板显示的连接」
105
+ * 与「工具实际用的连接」就成了两份来源,保存成功却看到未配置的怪象。
106
+ */
107
+ private configOf;
108
+ /** 惰性解析配置;凭据/端点变化即重建(闭包内比较,不落盘不记日志)。 */
109
+ private service;
110
+ /**
111
+ * 连接摘要:面板要的每一个非敏感字段都在这里,密钥只回「有没有」。
112
+ * 未配置时 configured:false + reason(resolveConfig 的中文指引),面板据此渲染引导态。
113
+ */
114
+ connection(): Record<string, unknown>;
115
+ /** 兼容旧动作名:status 就是连接摘要。 */
116
+ status(): Record<string, unknown>;
117
+ /**
118
+ * 草稿 → 拟议配置:先与已存值合并(草稿缺席的键保留原值),再叠加到当前生效配置之上。
119
+ * 连接测试和保存都走这条路,所以「测试通过」与「保存后能用」看到的是同一份配置。
120
+ */
121
+ private proposed;
122
+ /** 真连一次:拿拟议配置列一下未来 30 天,能列出来就算通。 */
123
+ private probe;
124
+ private responseJson;
125
+ /** 路由入口。GET = 读状态,POST = 动作(list/create/update/delete)。 */
126
+ handle(req: any, res: any): Promise<void>;
127
+ /** 动作分发。每个动作自己校验参数,错误信息面向用户(中文,面板直接显示)。 */
128
+ private dispatch;
129
+ }
130
+ /**
131
+ * 把设置页里的日历面板挂到宿主 webserver 上。
132
+ * 与 dsh-email 相同:ctx.inject(['webServer']) + effect 注册,插件卸载即摘掉路由。
133
+ */
134
+ export declare function installCalendarSettingsWeb(ctx: any, backend: CalendarSettingsBackend): void;
package/lib/web.js ADDED
@@ -0,0 +1,453 @@
1
+ /**
2
+ * dsh-calendar 的网页端后端:给设置页里的日历面板提供数据与写操作。
3
+ *
4
+ * 与 dsh-email 的设置路由同一套安全模型,理由也一样:这个路由能读用户的
5
+ * 全部日程、还能替用户改/删日程,而宿主 webserver 有可能绑定在 0.0.0.0。
6
+ * 四道门缺一不可 ——
7
+ * 1. remoteAddress 必须是回环地址(局域网不可达);
8
+ * 2. Host 头必须是 localhost 名(挡 DNS rebinding:恶意域名解析到 127.0.0.1);
9
+ * 3. 写操作要求 application/json(跨源页面无法用简单请求绕过预检)+
10
+ * Origin/Sec-Fetch-Site 校验(挡 Origin: null 的沙箱 iframe);
11
+ * 4. 响应里永不回显凭证(服务对象只在闭包里,事件对象本来也不含密码)。
12
+ *
13
+ * 面板要的是「事件列表 + 增删改」,所以这一层不做自己的状态:每次请求都问
14
+ * CalDAV 服务器要,服务器就是唯一真相。缓存的只有 service 实例(token 复用)。
15
+ */
16
+ import { resolveConfig } from './config.js';
17
+ import { SETTINGS_NAMESPACE, draftToConfig, validateSettingsValue } from './settings.js';
18
+ import { CalendarService } from './caldav.js';
19
+ import { asRecord, assertIsoTime, assertTimeRange, isoNoMillis, optionalString, sortEvents } from './tools.js';
20
+ /** 面板与浏览器说话的同源路由。 */
21
+ export const SETTINGS_ROUTE = '/_dsh/dsh-calendar/settings';
22
+ /** 回环主机名白名单;带端口会被拆掉,IPv6 字面量保留方括号。 */
23
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
24
+ /** Host 头裁决:undefined = 放行,否则给出拒绝原因。 */
25
+ export function hostVerdict(host) {
26
+ if (typeof host !== 'string' || host.trim() === '')
27
+ return undefined;
28
+ const text = host.trim().toLowerCase();
29
+ const name = text.startsWith('[') ? text.slice(0, text.indexOf(']') + 1) : text.split(':')[0];
30
+ return LOCAL_HOSTNAMES.has(name) ? undefined : `host "${host}" is not a localhost name`;
31
+ }
32
+ /** 写操作裁决:undefined = 放行,否则给出状态码与原因。 */
33
+ export function postVerdict(headers) {
34
+ const contentType = String(headers['content-type'] ?? '');
35
+ if (!contentType.toLowerCase().includes('application/json')) {
36
+ return { status: 415, message: 'dsh-calendar settings route accepts application/json only' };
37
+ }
38
+ const origin = headers.origin;
39
+ if (typeof origin === 'string' && origin.trim() !== '') {
40
+ let url;
41
+ try {
42
+ url = new URL(origin);
43
+ }
44
+ catch {
45
+ url = undefined;
46
+ }
47
+ if (url !== undefined && (url.protocol === 'http:' || url.protocol === 'https:')
48
+ && !LOCAL_HOSTNAMES.has(url.hostname.toLowerCase())) {
49
+ return { status: 403, message: `origin "${origin}" is not allowed to write dsh-calendar events` };
50
+ }
51
+ }
52
+ const site = headers['sec-fetch-site'];
53
+ if (typeof site === 'string' && site !== '' && site !== 'same-origin' && site !== 'none') {
54
+ return { status: 403, message: `a ${site} request may not write dsh-calendar events` };
55
+ }
56
+ return undefined;
57
+ }
58
+ /**
59
+ * 由一个「宿主 settings provider + 可选 scope」拼出面板要的读写面。
60
+ *
61
+ * 注册**不在这里**:宿主的 register 内部会 `ctx.effect(...)`,必须发生在插件加载的同步阶段;
62
+ * 在请求处理里调用它会失败(此前就这样:看着「接上了」,真写的时候报 namespace is not registered)。
63
+ */
64
+ export function settingsFaceOf(provider, scope, ns = SETTINGS_NAMESPACE) {
65
+ const findRow = () => (typeof provider.describe === 'function' ? provider.describe() ?? [] : [])
66
+ .find((row) => row !== undefined && row !== null && row.ns === ns);
67
+ return {
68
+ kind: 'settings',
69
+ read: () => {
70
+ if (scope !== undefined && scope !== null && typeof scope.get === 'function') {
71
+ const value = scope.get();
72
+ if (value !== undefined && value !== null)
73
+ return value;
74
+ }
75
+ const row = findRow();
76
+ const value = row === undefined ? undefined : (row.value ?? row.user);
77
+ return (value ?? {});
78
+ },
79
+ descriptor: () => findRow(),
80
+ replace: async (value, revision) => {
81
+ await provider.replace(ns, value, revision);
82
+ },
83
+ };
84
+ }
85
+ /** 面板要读写的连接字段。空串 = 保持不变,null = 明确清除。 */
86
+ export const CONNECTION_KEYS = ['provider', 'caldavUrl', 'username', 'password', 'host', 'user', 'calendar', 'calendarId', 'authMethod', 'clientId', 'clientSecret', 'refreshToken', 'tokenUrl', 'proxyUrl'];
87
+ /** 把「已存值 + 草稿」合并成要落盘的一版:草稿里缺席的键保留原值。 */
88
+ export function mergeConnection(stored, draft) {
89
+ const out = {};
90
+ for (const key of CONNECTION_KEYS) {
91
+ const value = stored[key];
92
+ if (typeof value === 'string' && value !== '')
93
+ out[key] = value;
94
+ }
95
+ for (const key of CONNECTION_KEYS) {
96
+ if (!(key in draft))
97
+ continue;
98
+ const value = draft[key];
99
+ if (value === null) {
100
+ delete out[key];
101
+ continue;
102
+ }
103
+ if (typeof value !== 'string')
104
+ continue;
105
+ if (value === '')
106
+ continue;
107
+ out[key] = value;
108
+ }
109
+ return out;
110
+ }
111
+ /**
112
+ * 浏览器端后端:把面板的四个动作翻译成 CalDAV 调用。
113
+ *
114
+ * 配置缺失不抛错:面板需要它来渲染「还没配置」的引导态,所以 status 动作
115
+ * 会回一个 configured:false,而不是让页面拿到 500。
116
+ */
117
+ export class CalendarSettingsBackend {
118
+ options;
119
+ cachedKey;
120
+ cachedService;
121
+ settings;
122
+ constructor(options = {}) {
123
+ this.options = options;
124
+ this.settings = options.settings;
125
+ }
126
+ /** settings 服务到位后接上(传 undefined 摘掉)—— 面板据此从只读变成可保存。 */
127
+ attachSettings(face) {
128
+ this.settings = face;
129
+ }
130
+ /**
131
+ * 兜底接入:注册由 index.ts 在插件加载时同步完成(那里能拿到活动作用域)。
132
+ * 这里只在「还没接上」时搭一次既有注册的车 —— 例如插件被重复 apply 时第二个实例,
133
+ * 它没资格注册,但可以照常读写(值从 describe() 描述符来,写走 provider.replace)。
134
+ */
135
+ ensureSettings() {
136
+ if (this.settings !== undefined)
137
+ return;
138
+ const ctx = this.options.ctx;
139
+ if (ctx === undefined || ctx === null)
140
+ return;
141
+ let provider;
142
+ try {
143
+ provider = typeof ctx.get === 'function' ? ctx.get('settings') : undefined;
144
+ }
145
+ catch (error) {
146
+ provider = undefined;
147
+ }
148
+ if (provider === undefined || provider === null) {
149
+ try {
150
+ provider = ctx.settings;
151
+ }
152
+ catch (error) {
153
+ provider = undefined;
154
+ }
155
+ }
156
+ if (provider === undefined || provider === null || typeof provider.replace !== 'function')
157
+ return;
158
+ // 只有描述符里真的出现了我们这个命名空间,才说明有人注册过 —— 否则写也是白写。
159
+ const known = (typeof provider.describe === 'function' ? provider.describe() ?? [] : []).some((row) => row !== undefined && row !== null && row.ns === SETTINGS_NAMESPACE);
160
+ if (known !== true)
161
+ return;
162
+ this.attachSettings(settingsFaceOf(provider, undefined));
163
+ }
164
+ /**
165
+ * 当前生效配置:patch 行配置(或 getter 现取的那份)+ 面板写进 settings 的字段。
166
+ *
167
+ * 面板自己也要合并一遍,而不是只依赖传入的 getter —— 否则「面板显示的连接」
168
+ * 与「工具实际用的连接」就成了两份来源,保存成功却看到未配置的怪象。
169
+ */
170
+ configOf() {
171
+ this.ensureSettings();
172
+ const source = this.options.config;
173
+ const base = (typeof source === 'function' ? source() : source) ?? {};
174
+ const stored = this.settings?.read?.();
175
+ if (stored === undefined || stored === null)
176
+ return base;
177
+ return { ...base, ...draftToConfig(stored) };
178
+ }
179
+ /** 惰性解析配置;凭据/端点变化即重建(闭包内比较,不落盘不记日志)。 */
180
+ service() {
181
+ const current = this.configOf();
182
+ if (this.options.serviceFactory !== undefined)
183
+ return this.options.serviceFactory(current);
184
+ const resolved = resolveConfig(current, this.options.env ?? process.env);
185
+ const key = JSON.stringify(resolved);
186
+ if (this.cachedService === undefined || this.cachedKey !== key) {
187
+ this.cachedService = new CalendarService(resolved);
188
+ this.cachedKey = key;
189
+ }
190
+ return this.cachedService;
191
+ }
192
+ /**
193
+ * 连接摘要:面板要的每一个非敏感字段都在这里,密钥只回「有没有」。
194
+ * 未配置时 configured:false + reason(resolveConfig 的中文指引),面板据此渲染引导态。
195
+ */
196
+ connection() {
197
+ const stored = (this.settings?.read?.() ?? {});
198
+ const effective = this.configOf();
199
+ const descriptor = this.settings?.descriptor?.();
200
+ let configured = false;
201
+ let reason;
202
+ try {
203
+ resolveConfig(effective, this.options.env ?? process.env);
204
+ configured = true;
205
+ }
206
+ catch (error) {
207
+ reason = error instanceof Error ? error.message : String(error);
208
+ }
209
+ const text = (key) => {
210
+ const value = stored[key];
211
+ if (typeof value === 'string' && value !== '')
212
+ return value;
213
+ const resolved = effective[key];
214
+ return typeof resolved === 'string' ? resolved : '';
215
+ };
216
+ return {
217
+ configured,
218
+ ...(reason !== undefined ? { reason } : {}),
219
+ settingsAvailable: this.settings !== undefined,
220
+ ...(this.settings?.kind !== undefined ? { settingsKind: this.settings.kind } : {}),
221
+ ...(this.options.settingsReason !== undefined ? { settingsReason: this.options.settingsReason } : {}),
222
+ revision: descriptor?.revision ?? 0,
223
+ provider: text('provider'),
224
+ caldavUrl: text('caldavUrl'),
225
+ username: text('username'),
226
+ host: text('host'),
227
+ user: text('user'),
228
+ calendar: text('calendar'),
229
+ calendarId: text('calendarId'),
230
+ authMethod: text('authMethod'),
231
+ clientId: text('clientId'),
232
+ tokenUrl: text('tokenUrl'),
233
+ proxyUrl: text('proxyUrl'),
234
+ hasPassword: text('password') !== '' || (effective.password ?? '') !== '',
235
+ hasClientSecret: text('clientSecret') !== '' || (effective.clientSecret ?? '') !== '',
236
+ hasRefreshToken: text('refreshToken') !== '' || (effective.refreshToken ?? '') !== '',
237
+ };
238
+ }
239
+ /** 兼容旧动作名:status 就是连接摘要。 */
240
+ status() {
241
+ return this.connection();
242
+ }
243
+ /**
244
+ * 草稿 → 拟议配置:先与已存值合并(草稿缺席的键保留原值),再叠加到当前生效配置之上。
245
+ * 连接测试和保存都走这条路,所以「测试通过」与「保存后能用」看到的是同一份配置。
246
+ */
247
+ proposed(draft) {
248
+ const storedValue = (this.settings?.read?.() ?? {});
249
+ const stored = mergeConnection(storedValue, draft);
250
+ return { stored, config: { ...this.configOf(), ...draftToConfig(stored) } };
251
+ }
252
+ /** 真连一次:拿拟议配置列一下未来 30 天,能列出来就算通。 */
253
+ async probe(config) {
254
+ if (this.options.probeFactory !== undefined)
255
+ return await this.options.probeFactory(config);
256
+ const service = this.options.serviceFactory !== undefined
257
+ ? this.options.serviceFactory(config)
258
+ : new CalendarService(resolveConfig(config, this.options.env ?? process.env));
259
+ const now = new Date();
260
+ const events = await service.list(isoNoMillis(now.toISOString()), isoNoMillis(new Date(now.getTime() + 30 * 86400000).toISOString()), { expand: false, maxOccurrences: 5 });
261
+ return { count: events.length, sample: events.slice(0, 3).map((event) => event.summary) };
262
+ }
263
+ responseJson(res, status, payload) {
264
+ const text = JSON.stringify(payload);
265
+ if (typeof res.writeHead === 'function') {
266
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
267
+ }
268
+ if (typeof res.end === 'function')
269
+ res.end(text);
270
+ }
271
+ /** 路由入口。GET = 读状态,POST = 动作(list/create/update/delete)。 */
272
+ async handle(req, res) {
273
+ // 每次请求前试一次:settings 服务晚到、或上一个实例刚卸载,都能在这一刻补上。
274
+ this.ensureSettings();
275
+ const remote = String(req.socket?.remoteAddress ?? '');
276
+ if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') {
277
+ this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-calendar settings route is localhost-only' } });
278
+ return;
279
+ }
280
+ const headers = (req.headers ?? {});
281
+ const host = hostVerdict(headers.host);
282
+ if (host !== undefined) {
283
+ this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: host } });
284
+ return;
285
+ }
286
+ if (req.method !== 'POST') {
287
+ this.responseJson(res, 200, { ok: true, value: this.status() });
288
+ return;
289
+ }
290
+ const post = postVerdict(headers);
291
+ if (post !== undefined) {
292
+ this.responseJson(res, post.status, {
293
+ ok: false,
294
+ error: { code: post.status === 415 ? 'unsupported-media-type' : 'forbidden', message: post.message },
295
+ });
296
+ return;
297
+ }
298
+ try {
299
+ const body = await readJsonBody(req);
300
+ const value = await this.dispatch(asRecord(body));
301
+ this.responseJson(res, 200, { ok: true, value });
302
+ }
303
+ catch (error) {
304
+ const message = error instanceof Error ? error.message : String(error);
305
+ this.responseJson(res, 400, { ok: false, error: { code: 'rejected', message } });
306
+ }
307
+ }
308
+ /** 动作分发。每个动作自己校验参数,错误信息面向用户(中文,面板直接显示)。 */
309
+ async dispatch(body) {
310
+ const action = typeof body.action === 'string' ? body.action : '';
311
+ if (action === 'status' || action === 'connection')
312
+ return this.connection();
313
+ if (action === 'testConnection') {
314
+ const { config } = this.proposed(connectionDraft(body));
315
+ const probe = await this.probe(config);
316
+ return { ok: true, ...probe, connection: this.connection() };
317
+ }
318
+ if (action === 'saveConnection') {
319
+ const { stored, config } = this.proposed(connectionDraft(body));
320
+ // 校验的是「落盘后的形状」:只有它同时覆盖了 YAML 兜底与面板新填的字段。
321
+ validateSettingsValue(stored);
322
+ // 默认先测再存:一个连不上的地址不该被写进 settings 再让人去猜哪里错了。
323
+ const probe = body.test === false ? undefined : await this.probe(config);
324
+ const face = this.settings;
325
+ if (face === undefined) {
326
+ throw new Error('当前宿主的 settings 服务不可用:请在 profile 的 cordis.patch.yml 里配置 dsh-calendar 后重启');
327
+ }
328
+ await face.replace(stored, face.descriptor()?.revision ?? 0);
329
+ try {
330
+ this.options.onSettings?.(stored);
331
+ }
332
+ catch (error) { /* 同上 */ }
333
+ return { saved: true, ...(probe !== undefined ? { probe } : {}), connection: this.connection() };
334
+ }
335
+ if (action === 'list') {
336
+ const now = new Date();
337
+ const start = optionalString(body, 'start') ?? isoNoMillis(now.toISOString());
338
+ const end = optionalString(body, 'end') ?? isoNoMillis(new Date(now.getTime() + 7 * 86400000).toISOString());
339
+ assertIsoTime(start, 'start');
340
+ assertIsoTime(end, 'end');
341
+ assertTimeRange(start, end);
342
+ const events = sortEvents(await this.service().list(start, end, {
343
+ expand: body.expand !== false,
344
+ maxOccurrences: clampInt(body.maxOccurrences, 200, 1, 500),
345
+ }));
346
+ return { count: events.length, start, end, events };
347
+ }
348
+ if (action === 'create') {
349
+ const fields = eventFields(body, true);
350
+ return { event: await this.service().create(fields) };
351
+ }
352
+ if (action === 'update') {
353
+ const uid = requireString(body, 'uid', '缺少要修改的事件 uid');
354
+ return { event: await this.service().update(uid, eventFields(body, false)) };
355
+ }
356
+ if (action === 'delete') {
357
+ const uid = requireString(body, 'uid', '缺少要删除的事件 uid');
358
+ return await this.service().delete(uid);
359
+ }
360
+ throw new Error('未知动作:' + (action === '' ? '(空)' : action));
361
+ }
362
+ }
363
+ /** POST body 读取:上限 256 KiB,超限或非 JSON 直接拒绝。 */
364
+ async function readJsonBody(req) {
365
+ const chunks = [];
366
+ let size = 0;
367
+ for await (const chunk of req) {
368
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
369
+ size += buf.length;
370
+ if (size > 256 * 1024)
371
+ throw new Error('请求体过大');
372
+ chunks.push(buf);
373
+ }
374
+ const text = Buffer.concat(chunks).toString('utf8').trim();
375
+ if (text === '')
376
+ return {};
377
+ try {
378
+ return JSON.parse(text);
379
+ }
380
+ catch {
381
+ throw new Error('请求体不是合法 JSON');
382
+ }
383
+ }
384
+ /** 从请求体里挑出连接字段:只认 CONNECTION_KEYS,字符串原样、null 表示清除。 */
385
+ function connectionDraft(body) {
386
+ const draft = {};
387
+ for (const key of CONNECTION_KEYS) {
388
+ if (!(key in body))
389
+ continue;
390
+ const value = body[key];
391
+ if (value === null || typeof value === 'string')
392
+ draft[key] = value;
393
+ }
394
+ return draft;
395
+ }
396
+ function requireString(body, key, message) {
397
+ const value = optionalString(body, key);
398
+ if (value === undefined || value === '')
399
+ throw new Error(message);
400
+ return value;
401
+ }
402
+ /** 把面板/工具传来的字段整理成 EventFields;create 时 summary/start/end 必填。 */
403
+ function eventFields(body, requireAll) {
404
+ const fields = {};
405
+ const summary = optionalString(body, 'summary');
406
+ if (summary !== undefined) {
407
+ if (summary === '')
408
+ throw new Error('标题不能为空');
409
+ fields.summary = summary;
410
+ }
411
+ else if (requireAll) {
412
+ throw new Error('缺少标题(summary)');
413
+ }
414
+ for (const key of ['start', 'end']) {
415
+ const value = optionalString(body, key);
416
+ if (value !== undefined) {
417
+ assertIsoTime(value, key);
418
+ fields[key] = value;
419
+ }
420
+ else if (requireAll) {
421
+ throw new Error('缺少' + (key === 'start' ? '开始时间' : '结束时间'));
422
+ }
423
+ }
424
+ if (typeof fields.start === 'string' && typeof fields.end === 'string')
425
+ assertTimeRange(fields.start, fields.end);
426
+ for (const key of ['description', 'location', 'rrule']) {
427
+ const value = body[key];
428
+ if (typeof value === 'string')
429
+ fields[key] = value;
430
+ }
431
+ if (typeof body.allDay === 'boolean')
432
+ fields.allDay = body.allDay;
433
+ return fields;
434
+ }
435
+ function clampInt(value, fallback, min, max) {
436
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback;
437
+ return Math.min(max, Math.max(min, n));
438
+ }
439
+ /**
440
+ * 把设置页里的日历面板挂到宿主 webserver 上。
441
+ * 与 dsh-email 相同:ctx.inject(['webServer']) + effect 注册,插件卸载即摘掉路由。
442
+ */
443
+ export function installCalendarSettingsWeb(ctx, backend) {
444
+ ctx.inject(['webServer'], (webCtx) => {
445
+ webCtx.effect(() => {
446
+ return webCtx.webServer.register({
447
+ kind: 'exact',
448
+ path: SETTINGS_ROUTE,
449
+ handler: (req, res) => backend.handle(req, res),
450
+ });
451
+ }, 'dsh-calendar: web routes');
452
+ });
453
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-calendar",
3
- "version": "0.5.4",
3
+ "version": "0.7.0",
4
4
  "description": "DSH 日历插件:CalDAV 日程查询、创建、修改、删除与搜索,支持 Google OAuth2、iCloud、Nextcloud 与自建服务。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,11 +10,13 @@
10
10
  "types": "./lib/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
+ "./client": "./lib/client.js",
13
14
  "./cordis.patch.yml": "./cordis.patch.yml",
14
15
  "./package.json": "./package.json"
15
16
  },
16
17
  "files": [
17
18
  "lib",
19
+ "scripts",
18
20
  "cordis.patch.yml",
19
21
  "README.md",
20
22
  "README.en.md",
@@ -29,6 +31,12 @@
29
31
  "dsh": {
30
32
  "bundle": {
31
33
  "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-ui-settings"
38
+ ],
39
+ "platform": "web"
32
40
  }
33
41
  },
34
42
  "keywords": [
@@ -46,6 +54,7 @@
46
54
  },
47
55
  "dependencies": {
48
56
  "ical.js": "^2.1.0",
57
+ "schemastery": "^3.18.0",
49
58
  "tsdav": "^2.3.1",
50
59
  "undici": "^8.10.0"
51
60
  },