dsh-calendar 0.3.0 → 0.3.2

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
  ## 安装
@@ -19,7 +24,7 @@ dsh plugin --profile web add dsh-calendar
19
24
  - `provider`:google | icloud | nextcloud | custom
20
25
  - `caldavUrl`:完整日历集合 URL(custom / icloud 必填;google / nextcloud 也可手填覆盖预设)
21
26
  - `username`:CalDAV 账号(Google / iCloud 为账号邮箱)
22
- - `password`:密码;Google / iCloud 请用应用专用密码。推荐用环境变量 `DSH_CALENDAR_PASSWORD`,避免明文入库。
27
+ - `password`:密码;Google / iCloud 请用应用专用密码。推荐用环境变量 `DSH_CALENDAR_PASSWORD`,避免明文入库。
23
28
  - `proxyUrl`:本机代理地址(如 http://127.0.0.1:7890)。中国大陆访问 Google / iCloud 必填,详见上文「特殊代理配置」专节;国内可直连的 CalDAV 服务无需填写。
24
29
  - `calendarId`:google 专用,日历 ID(通常是你的邮箱)
25
30
  - `host` / `user` / `calendar`:nextcloud 专用
@@ -126,9 +131,9 @@ iCloud:登录 appleid.apple.com → 登录与安全 → App 专用密码,生
126
131
 
127
132
  输入输出统一 ISO 8601。定时事件输出为 UTC(如 `2025-01-15T01:00:00Z`),全天事件输出 `YYYY-MM-DD`。输入可带时区偏移(如 `2025-01-15T09:00:00+08:00`),插件内部转 UTC 存储。
128
133
 
129
- ## 已知限制
130
-
131
- - **国内网络**:Google 与 iCloud 的 CalDAV 端点在中国大陆不可直连;可用 `proxyUrl` 走本机梯子/特殊代理,或改用国内可直连的 CalDAV 端点(自建 Nextcloud/Radicale)。
134
+ ## 已知限制
135
+
136
+ - **国内网络**:Google 与 iCloud 的 CalDAV 端点在中国大陆不可直连;可用 `proxyUrl` 走本机梯子/特殊代理,或改用国内可直连的 CalDAV 端点(自建 Nextcloud/Radicale)。
132
137
 
133
138
 
134
139
  - 重复事件展开:calendar_list 默认用 ICAL.RecurExpansion 展开 RRULE(`expand=true`),受 `maxOccurrences` 封顶;calendar_search 仍返回原始系列(不展开)。
@@ -153,3 +158,4 @@ pnpm test # 构建 + node --test
153
158
  - [dsh-calendar](https://github.com/STARDUSTLC666/dsh-calendar) — CalDAV 日历五件套
154
159
  - [dsh-slack](https://github.com/STARDUSTLC666/dsh-slack) — Slack 通知/收件箱
155
160
  - [dsh-dingtalk](https://github.com/STARDUSTLC666/dsh-dingtalk) — 钉钉群通知(零依赖)
161
+
package/lib/caldav.js CHANGED
@@ -44,13 +44,19 @@ export class CalendarService {
44
44
  this.collectionUrl = ensureTrailingSlash(config.caldavUrl);
45
45
  }
46
46
  client() {
47
- this.clientPromise ??= createDAVClient({
48
- serverUrl: this.config.caldavUrl,
49
- credentials: { username: this.config.username, password: this.config.password },
50
- authMethod: 'Basic',
51
- ...(this.config.proxyUrl !== '' ? { fetch: createProxyFetch(this.config.proxyUrl) } : {}),
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;
52
59
  });
53
- return this.clientPromise;
54
60
  }
55
61
  calendar() {
56
62
  return { url: this.collectionUrl };
@@ -158,6 +164,11 @@ export class CalendarService {
158
164
  if (start === undefined || end === undefined) {
159
165
  throw new CalDAVError('无法确定事件的开始/结束时间:请同时提供 start 与 end。');
160
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
+ }
161
172
  const merged = {
162
173
  summary: changes.summary ?? existing?.summary ?? '',
163
174
  start,
@@ -165,6 +176,7 @@ export class CalendarService {
165
176
  ...(changes.description !== undefined ? { description: changes.description } : existing?.description !== undefined ? { description: existing.description } : {}),
166
177
  ...(changes.location !== undefined ? { location: changes.location } : existing?.location !== undefined ? { location: existing.location } : {}),
167
178
  allDay: changes.allDay ?? existing?.allDay,
179
+ ...(changes.rrule !== undefined ? { rrule: changes.rrule } : existing?.rrule !== undefined ? { rrule: existing.rrule } : {}),
168
180
  ...(existing?.icalUid !== undefined ? { icalUid: existing.icalUid } : {}),
169
181
  };
170
182
  const iCalString = buildICalString(merged);
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,64 +1,61 @@
1
- {
2
- "name": "dsh-calendar",
3
- "version": "0.3.0",
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
- "undici": "^8.10.0"
49
- },
50
- "devDependencies": {
51
- "@types/node": "^24.0.0",
52
- "typescript": "^5.6.0"
53
- },
54
- "repository": {
55
- "type": "git",
56
- "url": "https://github.com/STARDUSTLC666/dsh-calendar"
57
- },
58
- "bugs": {
59
- "url": "https://github.com/STARDUSTLC666/dsh-calendar/issues"
60
- },
61
- "homepage": "https://github.com/STARDUSTLC666/dsh-calendar#readme",
62
- "author": "stardustlc",
63
- "packageManager": "pnpm@11.7.0"
64
- }
1
+ {
2
+ "name": "dsh-calendar",
3
+ "version": "0.3.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
+ "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
+ }