dsh-calendar 0.2.2 → 0.3.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.en.md CHANGED
@@ -20,6 +20,7 @@ All configuration lives in your profile's cordis.patch.yml; override the `calend
20
20
  - `password`: password; for Google / iCloud use an app-specific password. Recommended to use the `DSH_CALENDAR_PASSWORD` env var to avoid storing it in plaintext.
21
21
  - `calendarId`: google-specific, the calendar ID (usually your email)
22
22
  - `host` / `user` / `calendar`: nextcloud-specific
23
+ - `proxyUrl`: optional HTTP proxy (e.g. `http://127.0.0.1:7890`); routes only this plugin's CalDAV requests through it
23
24
 
24
25
  ### Google example
25
26
 
@@ -85,6 +86,10 @@ iCloud: sign in to appleid.apple.com → Sign-In and Security → App-Specific P
85
86
 
86
87
  If a call returns 401/403, it's usually the wrong password (login password used instead of an app-specific password); the plugin returns a Chinese hint.
87
88
 
89
+ ## Proxy
90
+
91
+ If your CalDAV server is not directly reachable from your network (some regional or corporate networks block it), set `proxyUrl` in the plugin config, e.g. `http://127.0.0.1:7890`, and restart. The proxy only routes this plugin's CalDAV requests; it does not affect other plugins in the same process.
92
+
88
93
  ## Tool reference
89
94
 
90
95
  - `calendar_list`: list events in a time range (start/end, ISO 8601; defaults to the next 7 days). Recurring events are expanded by default (`expand` defaults to true, `maxOccurrences` defaults to 30, clamped to 1-200): each occurrence is a separate row with `isOccurrence: true` and `seriesStart`; non-recurring events keep `isOccurrence: false`. With `expand=false`, recurring events are returned as a single original entry with `rrule`
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  # dsh-calendar
4
4
 
