dsh-calendar 0.5.3 → 0.5.4

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/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ > 完整历史(含详细改动说明)。README 只保留最近几个版本的一句话摘要。
4
+
5
+ ## 中文版
6
+
7
+ - **0.5.4(2026-09-18)**:**修复**:①`calendar_update` 重建 VEVENT 会静默丢掉原事件的 ATTENDEE / ORGANIZER / EXDATE / STATUS / CATEGORIES / VALARM(改一次标题就删掉邀请人和提醒),现在改为在原始 VCALENDAR 上做字段级覆盖,并补齐 RFC 5545 必需的 VERSION / PRODID / UID / DTSTAMP;②RECURRENCE-ID 覆盖实例被忽略(被单独改期的实例按原时间+旧标题返回,或被 EXDATE 排掉后整条消失),现在建覆盖映射并在展开时替换对应实例;③`calendar_create` 返回本地拼的 uid 而忽略服务器 `Location`,导致 create 后 update 找不到事件(现在优先取 Location,缺失时回读确认),且 create 的 PUT body 补上 VERSION / PRODID / DTSTAMP。**优化**:重复事件展开预算耗尽时不再静默返回 0 条(DTSTART 在 2010 的每小时系列实测 680ms 且返回空),改为显式报错并提示缩小时间范围或补 COUNT/UNTIL;`calendar_search` 支持 `start` / `end` 并走 timeRange 查询(缺省用有界窗口),不再先整本下载再过滤。测试 68 → 79 项。
8
+ - **0.5.3(2026-09-11)**:复验官方 Harness 0.1.5-rc.1,更新整套同载与真实服务验证记录;运行时代码未变。
9
+ - **0.5.2(2026-09-08)**:补充官方 Harness 0.1.3-alpha.2 的安装、加载与真实 Google 工具调用验证,更新兼容性和 Node 版本要求;运行时代码与 0.5.0 相同。
10
+ - **0.5.1(2026-09-08)**:补充真实 Google OAuth/CalDAV 读取验证、`calendar.readonly` 与 `calendar` 范围对比及 Testing 刷新令牌到期说明;运行时代码与 0.5.0 相同。
11
+ - **0.5.0(2026-09-07)**:修复 Google CalDAV #2:新增 OAuth 凭据与环境变量配置、请求时刷新、取消与代理透传;健康检查区分 Basic/OAuth,修正误导的应用专用密码说明。保留其他服务的 Basic 认证。
12
+ - **0.4.0**:新增 `calendar_health` 自检(离线检查 CalDAV 端点与凭据配置,不验证连接)。
13
+ - **0.3.2**:
14
+ - 修复 `calendar_update` 更新其他字段时丢失 `rrule` 的问题。
15
+ - 更新与新建都会校验 `end >= start`,并拒绝 `2025-02-30` 这类不存在的日期。
16
+ - `calendar_list` / `calendar_search` 输出按开始时间稳定排序;搜索 `limit` clamp 到 1-200。
17
+ - CalDAV 客户端创建失败后清空缓存,下一次调用可自动重试,不再永久复用 rejected promise。
18
+
19
+ ## English
20
+
21
+ - **0.5.4 (2026-09-18)**: **Fixes**: (1) `calendar_update` rebuilt the VEVENT and silently dropped ATTENDEE / ORGANIZER / EXDATE / STATUS / CATEGORIES / VALARM — renaming an event deleted its guests and reminders; the update now does field-level replacement on the original VCALENDAR and fills in the RFC 5545 essentials (VERSION / PRODID / UID / DTSTAMP). (2) RECURRENCE-ID overrides were ignored: a moved instance came back with its old time and title, or disappeared entirely when an EXDATE removed the original slot; overrides are now mapped and substituted during expansion. (3) `calendar_create` returned a locally invented uid instead of the server `Location`, so a follow-up update could not find the event (Location is now preferred, with a read-back check when absent), and the create body now carries VERSION / PRODID / DTSTAMP. **Improvements**: an exhausted expansion budget no longer returns an empty result silently (an hourly series starting in 2010 took 680ms and returned nothing) but raises a clear error suggesting a narrower range or COUNT/UNTIL; `calendar_search` accepts `start`/`end` and queries by timeRange instead of downloading the whole calendar first. Tests 68 → 79.
22
+ - **0.5.3 (2026-09-11)**: revalidate official Harness 0.1.5-rc.1 and refresh suite co-load and live-service evidence; runtime code is unchanged.
23
+ - **0.5.2 (2026-09-08)**: document installation, loading and real Google tool execution in official Harness 0.1.3-alpha.2; update compatibility and Node requirements. Runtime code is unchanged from 0.5.0.
24
+ - **0.5.1 (2026-09-08)**: document live Google OAuth/CalDAV read validation, the `calendar.readonly` versus `calendar` scope results and Testing refresh-token expiration. Runtime code is unchanged from 0.5.0.
25
+ - **0.5.0 (2026-09-07)**: fix Google CalDAV #2 with OAuth configuration/environment credentials, request-time refresh, cancellation and proxy forwarding. Make health checks and error guidance authentication-aware; retain Basic authentication for other servers.
26
+ - **0.4.0**: new `calendar_health` self-check (offline endpoint and credential configuration checks, not a connection test).
27
+ - **0.3.2**:
28
+ - Fix `calendar_update` dropping `rrule` while updating other fields.
29
+ - Validate `end >= start` and reject impossible dates such as `2025-02-30`.
30
+ - Sort `calendar_list` / `calendar_search` output by start time and clamp search `limit` to 1-200.
31
+ - Reset the cached CalDAV client after creation failure so the next tool call can retry.
package/README.en.md CHANGED
@@ -134,9 +134,9 @@ If your CalDAV server is not directly reachable from your network (some regional
134
134
  - `calendar_health`: offline provider, endpoint and Basic/OAuth credential-completeness checks; never displays secrets or connects to the server.
135
135
  - `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`. Results are stably sorted by start time.
136
136
  - `calendar_create`: create an event (summary/start/end required; description/location/allDay/rrule optional). Validates real calendar dates and `end >= start`.
137
- - `calendar_update`: edit an event by uid (summary/start/end/description/location/allDay/rrule optional; omitted fields keep their original values, including the recurrence rule).
137
+ - `calendar_update`: edit an event by uid (summary/start/end/description/location/allDay/rrule optional; omitted fields keep their original values, including the recurrence rule and original properties such as ATTENDEE / ORGANIZER / VALARM).
138
138
  - `calendar_delete`: delete an event by uid
139
- - `calendar_search`: search events by keyword (client-side filter over title/description/location/UID, case-insensitive; `limit` defaults to 50, clamped to 1-200, and results are sorted by start time).
139
+ - `calendar_search`: search events by keyword within the `start`~`end` window (defaults to one year before/after now; client-side filter over title/description/location/UID, case-insensitive; `limit` defaults to 50, clamped to 1-200, and results are sorted by start time).
140
140
 
141
141
  The stable event identifier `uid` is the CalDAV href (full object URL); `calendar_update` / `calendar_delete` use it.
142
142
 
@@ -147,21 +147,14 @@ Input and output are uniformly ISO 8601. Timed events are output in UTC (e.g. `2
147
147
 
148
148
  ## Changelog
149
149
 
150
- - **0.5.3 (2026-09-11)**: revalidate official Harness 0.1.5-rc.1 and refresh suite co-load and live-service evidence; runtime code is unchanged.
151
- - **0.5.2 (2026-09-08)**: document installation, loading and real Google tool execution in official Harness 0.1.3-alpha.2; update compatibility and Node requirements. Runtime code is unchanged from 0.5.0.
152
- - **0.5.1 (2026-09-08)**: document live Google OAuth/CalDAV read validation, the `calendar.readonly` versus `calendar` scope results and Testing refresh-token expiration. Runtime code is unchanged from 0.5.0.
153
- - **0.5.0 (2026-09-07)**: fix Google CalDAV #2 with OAuth configuration/environment credentials, request-time refresh, cancellation and proxy forwarding. Make health checks and error guidance authentication-aware; retain Basic authentication for other servers.
154
- - **0.4.0**: new `calendar_health` self-check (offline endpoint and credential configuration checks, not a connection test).
155
- - **0.3.2**:
156
- - Fix `calendar_update` dropping `rrule` while updating other fields.
157
- - Validate `end >= start` and reject impossible dates such as `2025-02-30`.
158
- - Sort `calendar_list` / `calendar_search` output by start time and clamp search `limit` to 1-200.
159
- - Reset the cached CalDAV client after creation failure so the next tool call can retry.
150
+ - **0.5.4 (2026-09-18)**: fixes for `calendar_update` dropping ATTENDEE/VALARM and other original properties, ignored RECURRENCE-ID overrides, `calendar_create` inventing a uid instead of using the server Location, and silent empty expansion results; `calendar_search` gained a time range. 79 tests.
151
+ - **0.5.3 (2026-09-11)**: revalidated against official Harness 0.1.5-rc.1 and refreshed co-load / live-service verification; runtime code unchanged.
152
+ - **0.5.2 and earlier**: see [CHANGELOG.md](CHANGELOG.md).
160
153
 
161
154
  ## Known limitations
162
155
 
163
- - Recurring event expansion: calendar_list expands RRULE by default via ICAL.RecurExpansion (`expand=true`), capped by `maxOccurrences`; calendar_search still returns the original series (not expanded).
164
- - No single-instance edit/delete: calendar_update / calendar_delete operate on the whole recurring series (by uid); you cannot modify or delete just one occurrence (no RECURRENCE-ID instance-level operations).
156
+ - Recurring event expansion: calendar_list expands RRULE by default via ICAL.RecurExpansion (`expand=true`), capped by `maxOccurrences`, and raises an explicit error when the iteration budget is exhausted; calendar_search only queries the `start`~`end` window (default one year before/after now) and still returns the original series (not expanded).
157
+ - Single-instance reads vs edit/delete: calendar_list honors RECURRENCE-ID overrides when expanding (an occurrence separately rescheduled/retitled is returned with the override time and fields, including when the original instant is EXDATE-excluded); calendar_update / calendar_delete still operate on the whole recurring series (by uid) and cannot modify or delete just one occurrence.
165
158
  - OAuth credentials must be obtained beforehand: refresh-token authentication is supported, but there is no browser login UI / login CLI and runtime tokens are not written back to configuration.
166
159
  - Timezone rules: events with TZID (named timezone) are output converted to UTC (Z); all-day boundaries, DST, and other complex timezone rules are not handled finely.
167
160
  - No settings-page UI: this round is a node half-body; config only via cordis.patch.yml, no Web settings page.
package/README.md CHANGED
@@ -158,9 +158,9 @@ Nextcloud / 自定义 Basic 服务:检查账号、密码或服务要求的应
158
158
  - `calendar_health`:离线检查服务商、日历集合地址与 Basic/OAuth 凭据完整性,不回显密钥、不发起网络连接。
159
159
  - `calendar_list`:列出某时间段事件(start/end,ISO 8601,缺省未来 7 天)。默认展开重复事件(`expand` 默认 true,`maxOccurrences` 默认 30、clamp 1-200):每个实例独立成行,带 `isOccurrence: true` 与 `seriesStart`;非重复事件保持 `isOccurrence: false`。`expand=false` 时重复事件按原始单条返回并带 `rrule`。结果按开始时间稳定排序
160
160
  - `calendar_create`:新建事件(summary/start/end 必填,description/location/allDay/rrule 可选)。严格校验真实日历日期与 `end >= start`
161
- - `calendar_update`:按 uid 改事件(summary/start/end/description/location/allDay/rrule 可选,未提供保留原值,重复规则不再丢失)
161
+ - `calendar_update`:按 uid 改事件(summary/start/end/description/location/allDay/rrule 可选,未提供保留原值;ATTENDEE、ORGANIZER、VALARM 等原始属性一并保留,重复规则不再丢失)
162
162
  - `calendar_delete`:按 uid 删事件
163
- - `calendar_search`:按关键词搜事件(客户端过滤标题/描述/地点/UID,不区分大小写;`limit` 默认 50、clamp 1-200,结果按开始时间排序)
163
+ - `calendar_search`:按关键词搜事件(只查询 start~end 窗口内的事件,缺省为当前时间前后各 1 年;客户端过滤标题/描述/地点/UID,不区分大小写;`limit` 默认 50、clamp 1-200,结果按开始时间排序)
164
164
 
165
165
  事件稳定标识 `uid` 为 CalDAV href(完整对象 URL),`calendar_update` / `calendar_delete` 使用它。
166
166
 
@@ -170,25 +170,17 @@ Nextcloud / 自定义 Basic 服务:检查账号、密码或服务要求的应
170
170
 
171
171
  ## 版本记录
172
172
 
173
- - **0.5.3(2026-09-11)**:复验官方 Harness 0.1.5-rc.1,更新整套同载与真实服务验证记录;运行时代码未变。
174
- - **0.5.2(2026-09-08)**:补充官方 Harness 0.1.3-alpha.2 的安装、加载与真实 Google 工具调用验证,更新兼容性和 Node 版本要求;运行时代码与 0.5.0 相同。
175
- - **0.5.1(2026-09-08)**:补充真实 Google OAuth/CalDAV 读取验证、`calendar.readonly` 与 `calendar` 范围对比及 Testing 刷新令牌到期说明;运行时代码与 0.5.0 相同。
176
- - **0.5.0(2026-09-07)**:修复 Google CalDAV #2:新增 OAuth 凭据与环境变量配置、请求时刷新、取消与代理透传;健康检查区分 Basic/OAuth,修正误导的应用专用密码说明。保留其他服务的 Basic 认证。
177
- - **0.4.0**:新增 `calendar_health` 自检(离线检查 CalDAV 端点与凭据配置,不验证连接)。
178
- - **0.3.2**:
179
- - 修复 `calendar_update` 更新其他字段时丢失 `rrule` 的问题。
180
- - 更新与新建都会校验 `end >= start`,并拒绝 `2025-02-30` 这类不存在的日期。
181
- - `calendar_list` / `calendar_search` 输出按开始时间稳定排序;搜索 `limit` clamp 到 1-200。
182
- - CalDAV 客户端创建失败后清空缓存,下一次调用可自动重试,不再永久复用 rejected promise。
183
-
173
+ - **0.5.4(2026-09-18)**:修复 `calendar_update` 丢 ATTENDEE/VALARM 等原始属性、RECURRENCE-ID 覆盖实例被忽略、`calendar_create` 的 uid 不用服务器 Location、展开超限静默返回空;`calendar_search` 支持时间窗。测试 79 项。
174
+ - **0.5.3(2026-09-11)**:复验官方 Harness 0.1.5-rc.1,更新同载与真实服务验证记录;运行时代码未变。
175
+ - **0.5.2 及更早**:见 [CHANGELOG.md](CHANGELOG.md)。
184
176
 
185
177
  ## 已知限制
186
178
 
187
179
  - **网络可达性**:若无法直连,可用 `proxyUrl` 指定本机 HTTP 代理,或改用可直连的 CalDAV 端点。
188
180
 
189
181
 
190
- - 重复事件展开:calendar_list 默认用 ICAL.RecurExpansion 展开 RRULE(`expand=true`),受 `maxOccurrences` 封顶;calendar_search 仍返回原始系列(不展开)。
191
- - 不支持单次实例的改/删:calendar_update / calendar_delete 针对整个重复系列(按 uid 操作),无法只修改或删除某一次发生(不支持 RECURRENCE-ID 实例级操作)。
182
+ - 重复事件展开:calendar_list 默认用 ICAL.RecurExpansion 展开 RRULE(`expand=true`),受 `maxOccurrences` 封顶,展开超出迭代预算时显式报错;calendar_search 只查 start~end 窗口(默认当前前后各 1 年),仍返回原始系列(不展开)。
183
+ - 单次实例的读取与改/删:calendar_list 展开时识别 RECURRENCE-ID 覆盖实例(单独改期/改标题的实例按覆盖后的时间与字段返回,被 EXDATE 排除原时间的覆盖实例仍会返回);calendar_update / calendar_delete 仍针对整个重复系列(按 uid 操作),无法只修改或删除某一次发生。
192
184
  - OAuth 凭据需要事先取得:支持刷新令牌认证,但不提供浏览器登录 UI / 登录 CLI,也不把运行时令牌写回配置文件。
193
185
  - 时区规则:带 TZID(命名时区)的事件输出会转成 UTC(Z);全天边界、夏令时等复杂时区规则不做精细化处理。
194
186
  - 无设置页 UI:本轮为 node 半身,配置只走 cordis.patch.yml,不提供 Web 设置页。
package/lib/caldav.d.ts CHANGED
@@ -24,8 +24,11 @@ export declare class CalendarService {
24
24
  expand?: boolean;
25
25
  maxOccurrences?: number;
26
26
  }, signal?: AbortSignal): Promise<CalendarEvent[]>;
27
- /** 列出全部事件(客户端过滤用)。 */
28
- all(signal?: AbortSignal): Promise<CalendarEvent[]>;
27
+ /** 列出全部(或 timeRange 窗口内)事件,供客户端过滤用。 */
28
+ all(signal?: AbortSignal, timeRange?: {
29
+ start: string;
30
+ end: string;
31
+ }): Promise<CalendarEvent[]>;
29
32
  private toEvents;
30
33
  /** 列出并展开:每个对象经 expandEventFromICal 展开为若干实例行。 */
31
34
  private toExpandedEvents;
@@ -33,6 +36,8 @@ export declare class CalendarService {
33
36
  private findObject;
34
37
  /** 新建事件,返回带 href/uid 的事件。 */
35
38
  create(fields: EventFields, signal?: AbortSignal): Promise<CalendarEvent>;
39
+ /** 解析 PUT 响应的 Location;取不到时回读一次确认,避免把本地猜测的 href 当成 uid。 */
40
+ private resolveCreatedHref;
36
41
  /** 按 uid 更新事件;未提供的字段保留原值。 */
37
42
  update(uid: string, changes: Partial<EventFields>, signal?: AbortSignal): Promise<CalendarEvent>;
38
43
  /** 按 uid 删除事件。 */
package/lib/caldav.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { createDAVClient } from 'tsdav';
8
8
  import { createProxyFetch } from './proxy-fetch.js';
9
9
  import { createOAuthFetch, OAuthError } from './oauth.js';
10
- import { buildICalString, expandEventFromICal, generateUid, parseEventFromICal, } from './ical.js';
10
+ import { buildICalString, expandEventFromICal, ExpansionLimitError, generateUid, parseEventFromICal, updateICalString, } from './ical.js';
11
11
  /** CalDAV 操作错误:带中文指引。 */
12
12
  export class CalDAVError extends Error {
13
13
  status;
@@ -34,6 +34,9 @@ function authenticationError(config, status) {
34
34
  function translateError(error, action, config) {
35
35
  if (error instanceof CalDAVError)
36
36
  return error;
37
+ // 展开超限是可操作的明确提示,不能被下面的 OAuth 通用文案覆盖。
38
+ if (error instanceof ExpansionLimitError)
39
+ return new CalDAVError(error.message);
37
40
  if (error instanceof OAuthError)
38
41
  return new CalDAVError(error.message, error.status);
39
42
  const message = error instanceof Error ? error.message : String(error);
@@ -101,14 +104,15 @@ export class CalendarService {
101
104
  throw translateError(error, '读取日历', this.config);
102
105
  }
103
106
  }
104
- /** 列出全部事件(客户端过滤用)。 */
105
- async all(signal) {
107
+ /** 列出全部(或 timeRange 窗口内)事件,供客户端过滤用。 */
108
+ async all(signal, timeRange) {
106
109
  signal?.throwIfAborted();
107
110
  try {
108
111
  const client = await this.client();
109
112
  signal?.throwIfAborted();
110
113
  const objects = await client.fetchCalendarObjects({
111
114
  calendar: this.calendar(),
115
+ ...(timeRange !== undefined ? { timeRange } : {}),
112
116
  urlFilter: (url) => typeof url === 'string' && url.length > 0,
113
117
  ...(signal !== undefined ? { fetchOptions: { signal } } : {}),
114
118
  });
@@ -157,6 +161,8 @@ export class CalendarService {
157
161
  const icalUid = fields.icalUid ?? generateUid();
158
162
  const iCalString = buildICalString({ ...fields, icalUid });
159
163
  const filename = icalUid + '.ics';
164
+ // 本地 filename 只是 PUT 目标,服务器可以按自己的规则分配 href;先按本地猜测兜底。
165
+ let href = new URL(filename, this.collectionUrl).href;
160
166
  try {
161
167
  const client = await this.client();
162
168
  signal?.throwIfAborted();
@@ -168,6 +174,7 @@ export class CalendarService {
168
174
  });
169
175
  signal?.throwIfAborted();
170
176
  assertOk(response, '新建事件', this.config);
177
+ href = await this.resolveCreatedHref(response, filename, signal);
171
178
  }
172
179
  catch (error) {
173
180
  signal?.throwIfAborted();
@@ -175,12 +182,26 @@ export class CalendarService {
175
182
  throw error;
176
183
  throw translateError(error, '新建事件', this.config);
177
184
  }
178
- const href = new URL(filename, this.collectionUrl).href;
179
185
  const event = parseEventFromICal(iCalString, href);
180
186
  if (event === null)
181
187
  throw new CalDAVError('新建事件失败:生成的 iCal 无法解析');
182
188
  return event;
183
189
  }
190
+ /** 解析 PUT 响应的 Location;取不到时回读一次确认,避免把本地猜测的 href 当成 uid。 */
191
+ async resolveCreatedHref(response, filename, signal) {
192
+ const location = response.headers.get('location')?.trim();
193
+ if (location !== undefined && location !== '') {
194
+ try {
195
+ return new URL(location, this.collectionUrl).href;
196
+ }
197
+ catch {
198
+ // Location 不是合法 URL 时退回回读确认。
199
+ }
200
+ }
201
+ const guessed = new URL(filename, this.collectionUrl).href;
202
+ const confirmed = await this.findObject(guessed, signal);
203
+ return confirmed === undefined ? guessed : confirmed.url;
204
+ }
184
205
  /** 按 uid 更新事件;未提供的字段保留原值。 */
185
206
  async update(uid, changes, signal) {
186
207
  signal?.throwIfAborted();
@@ -217,7 +238,9 @@ export class CalendarService {
217
238
  ...(changes.rrule !== undefined ? { rrule: changes.rrule } : existing?.rrule !== undefined ? { rrule: existing.rrule } : {}),
218
239
  ...(existing?.icalUid !== undefined ? { icalUid: existing.icalUid } : {}),
219
240
  };
220
- const iCalString = buildICalString(merged);
241
+ // 字段级覆盖:保留原 VCALENDAR/VEVENT 的 ATTENDEE、VALARM 等属性,只改显式给出的字段。
242
+ // 原 calendar-data 无法解析时回退到整条重建,保持旧的可用行为。
243
+ const iCalString = updateICalString(String(object.data ?? ''), changes) ?? buildICalString(merged);
221
244
  try {
222
245
  const client = await this.client();
223
246
  signal?.throwIfAborted();
package/lib/ical.d.ts CHANGED
@@ -33,6 +33,10 @@ export interface CalendarEvent {
33
33
  created?: string;
34
34
  lastModified?: string;
35
35
  }
36
+ /** 展开重复事件超出迭代预算:显式报错,避免把「没走到窗口」静默当成「窗口内没有实例」。 */
37
+ export declare class ExpansionLimitError extends Error {
38
+ constructor(message: string);
39
+ }
36
40
  /** 新建 / 更新事件时需要的字段。 */
37
41
  export interface EventFields {
38
42
  summary: string;
@@ -57,15 +61,30 @@ export declare function parseEventFromICal(data: string, href: string, etag?: st
57
61
  * 解析并(可选)展开一个 VEVENT:非重复事件原样返回(isOccurrence: false);
58
62
  * 重复事件用 ICAL.RecurExpansion 在 [rangeStart, rangeEnd] 内展开,最多返回
59
63
  * maxOccurrences 个实例(isOccurrence: true + seriesStart)。
64
+ *
65
+ * 同一 VCALENDAR 里带 RECURRENCE-ID 的 VEVENT 是单次实例的覆盖(改期/改标题):
66
+ * 展开时用覆盖 VEVENT 替换对应原实例;原实例被 EXDATE 排除或原时间在窗口外时,
67
+ * 只要覆盖后的实例落在窗口内仍单独返回,避免实例被静默丢弃。
60
68
  * @param data - iCal 文本。
61
69
  * @param href - CalDAV 对象 href,作为稳定 uid。
62
70
  * @param etag - 服务器 ETag。
63
71
  * @param rangeStart - 查询窗口起始(ISO 8601)。
64
72
  * @param rangeEnd - 查询窗口结束(ISO 8601)。
65
73
  * @param maxOccurrences - 每个事件最多展开的实例数(防死循环)。
74
+ * @throws {ExpansionLimitError} 迭代次数超出预算仍未能到达查询窗口时抛出。
66
75
  */
67
76
  export declare function expandEventFromICal(data: string, href: string, etag: string | undefined, rangeStart: string, rangeEnd: string, maxOccurrences: number): CalendarEvent[];
68
77
  /** 生成随机 iCal UID(带 host 后缀,形如 UUID)。 */
69
78
  export declare function generateUid(): string;
70
79
  /** 把字段生成一段完整 iCal 文本(单个 VEVENT)。 */
71
80
  export declare function buildICalString(fields: EventFields): string;
81
+ /**
82
+ * 在原 iCal 文本上做字段级覆盖:保留原 VCALENDAR / VEVENT 的全部属性
83
+ * (ATTENDEE、ORGANIZER、EXDATE、STATUS、CATEGORIES、VALARM 及未知属性等),
84
+ * 只替换 changes 里显式给出的字段;原文本缺少 RFC 5545 必需属性时补齐
85
+ * (VCALENDAR 的 VERSION/PRODID,VEVENT 的 UID/DTSTAMP)。
86
+ * 解析失败或没有 VEVENT 时返回 null,由调用方回退到整条重建。
87
+ * @param data - 原 iCal 文本(通常来自服务器的 calendar-data)。
88
+ * @param changes - 需要覆盖的字段;未提供的字段保留原值。
89
+ */
90
+ export declare function updateICalString(data: string, changes: Partial<EventFields>): string | null;
package/lib/ical.js CHANGED
@@ -6,6 +6,13 @@
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import ICAL from 'ical.js';
9
+ /** 展开重复事件超出迭代预算:显式报错,避免把「没走到窗口」静默当成「窗口内没有实例」。 */
10
+ export class ExpansionLimitError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'ExpansionLimitError';
14
+ }
15
+ }
9
16
  function pad(value) {
10
17
  return value < 10 ? '0' + value : String(value);
11
18
  }
@@ -61,7 +68,7 @@ export function parseEventFromICal(data, href, etag) {
61
68
  catch {
62
69
  return null;
63
70
  }
64
- const vevent = vcal.getFirstSubcomponent('vevent');
71
+ const vevent = masterVevent(vcal);
65
72
  if (vevent === null)
66
73
  return null;
67
74
  return veventToEvent(vevent, href, etag);
@@ -114,6 +121,15 @@ function veventToEvent(vevent, href, etag) {
114
121
  };
115
122
  return event;
116
123
  }
124
+ /** 挑选系列主 VEVENT:优先不带 RECURRENCE-ID 的那个,避免把单次实例覆盖当成系列本身。 */
125
+ function masterVevent(vcal) {
126
+ const vevents = vcal.getAllSubcomponents('vevent');
127
+ return vevents.find((vevent) => !vevent.hasProperty('recurrence-id')) ?? vevents[0] ?? null;
128
+ }
129
+ /** 把实例时间规整成可比较的键:全天按日期,定时按 epoch 秒。 */
130
+ function recurrenceKey(time) {
131
+ return time.isDate === true ? 'D:' + icalTimeToIso(time) : 'T:' + time.toUnixTime();
132
+ }
117
133
  /** 由一次 occurrence 起始时间构造实例行。 */
118
134
  function buildOccurrence(base, occurrenceStart, duration) {
119
135
  const start = icalTimeToIso(occurrenceStart);
@@ -142,55 +158,123 @@ function buildOccurrence(base, occurrenceStart, duration) {
142
158
  ...(base.lastModified !== undefined ? { lastModified: base.lastModified } : {}),
143
159
  };
144
160
  }
161
+ /**
162
+ * 用带 RECURRENCE-ID 的覆盖 VEVENT 构造实例行:字段以覆盖 VEVENT 为准,
163
+ * 稳定标识(uid/href/etag)与 seriesStart 继承系列主 VEVENT。
164
+ * 覆盖 VEVENT 缺少可用 DTSTART 时用 RECURRENCE-ID 兜底,避免实例被静默丢弃。
165
+ */
166
+ function buildOverrideEvent(base, vevent) {
167
+ const parsed = veventToEvent(vevent, base.href, base.etag);
168
+ if (parsed !== null) {
169
+ return {
170
+ ...parsed,
171
+ uid: base.uid,
172
+ href: base.href,
173
+ isOccurrence: true,
174
+ seriesStart: base.start,
175
+ ...(parsed.icalUid === undefined && base.icalUid !== undefined ? { icalUid: base.icalUid } : {}),
176
+ };
177
+ }
178
+ const recurrenceId = timePropertyValue(vevent, 'recurrence-id');
179
+ if (recurrenceId === undefined)
180
+ return null;
181
+ return {
182
+ uid: base.uid,
183
+ href: base.href,
184
+ summary: asString(vevent.getFirstPropertyValue('summary')) ?? base.summary,
185
+ start: icalTimeToIso(recurrenceId),
186
+ end: icalTimeToIso(recurrenceId),
187
+ allDay: recurrenceId.isDate === true,
188
+ isOccurrence: true,
189
+ seriesStart: base.start,
190
+ ...(base.icalUid !== undefined ? { icalUid: base.icalUid } : {}),
191
+ ...(base.etag !== undefined ? { etag: base.etag } : {}),
192
+ ...(base.description !== undefined ? { description: base.description } : {}),
193
+ ...(base.location !== undefined ? { location: base.location } : {}),
194
+ };
195
+ }
145
196
  /**
146
197
  * 解析并(可选)展开一个 VEVENT:非重复事件原样返回(isOccurrence: false);
147
198
  * 重复事件用 ICAL.RecurExpansion 在 [rangeStart, rangeEnd] 内展开,最多返回
148
199
  * maxOccurrences 个实例(isOccurrence: true + seriesStart)。
200
+ *
201
+ * 同一 VCALENDAR 里带 RECURRENCE-ID 的 VEVENT 是单次实例的覆盖(改期/改标题):
202
+ * 展开时用覆盖 VEVENT 替换对应原实例;原实例被 EXDATE 排除或原时间在窗口外时,
203
+ * 只要覆盖后的实例落在窗口内仍单独返回,避免实例被静默丢弃。
149
204
  * @param data - iCal 文本。
150
205
  * @param href - CalDAV 对象 href,作为稳定 uid。
151
206
  * @param etag - 服务器 ETag。
152
207
  * @param rangeStart - 查询窗口起始(ISO 8601)。
153
208
  * @param rangeEnd - 查询窗口结束(ISO 8601)。
154
209
  * @param maxOccurrences - 每个事件最多展开的实例数(防死循环)。
210
+ * @throws {ExpansionLimitError} 迭代次数超出预算仍未能到达查询窗口时抛出。
155
211
  */
156
212
  export function expandEventFromICal(data, href, etag, rangeStart, rangeEnd, maxOccurrences) {
157
- const base = parseEventFromICal(data, href, etag);
158
- if (base === null)
159
- return [];
160
213
  let vcal;
161
214
  try {
162
215
  vcal = new ICAL.Component(ICAL.parse(data));
163
216
  }
164
217
  catch {
165
- return [{ ...base, isOccurrence: false }];
218
+ return [];
166
219
  }
167
- const vevent = vcal.getFirstSubcomponent('vevent');
168
- if (vevent === null)
169
- return [{ ...base, isOccurrence: false }];
170
- if (!vevent.hasProperty('rrule') && !vevent.hasProperty('rdate')) {
220
+ const master = masterVevent(vcal);
221
+ if (master === null)
222
+ return [];
223
+ const base = veventToEvent(master, href, etag);
224
+ if (base === null)
225
+ return [];
226
+ // 收集 RECURRENCE-ID -> 覆盖 VEVENT,展开时按原实例时间替换。
227
+ const overrides = new Map();
228
+ for (const candidate of vcal.getAllSubcomponents('vevent')) {
229
+ if (candidate === master)
230
+ continue;
231
+ const recurrenceId = timePropertyValue(candidate, 'recurrence-id');
232
+ if (recurrenceId === undefined)
233
+ continue;
234
+ overrides.set(recurrenceKey(recurrenceId), candidate);
235
+ }
236
+ if (!master.hasProperty('rrule') && !master.hasProperty('rdate')) {
171
237
  return [{ ...base, isOccurrence: false }];
172
238
  }
173
- const dtstart = timePropertyValue(vevent, 'dtstart');
239
+ const dtstart = timePropertyValue(master, 'dtstart');
174
240
  if (dtstart === undefined)
175
241
  return [{ ...base, isOccurrence: false }];
176
- const duration = veventDuration(vevent, dtstart);
177
- const expansion = new ICAL.RecurExpansion({ component: vevent, dtstart });
178
242
  const rangeStartMs = isoToEpochMs(rangeStart);
179
243
  const rangeEndMs = isoToEpochMs(rangeEnd);
244
+ const inRange = (event) => {
245
+ const startMs = isoToEpochMs(event.start);
246
+ return !Number.isNaN(startMs) && startMs >= rangeStartMs && startMs <= rangeEndMs;
247
+ };
248
+ const duration = veventDuration(master, dtstart);
249
+ const expansion = new ICAL.RecurExpansion({ component: master, dtstart });
180
250
  const occurrences = [];
251
+ const consumedOverrides = new Set();
252
+ const totalIterationCap = Math.max(100000, maxOccurrences * 1000);
253
+ let budgetExhausted = false;
181
254
  try {
182
- const totalIterationCap = Math.max(100000, maxOccurrences * 1000);
183
255
  let iterations = 0;
184
256
  while (occurrences.length < maxOccurrences) {
185
257
  iterations += 1;
186
- if (iterations > totalIterationCap)
258
+ if (iterations > totalIterationCap) {
259
+ budgetExhausted = true;
187
260
  break;
261
+ }
188
262
  const next = expansion.next();
189
263
  if (next === null || next === undefined)
190
264
  break;
191
265
  const occMs = next.toUnixTime() * 1000;
192
266
  if (occMs > rangeEndMs)
193
267
  break;
268
+ const key = recurrenceKey(next);
269
+ const overrideVevent = overrides.get(key);
270
+ if (overrideVevent !== undefined) {
271
+ // 该次实例已被单独覆盖:只输出覆盖后的字段与时间(覆盖后移出窗口则不再出现)。
272
+ consumedOverrides.add(key);
273
+ const overrideEvent = buildOverrideEvent(base, overrideVevent);
274
+ if (overrideEvent !== null && inRange(overrideEvent))
275
+ occurrences.push(overrideEvent);
276
+ continue;
277
+ }
194
278
  if (occMs < rangeStartMs)
195
279
  continue;
196
280
  occurrences.push(buildOccurrence(base, next, duration));
@@ -199,6 +283,22 @@ export function expandEventFromICal(data, href, etag, rangeStart, rangeEnd, maxO
199
283
  catch {
200
284
  // 规则无法满足或迭代异常时,返回已成功展开的部分实例。
201
285
  }
286
+ if (budgetExhausted) {
287
+ throw new ExpansionLimitError('重复事件「' + (base.summary !== '' ? base.summary : (base.icalUid ?? href)) + '」展开超过迭代上限(' +
288
+ totalIterationCap + ' 次):该系列从 ' + base.start + ' 开始,未能在预算内到达查询窗口。' +
289
+ '请缩小 start/end 时间范围,或为该 RRULE 添加 COUNT/UNTIL 限制。');
290
+ }
291
+ // EXDATE 已把原时间排除、或原时间在窗口外但覆盖后移入窗口:覆盖实例仍需单独返回。
292
+ for (const [key, overrideVevent] of overrides) {
293
+ if (consumedOverrides.has(key))
294
+ continue;
295
+ const overrideEvent = buildOverrideEvent(base, overrideVevent);
296
+ if (overrideEvent === null || !inRange(overrideEvent))
297
+ continue;
298
+ if (occurrences.length >= maxOccurrences)
299
+ break;
300
+ occurrences.push(overrideEvent);
301
+ }
202
302
  return occurrences;
203
303
  }
204
304
  /** 生成随机 iCal UID(带 host 后缀,形如 UUID)。 */
@@ -208,8 +308,12 @@ export function generateUid() {
208
308
  /** 把字段生成一段完整 iCal 文本(单个 VEVENT)。 */
209
309
  export function buildICalString(fields) {
210
310
  const vcal = new ICAL.Component('vcalendar');
311
+ // RFC 5545 必需属性:服务器不一定替我们补,创建路径显式输出。
312
+ vcal.addPropertyWithValue('version', '2.0');
313
+ vcal.addPropertyWithValue('prodid', '-//dsh-calendar//EN');
211
314
  const vevent = new ICAL.Component('vevent');
212
315
  vevent.addPropertyWithValue('uid', fields.icalUid ?? generateUid());
316
+ vevent.addPropertyWithValue('dtstamp', ICAL.Time.fromJSDate(new Date(), true));
213
317
  vevent.addPropertyWithValue('summary', fields.summary);
214
318
  const start = parseTime(fields.start, fields.allDay === true || isDateOnly(fields.start));
215
319
  const end = parseTime(fields.end, fields.allDay === true || isDateOnly(fields.end));
@@ -236,6 +340,109 @@ export function buildICalString(fields) {
236
340
  vcal.addSubcomponent(vevent);
237
341
  return vcal.toString();
238
342
  }
343
+ /** 把 ICAL.Time 转成全天 DATE 值(保留原年月日)。 */
344
+ function toDateValue(time) {
345
+ return ICAL.Time.fromDateString(pad(time.year) + '-' + pad(time.month) + '-' + pad(time.day));
346
+ }
347
+ /** 把 ICAL.Time 转成 UTC 零点的定时值(allDay 由 true 改 false 时使用)。 */
348
+ function toMidnightUtc(time) {
349
+ return ICAL.Time.fromDateTimeString(pad(time.year) + '-' + pad(time.month) + '-' + pad(time.day) + 'T00:00:00Z');
350
+ }
351
+ /** 显式改 allDay 时同步 DTSTART/DTEND 的 DATE / DATE-TIME 表示。 */
352
+ function convertTimeForAllDay(time, allDay) {
353
+ if (allDay)
354
+ return time.isDate === true ? time : toDateValue(time);
355
+ return time.isDate === true ? toMidnightUtc(time) : time;
356
+ }
357
+ /** 在既有 VEVENT 上覆盖开始/结束时间(含 allDay 表示转换),未显式给出的时间不动。 */
358
+ function applyEventTimes(vevent, changes) {
359
+ const existingStart = timePropertyValue(vevent, 'dtstart');
360
+ const existingEnd = timePropertyValue(vevent, 'dtend');
361
+ const allDay = changes.allDay ?? (existingStart?.isDate === true);
362
+ let start;
363
+ if (changes.start !== undefined) {
364
+ start = parseTime(changes.start, allDay || isDateOnly(changes.start));
365
+ }
366
+ else if (existingStart !== undefined && changes.allDay !== undefined) {
367
+ start = convertTimeForAllDay(existingStart, allDay);
368
+ }
369
+ if (start !== undefined)
370
+ vevent.updatePropertyWithValue('dtstart', start);
371
+ let end;
372
+ if (changes.end !== undefined) {
373
+ end = parseTime(changes.end, allDay || isDateOnly(changes.end));
374
+ }
375
+ else if (existingEnd !== undefined && changes.allDay !== undefined) {
376
+ end = convertTimeForAllDay(existingEnd, allDay);
377
+ }
378
+ if (end !== undefined) {
379
+ vevent.updatePropertyWithValue('dtend', end);
380
+ // DTEND 与 DURATION 互斥;显式给结束时间时移除原 DURATION,避免双份定义。
381
+ vevent.removeProperty('duration');
382
+ }
383
+ }
384
+ /**
385
+ * 在原 iCal 文本上做字段级覆盖:保留原 VCALENDAR / VEVENT 的全部属性
386
+ * (ATTENDEE、ORGANIZER、EXDATE、STATUS、CATEGORIES、VALARM 及未知属性等),
387
+ * 只替换 changes 里显式给出的字段;原文本缺少 RFC 5545 必需属性时补齐
388
+ * (VCALENDAR 的 VERSION/PRODID,VEVENT 的 UID/DTSTAMP)。
389
+ * 解析失败或没有 VEVENT 时返回 null,由调用方回退到整条重建。
390
+ * @param data - 原 iCal 文本(通常来自服务器的 calendar-data)。
391
+ * @param changes - 需要覆盖的字段;未提供的字段保留原值。
392
+ */
393
+ export function updateICalString(data, changes) {
394
+ let vcal;
395
+ try {
396
+ vcal = new ICAL.Component(ICAL.parse(data));
397
+ }
398
+ catch {
399
+ return null;
400
+ }
401
+ const vevent = masterVevent(vcal);
402
+ if (vevent === null)
403
+ return null;
404
+ if (!vcal.hasProperty('version'))
405
+ vcal.addPropertyWithValue('version', '2.0');
406
+ if (!vcal.hasProperty('prodid'))
407
+ vcal.addPropertyWithValue('prodid', '-//dsh-calendar//EN');
408
+ if (!vevent.hasProperty('uid')) {
409
+ vevent.addPropertyWithValue('uid', changes.icalUid ?? generateUid());
410
+ }
411
+ else if (changes.icalUid !== undefined) {
412
+ vevent.updatePropertyWithValue('uid', changes.icalUid);
413
+ }
414
+ if (!vevent.hasProperty('dtstamp')) {
415
+ vevent.addPropertyWithValue('dtstamp', ICAL.Time.fromJSDate(new Date(), true));
416
+ }
417
+ // 字段级覆盖:updatePropertyWithValue 保留原属性参数(如 ATTENDEE 的 CN/PARTSTAT)。
418
+ if (changes.summary !== undefined)
419
+ vevent.updatePropertyWithValue('summary', changes.summary);
420
+ if (changes.description !== undefined)
421
+ vevent.updatePropertyWithValue('description', changes.description);
422
+ if (changes.location !== undefined)
423
+ vevent.updatePropertyWithValue('location', changes.location);
424
+ if (changes.rrule !== undefined) {
425
+ const rule = changes.rrule.trim();
426
+ if (rule === '') {
427
+ vevent.removeProperty('rrule');
428
+ }
429
+ else {
430
+ if (!/^FREQ=/i.test(rule)) {
431
+ throw new Error('rrule 格式无效:' + changes.rrule + '(应为 RFC 5545 RRULE,如 FREQ=WEEKLY;COUNT=4)');
432
+ }
433
+ try {
434
+ vevent.updatePropertyWithValue('rrule', ICAL.Recur.fromString(rule));
435
+ }
436
+ catch (error) {
437
+ throw new Error('rrule 格式无效:' + changes.rrule);
438
+ }
439
+ }
440
+ }
441
+ if (changes.start !== undefined || changes.end !== undefined || changes.allDay !== undefined) {
442
+ applyEventTimes(vevent, changes);
443
+ }
444
+ return vcal.toString();
445
+ }
239
446
  /** 把 ISO 字符串解析成 ICAL.Time(全天 YYYY-MM-DD 或转 UTC 的定时时间)。 */
240
447
  function parseTime(value, allDay) {
241
448
  if (allDay || isDateOnly(value)) {
package/lib/tools.js CHANGED
@@ -302,9 +302,11 @@ export function buildCalendarTools(config, env = process.env) {
302
302
  };
303
303
  const search = {
304
304
  name: 'calendar_search',
305
- description: '按关键词搜索日历事件(客户端过滤:匹配标题、描述、地点与 iCal UID,不区分大小写)。query 必填,limit 可选(默认 50)。返回每个事件的稳定标识 uid。',
305
+ description: '按关键词搜索日历事件(客户端过滤:匹配标题、描述、地点与 iCal UID,不区分大小写)。只查询 start~end 窗口内的事件(start/end 缺省为当前时间前后各 1 年),query 必填,limit 可选(默认 50)。返回每个事件的稳定标识 uid。',
306
306
  parameters: compileParameters({
307
307
  query: { type: 'string', required: true, description: '搜索关键词(必填)。' },
308
+ start: { type: 'string', description: '查询窗口起始时间(ISO 8601,含时区偏移)。缺省为当前时间前 1 年。' },
309
+ end: { type: 'string', description: '查询窗口结束时间(ISO 8601,含时区偏移)。缺省为当前时间后 1 年。' },
308
310
  limit: { type: 'integer', description: '最多返回条数(可选,默认 50,自动 clamp 到 1-200)。' },
309
311
  }),
310
312
  output: {
@@ -330,7 +332,14 @@ export function buildCalendarTools(config, env = process.env) {
330
332
  const input = asRecord(args);
331
333
  const query = requiredString(input, 'query', '搜索关键词');
332
334
  const limit = clampedInteger(input, 'limit', 50, 1, 200);
333
- const all = await service().all(executionSignal(exec));
335
+ const now = new Date();
336
+ const yearMs = 365 * 24 * 3600 * 1000;
337
+ const start = optionalString(input, 'start') ?? isoNoMillis(new Date(now.getTime() - yearMs).toISOString());
338
+ const end = optionalString(input, 'end') ?? isoNoMillis(new Date(now.getTime() + yearMs).toISOString());
339
+ assertIsoTime(start, 'start');
340
+ assertIsoTime(end, 'end');
341
+ assertTimeRange(start, end);
342
+ const all = await service().all(executionSignal(exec), { start, end });
334
343
  const matched = sortEvents(all).filter(buildSearchFilter(query)).slice(0, limit);
335
344
  return { query, count: matched.length, events: matched };
336
345
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-calendar",
3
- "version": "0.5.3",
4
- "description": "DeepSeek Harness 日历插件:CalDAV 日程查询、创建、修改、删除与搜索,支持 Google OAuth 2.0、iCloud、Nextcloud、自定义服务及离线配置自检。",
3
+ "version": "0.5.4",
4
+ "description": "DSH 日历插件:CalDAV 日程查询、创建、修改、删除与搜索,支持 Google OAuth2、iCloud、Nextcloud 与自建服务。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -17,7 +17,8 @@
17
17
  "lib",
18
18
  "cordis.patch.yml",
19
19
  "README.md",
20
- "README.en.md"
20
+ "README.en.md",
21
+ "CHANGELOG.md"
21
22
  ],
22
23
  "scripts": {
23
24
  "build": "tsc -p tsconfig.json",