5
+ > **agent 从此会排期**:CalDAV 读写日历,重复日程自动展开。
6
+
7
+ ![npm version](https://img.shields.io/npm/v/dsh-calendar?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-calendar) ![license](https://img.shields.io/npm/l/dsh-calendar) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-calendar?style=social)
8
+
9
+
5
10
  DSH 社区插件:通过 CalDAV 读写日历事件。提供 5 个面向模型的工具(calendar_list / calendar_create / calendar_update / calendar_delete / calendar_search),支持 Google / iCloud / Nextcloud 及任意 CalDAV 服务器。本轮为 node 半身,不含设置页 UI,配置全部走 profile 的 cordis.patch.yml。
6
11
 
7
12
  ## 安装
@@ -20,9 +25,34 @@ dsh plugin --profile web add dsh-calendar
20
25
  - `caldavUrl`:完整日历集合 URL(custom / icloud 必填;google / nextcloud 也可手填覆盖预设)
21
26
  - `username`:CalDAV 账号(Google / iCloud 为账号邮箱)
22
27
  - `password`:密码;Google / iCloud 请用应用专用密码。推荐用环境变量 `DSH_CALENDAR_PASSWORD`,避免明文入库。
28
+ - `proxyUrl`:本机代理地址(如 http://127.0.0.1:7890)。中国大陆访问 Google / iCloud 必填,详见上文「特殊代理配置」专节;国内可直连的 CalDAV 服务无需填写。
23
29
  - `calendarId`:google 专用,日历 ID(通常是你的邮箱)
24
30
  - `host` / `user` / `calendar`:nextcloud 专用
25
31
 
32
+ ## 中国用户:特殊代理配置(Google / iCloud)
33
+
34
+ Google 与 iCloud 的 CalDAV 端点在中国大陆**不可直连**,需要配合你常用的梯子/特殊代理使用。插件内置 `proxyUrl` 配置:把 CalDAV 请求路由到**你本机代理客户端的端口**,不影响其他插件,也无需改任何系统设置。
35
+
36
+ ```yaml
37
+ - id: calendar
38
+ config:
39
+ provider: google
40
+ username: you@gmail.com
41
+ calendarId: you@gmail.com
42
+ password: 你的应用专用密码
43
+ proxyUrl: http://127.0.0.1:7890 # 改成你代理客户端的本地端口
44
+ ```
45
+
46
+ ### 常见代理客户端本地端口
47
+
48
+ | 客户端 | 本地端口 |
49
+ |---|---|
50
+ | Clash / Clash Verge(HTTP) | 7890 / 7897 |
51
+ | v2rayN(HTTP / SOCKS) | 10808 / 10809 |
52
+ | Shadowsocks | 1080 |
53
+
54
+ 在客户端界面确认你的实际端口,填进 `proxyUrl` 即可。国内可直连的 CalDAV 服务(如自建 Nextcloud)则无需填写。
55
+
26
56
  ### Google 示例
27
57
 
28
58
  ```yaml
@@ -101,9 +131,9 @@ iCloud:登录 appleid.apple.com → 登录与安全 → App 专用密码,生
101
131
 
102
132
  输入输出统一 ISO 8601。定时事件输出为 UTC(如 `2025-01-15T01:00:00Z`),全天事件输出 `YYYY-MM-DD`。输入可带时区偏移(如 `2025-01-15T09:00:00+08:00`),插件内部转 UTC 存储。
103
133
 
104
- ## 已知限制
105
-
106
- - **国内网络**:Google(apidata.googleusercontent.com)与 iCloud(caldav.icloud.com)在中国大陆不可直连;请使用可达的 CalDAV 端点(自建 Nextcloud/Radicale 或代理)。
134
+ ## 已知限制
135
+
136
+ - **国内网络**:Google iCloud CalDAV 端点在中国大陆不可直连;可用 `proxyUrl` 走本机梯子/特殊代理,或改用国内可直连的 CalDAV 端点(自建 Nextcloud/Radicale)。
107
137
 
108
138
 
109
139
  - 重复事件展开:calendar_list 默认用 ICAL.RecurExpansion 展开 RRULE(`expand=true`),受 `maxOccurrences` 封顶;calendar_search 仍返回原始系列(不展开)。
@@ -128,3 +158,4 @@ pnpm test # 构建 + node --test
128
158
  - [dsh-calendar](https://github.com/STARDUSTLC666/dsh-calendar) — CalDAV 日历五件套
129
159
  - [dsh-slack](https://github.com/STARDUSTLC666/dsh-slack) — Slack 通知/收件箱
130
160
  - [dsh-dingtalk](https://github.com/STARDUSTLC666/dsh-dingtalk) — 钉钉群通知(零依赖)
161
+
package/lib/caldav.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * @module dsh-calendar/caldav
6
6
  */
7
7
  import { createDAVClient } from 'tsdav';
8
+ import { createProxyFetch } from './proxy-fetch.js';
8
9
  import { buildICalString, expandEventFromICal, generateUid, parseEventFromICal, } from './ical.js';
9
10
  /** CalDAV 操作错误:带中文指引。 */
10
11
  export class CalDAVError extends Error {
@@ -43,12 +44,19 @@ export class CalendarService {
43
44
  this.collectionUrl = ensureTrailingSlash(config.caldavUrl);
44
45
  }
45
46
  client() {
46
- this.clientPromise ??= createDAVClient({
47
- serverUrl: this.config.caldavUrl,
48
- credentials: { username: this.config.username, password: this.config.password },
49
- authMethod: 'Basic',
47
+ if (this.clientPromise === undefined) {
48
+ this.clientPromise = createDAVClient({
49
+ serverUrl: this.config.caldavUrl,
50
+ credentials: { username: this.config.username, password: this.config.password },
51
+ authMethod: 'Basic',
52
+ ...(this.config.proxyUrl !== '' ? { fetch: createProxyFetch(this.config.proxyUrl) } : {}),
53
+ });
54
+ }
55
+ // 创建失败时清掉缓存,让下一次工具调用有机会重试,而不是永久复用 rejected promise。
56
+ return this.clientPromise.catch((error) => {
57
+ this.clientPromise = undefined;
58
+ throw error;
50
59
  });
51
- return this.clientPromise;
52
60
  }
53
61
  calendar() {
54
62
  return { url: this.collectionUrl };
@@ -156,6 +164,11 @@ export class CalendarService {
156
164
  if (start === undefined || end === undefined) {
157
165
  throw new CalDAVError('无法确定事件的开始/结束时间:请同时提供 start 与 end。');
158
166
  }
167
+ const startMs = new Date(start).getTime();
168
+ const endMs = new Date(end).getTime();
169
+ if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && endMs < startMs) {
170
+ throw new CalDAVError('更新时间范围无效:end 不能早于 start(' + end + ' < ' + start + ')。');
171
+ }
159
172
  const merged = {
160
173
  summary: changes.summary ?? existing?.summary ?? '',
161
174
  start,
@@ -163,6 +176,7 @@ export class CalendarService {
163
176
  ...(changes.description !== undefined ? { description: changes.description } : existing?.description !== undefined ? { description: existing.description } : {}),
164
177
  ...(changes.location !== undefined ? { location: changes.location } : existing?.location !== undefined ? { location: existing.location } : {}),
165
178
  allDay: changes.allDay ?? existing?.allDay,
179
+ ...(changes.rrule !== undefined ? { rrule: changes.rrule } : existing?.rrule !== undefined ? { rrule: existing.rrule } : {}),
166
180
  ...(existing?.icalUid !== undefined ? { icalUid: existing.icalUid } : {}),
167
181
  };
168
182
  const iCalString = buildICalString(merged);
package/lib/config.d.ts CHANGED
@@ -24,6 +24,12 @@ export interface CalendarConfig {
24
24
  user?: string;
25
25
  /** nextcloud:日历名。 */
26
26
  calendar?: string;
27
+ /**
28
+ * HTTP 代理地址,如 http://127.0.0.1:7890。
29
+ * 中国用户访问 Google / iCloud 需经代理:填你本地代理客户端的端口即可,
30
+ * 插件会把所有 CalDAV 请求路由到该代理,不影响其他插件。
31
+ */
32
+ proxyUrl?: string;
27
33
  }
28
34
  /** 解析完成、可直接建客户端的配置。 */
29
35
  export interface ResolvedConfig {
@@ -31,6 +37,7 @@ export interface ResolvedConfig {
31
37
  caldavUrl: string;
32
38
  username: string;
33
39
  password: string;
40
+ proxyUrl: string;
34
41
  }
35
42
  /** 预设端点常量。 */
36
43
  export declare const GOOGLE_CALDAV_PREFIX = "https://apidata.googleusercontent.com/caldav/v2/";
package/lib/config.js CHANGED
@@ -49,7 +49,7 @@ export function resolveConfig(config, env = process.env) {
49
49
  '或在 profile 的 cordis.patch.yml 覆盖 calendar 行的 password(Google / iCloud 请用应用专用密码)后重启。');
50
50
  }
51
51
  const caldavUrl = buildCaldavUrl(config ?? {}, provider);
52
- return { provider, caldavUrl, username, password };
52
+ return { provider, caldavUrl, username, password, proxyUrl: nonEmpty(config?.proxyUrl) ?? '' };
53
53
  }
54
54
  /** 依据 provider 预设或手填字段拼出日历集合 URL。 */
55
55
  export function buildCaldavUrl(config, provider) {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 构造一个把所有请求路由到 proxyUrl 的 fetch(供 tsdav 的 fetch 注入使用)。
3
+ * @param proxyUrl - 如 http://127.0.0.1:7890
4
+ */
5
+ export declare function createProxyFetch(proxyUrl: string): typeof globalThis.fetch;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 插件级 HTTP 代理:给 CalDAV 请求一个走指定代理的 fetch,不影响同进程其他插件。
3
+ */
4
+ import { ProxyAgent, fetch as undiciFetch } from 'undici';
5
+ /**
6
+ * 构造一个把所有请求路由到 proxyUrl 的 fetch(供 tsdav 的 fetch 注入使用)。
7
+ * @param proxyUrl - 如 http://127.0.0.1:7890
8
+ */
9
+ export function createProxyFetch(proxyUrl) {
10
+ const agent = new ProxyAgent(proxyUrl);
11
+ // undici 自带类型与全局 fetch 类型不完全一致,桥接处用 any 避免类型摩擦;
12
+ // 对外契约仍是 typeof globalThis.fetch。
13
+ return ((input, init) => undiciFetch(input, { ...init, dispatcher: agent }));
14
+ }
package/lib/tools.js CHANGED
@@ -42,16 +42,46 @@ function clampedInteger(args, key, fallback, min, max) {
42
42
  return fallback;
43
43
  return Math.min(max, Math.max(min, value));
44
44
  }
45
+ const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
45
46
  function assertIsoTime(value, label) {
46
- if (/^\d{4}-\d{2}-\d{2}$/.test(value))
47
+ const dateOnly = DATE_ONLY_PATTERN.exec(value);
48
+ if (dateOnly !== null) {
49
+ const year = Number(dateOnly[1]);
50
+ const month = Number(dateOnly[2]);
51
+ const day = Number(dateOnly[3]);
52
+ const date = new Date(Date.UTC(year, month - 1, day));
53
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
54
+ throw new Error(label + ' 不是合法的日期:' + value + '(YYYY-MM-DD 必须是真实存在的日期)。');
55
+ }
47
56
  return;
57
+ }
48
58
  if (Number.isNaN(new Date(value).getTime())) {
49
59
  throw new Error(label + ' 不是合法的 ISO 8601 时间,例如 2025-01-01T09:00:00+08:00 或 2025-01-01T09:00:00Z。');
50
60
  }
51
61
  }
62
+ /** 日期/时间统一转 epoch 毫秒(date-only 按 UTC 零点),用于范围比较。 */
63
+ function toEpochMs(value) {
64
+ const dateOnly = DATE_ONLY_PATTERN.exec(value);
65
+ if (dateOnly !== null) {
66
+ return Date.UTC(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]));
67
+ }
68
+ return new Date(value).getTime();
69
+ }
70
+ function assertTimeRange(start, end) {
71
+ if (toEpochMs(start) > toEpochMs(end)) {
72
+ throw new Error('end 不能早于 start:' + end + ' < ' + start + '。请确认起止时间顺序。');
73
+ }
74
+ }
52
75
  function isoNoMillis(value) {
53
76
  return value.replace(/\.\d{3}Z$/, 'Z');
54
77
  }
78
+ /** 按开始时间升序稳定排序(CalDAV 服务端返回顺序不保证稳定)。 */
79
+ function sortEvents(events) {
80
+ return events
81
+ .map((event, index) => ({ event, index, startMs: toEpochMs(event.start) }))
82
+ .sort((a, b) => a.startMs - b.startMs || a.index - b.index)
83
+ .map(({ event }) => event);
84
+ }
55
85
  function formatEvent(event) {
56
86
  const range = event.allDay ? event.start + '(全天)' : event.start + ' ~ ' + event.end;
57
87
  const extra = [event.location ? '地点:' + event.location : '', event.rrule ? '重复:' + event.rrule : '']
@@ -106,9 +136,10 @@ export function buildCalendarTools(config) {
106
136
  const end = optionalString(input, 'end') ?? isoNoMillis(defaultEnd.toISOString());
107
137
  assertIsoTime(start, 'start');
108
138
  assertIsoTime(end, 'end');
139
+ assertTimeRange(start, end);
109
140
  const expand = booleanWithDefault(input, 'expand', true);
110
141
  const maxOccurrences = clampedInteger(input, 'maxOccurrences', 30, 1, 200);
111
- const events = await service().list(start, end, { expand, maxOccurrences });
142
+ const events = sortEvents(await service().list(start, end, { expand, maxOccurrences }));
112
143
  return { count: events.length, start, end, events };
113
144
  },
114
145
  timeoutMs: TIMEOUT_MS,
@@ -144,14 +175,19 @@ export function buildCalendarTools(config) {
144
175
  const end = requiredString(input, 'end', '结束时间');
145
176
  assertIsoTime(start, 'start');
146
177
  assertIsoTime(end, 'end');
178
+ assertTimeRange(start, end);
179
+ const description = optionalString(input, 'description');
180
+ const location = optionalString(input, 'location');
181
+ const allDay = optionalBoolean(input, 'allDay');
182
+ const rrule = optionalString(input, 'rrule');
147
183
  const fields = {
148
184
  summary,
149
185
  start,
150
186
  end,
151
- ...(optionalString(input, 'description') !== undefined ? { description: optionalString(input, 'description') } : {}),
152
- ...(optionalString(input, 'location') !== undefined ? { location: optionalString(input, 'location') } : {}),
153
- ...(optionalBoolean(input, 'allDay') !== undefined ? { allDay: optionalBoolean(input, 'allDay') } : {}),
154
- ...(optionalString(input, 'rrule') !== undefined ? { rrule: optionalString(input, 'rrule') } : {}),
187
+ ...(description !== undefined ? { description } : {}),
188
+ ...(location !== undefined ? { location } : {}),
189
+ ...(allDay !== undefined ? { allDay } : {}),
190
+ ...(rrule !== undefined ? { rrule } : {}),
155
191
  };
156
192
  const created = await service().create(fields);
157
193
  return { created };
@@ -160,7 +196,7 @@ export function buildCalendarTools(config) {
160
196
  };
161
197
  const update = {
162
198
  name: 'calendar_update',
163
- description: '按 uid 修改日历事件。uid 必填(来自 calendar_list / calendar_search 返回的 uid)。summary、start、end、description、location、allDay 均可选,未提供的字段保留原值。',
199
+ description: '按 uid 修改日历事件。uid 必填(来自 calendar_list / calendar_search 返回的 uid)。summary、start、end、description、location、allDay、rrule 均可选,未提供的字段保留原值。',
164
200
  parameters: compileParameters({
165
201
  uid: { type: 'string', required: true, description: '事件稳定标识(来自 calendar_list / calendar_search 的 uid,必填)。' },
166
202
  summary: { type: 'string', description: '新标题(可选)。' },
@@ -252,7 +288,7 @@ export function buildCalendarTools(config) {
252
288
  description: '按关键词搜索日历事件(客户端过滤:匹配标题、描述、地点与 iCal UID,不区分大小写)。query 必填,limit 可选(默认 50)。返回每个事件的稳定标识 uid。',
253
289
  parameters: compileParameters({
254
290
  query: { type: 'string', required: true, description: '搜索关键词(必填)。' },
255
- limit: { type: 'integer', description: '最多返回条数(可选,默认 50)。' },
291
+ limit: { type: 'integer', description: '最多返回条数(可选,默认 50,自动 clamp 到 1-200)。' },
256
292
  }),
257
293
  output: {
258
294
  schema: {
@@ -276,9 +312,9 @@ export function buildCalendarTools(config) {
276
312
  async execute(args) {
277
313
  const input = asRecord(args);
278
314
  const query = requiredString(input, 'query', '搜索关键词');
279
- const limit = optionalInteger(input, 'limit') ?? 50;
315
+ const limit = clampedInteger(input, 'limit', 50, 1, 200);
280
316
  const all = await service().all();
281
- const matched = all.filter(buildSearchFilter(query)).slice(0, limit);
317
+ const matched = sortEvents(all).filter(buildSearchFilter(query)).slice(0, limit);
282
318
  return { query, count: matched.length, events: matched };
283
319
  },
284
320
  timeoutMs: TIMEOUT_MS,
package/package.json CHANGED
@@ -1,63 +1,61 @@
1
- {
2
- "name": "dsh-calendar",
3
- "version": "0.2.2",
4
- "description": "DSH 日历工具插件:通过 CalDAV 读写日历事件(Google / iCloud / Nextcloud / 自定义)",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "types": "lib/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./lib/index.d.ts",
11
- "default": "./lib/index.js"
12
- },
13
- "./cordis.patch.yml": "./cordis.patch.yml",
14
- "./package.json": "./package.json"
15
- },
16
- "files": [
17
- "lib",
18
- "cordis.patch.yml",
19
- "README.md"
20
- ],
21
- "scripts": {
22
- "build": "tsc -p tsconfig.json",
23
- "prepare": "tsc -p tsconfig.json",
24
- "typecheck": "tsc -p tsconfig.json --noEmit",
25
- "test": "pnpm run build && node --test \"test/*.test.mjs\"",
26
- "prepublishOnly": "pnpm run build"
27
- },
28
- "dsh": {
29
- "bundle": {
30
- "patch": "./cordis.patch.yml"
31
- }
32
- },
33
- "keywords": [
34
- "dsh",
35
- "deepseek-harness",
36
- "plugin",
37
- "calendar",
38
- "caldav",
39
- "ical"
40
- ],
41
- "license": "MIT",
42
- "engines": {
43
- "node": ">=18"
44
- },
45
- "dependencies": {
46
- "ical.js": "^2.1.0",
47
- "tsdav": "^2.3.1"
48
- },
49
- "devDependencies": {
50
- "@types/node": "^24.0.0",
51
- "typescript": "^5.6.0"
52
- },
53
- "repository": {
54
- "type": "git",
55
- "url": "https://github.com/STARDUSTLC666/dsh-calendar"
56
- },
57
- "bugs": {
58
- "url": "https://github.com/STARDUSTLC666/dsh-calendar/issues"
59
- },
60
- "homepage": "https://github.com/STARDUSTLC666/dsh-calendar#readme",
61
- "author": "stardustlc",
62
- "packageManager": "pnpm@11.7.0"
63
- }
1
+ {
2
+ "name": "dsh-calendar",
3
+ "version": "0.3.1",
4
+ "description": "DSH 日历工具插件:通过 CalDAV 读写日历事件(Google / iCloud / Nextcloud / 自定义)",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml",
19
+ "README.md"
20
+ ],
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ }
25
+ },
26
+ "keywords": [
27
+ "dsh",
28
+ "deepseek-harness",
29
+ "plugin",
30
+ "calendar",
31
+ "caldav",
32
+ "ical"
33
+ ],
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "dependencies": {
39
+ "ical.js": "^2.1.0",
40
+ "tsdav": "^2.3.1",
41
+ "undici": "^8.10.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^24.0.0",
45
+ "typescript": "^5.6.0"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/STARDUSTLC666/dsh-calendar"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/STARDUSTLC666/dsh-calendar/issues"
53
+ },
54
+ "homepage": "https://github.com/STARDUSTLC666/dsh-calendar#readme",
55
+ "author": "stardustlc",
56
+ "scripts": {
57
+ "build": "tsc -p tsconfig.json",
58
+ "typecheck": "tsc -p tsconfig.json --noEmit",
59
+ "test": "pnpm run build && node --test \"test/*.test.mjs\""
60
+ }
61
+ }