dsh-calendar 0.1.0 → 0.2.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/README.en.md +119 -0
- package/README.md +11 -2
- package/lib/caldav.d.ts +7 -2
- package/lib/caldav.js +16 -4
- package/lib/ical.d.ts +19 -3
- package/lib/ical.js +99 -1
- package/lib/tools.js +17 -2
- package/package.json +3 -2
package/README.en.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# dsh-calendar
|
|
2
|
+
|
|
3
|
+
DSH community plugin: read/write calendar events via CalDAV. Provides 5 model-facing tools (calendar_list / calendar_create / calendar_update / calendar_delete / calendar_search), supporting Google / iCloud / Nextcloud and any CalDAV server. This round is a node half-body with no settings-page UI; all configuration goes through the profile's cordis.patch.yml.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
dsh plugin --profile web add dsh-calendar
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
After installing, restart dsh. The plugin inserts a `calendar` config line into the profile (see this package's cordis.patch.yml). The default provider is custom with no credentials filled in; the plugin still loads, but tools throw a Chinese guidance error when called, prompting you to complete the configuration.
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
All configuration lives in your profile's cordis.patch.yml; override the `calendar` line by id (overriding replaces that line's config wholesale). Common fields:
|
|
16
|
+
|
|
17
|
+
- `provider`: google | icloud | nextcloud | custom
|
|
18
|
+
- `caldavUrl`: full calendar collection URL (required for custom / icloud; google / nextcloud can also override the preset manually)
|
|
19
|
+
- `username`: CalDAV account (account email for Google / iCloud)
|
|
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
|
+
- `calendarId`: google-specific, the calendar ID (usually your email)
|
|
22
|
+
- `host` / `user` / `calendar`: nextcloud-specific
|
|
23
|
+
|
|
24
|
+
### Google example
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
- id: calendar
|
|
28
|
+
name: dsh-calendar
|
|
29
|
+
config:
|
|
30
|
+
provider: google
|
|
31
|
+
username: you@gmail.com
|
|
32
|
+
calendarId: you@gmail.com
|
|
33
|
+
# password 推荐用环境变量 DSH_CALENDAR_PASSWORD
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Google's CalDAV collection URL is assembled by the plugin: `https://apidata.googleusercontent.com/caldav/v2/<calendarId>/events`.
|
|
37
|
+
|
|
38
|
+
### iCloud example
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
- id: calendar
|
|
42
|
+
name: dsh-calendar
|
|
43
|
+
config:
|
|
44
|
+
provider: icloud
|
|
45
|
+
username: you@icloud.com
|
|
46
|
+
caldavUrl: https://caldav.icloud.com/123456789/calendars/<日历ID>/
|
|
47
|
+
# password 推荐用环境变量 DSH_CALENDAR_PASSWORD
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
iCloud needs the full calendar collection URL (with your user ID and calendar ID); you can find the specific calendar address in the calendar CalDAV settings on icloud.com.
|
|
51
|
+
|
|
52
|
+
### Nextcloud example
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
- id: calendar
|
|
56
|
+
name: dsh-calendar
|
|
57
|
+
config:
|
|
58
|
+
provider: nextcloud
|
|
59
|
+
username: alice
|
|
60
|
+
host: https://cloud.example.com
|
|
61
|
+
user: alice
|
|
62
|
+
calendar: personal
|
|
63
|
+
# password 推荐用环境变量 DSH_CALENDAR_PASSWORD
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The plugin assembles: `https://cloud.example.com/remote.php/dav/calendars/alice/personal/`.
|
|
67
|
+
|
|
68
|
+
### Custom CalDAV example
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
- id: calendar
|
|
72
|
+
name: dsh-calendar
|
|
73
|
+
config:
|
|
74
|
+
provider: custom
|
|
75
|
+
caldavUrl: https://dav.example.com/calendars/me/work/
|
|
76
|
+
username: me
|
|
77
|
+
# password 推荐用环境变量 DSH_CALENDAR_PASSWORD
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## App-specific password guidance
|
|
81
|
+
|
|
82
|
+
Google: sign in to myaccount.google.com → Security → 2-Step Verification (must be enabled first) → App passwords, choose "Other" to generate a 16-character password, and fill it into `password` or `DSH_CALENDAR_PASSWORD`. You cannot use your Google login password.
|
|
83
|
+
|
|
84
|
+
iCloud: sign in to appleid.apple.com → Sign-In and Security → App-Specific Passwords, generate one and fill it into `password` or `DSH_CALENDAR_PASSWORD`. You cannot use your Apple ID password.
|
|
85
|
+
|
|
86
|
+
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
|
+
## Tool reference
|
|
89
|
+
|
|
90
|
+
- `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`
|
|
91
|
+
- `calendar_create`: create an event (summary/start/end required; description/location/allDay optional)
|
|
92
|
+
- `calendar_update`: edit an event by uid (summary/start/end/description/location/allDay optional; omitted fields keep their original values)
|
|
93
|
+
- `calendar_delete`: delete an event by uid
|
|
94
|
+
- `calendar_search`: search events by keyword (client-side filter over title/description/location/UID, case-insensitive)
|
|
95
|
+
|
|
96
|
+
The stable event identifier `uid` is the CalDAV href (full object URL); `calendar_update` / `calendar_delete` use it.
|
|
97
|
+
|
|
98
|
+
## Time and timezone
|
|
99
|
+
|
|
100
|
+
Input and output are uniformly ISO 8601. Timed events are output in UTC (e.g. `2025-01-15T01:00:00Z`); all-day events output `YYYY-MM-DD`. Input may carry a timezone offset (e.g. `2025-01-15T09:00:00+08:00`); the plugin converts to UTC internally for storage.
|
|
101
|
+
|
|
102
|
+
## Known limitations
|
|
103
|
+
|
|
104
|
+
- 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).
|
|
105
|
+
- 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).
|
|
106
|
+
- No OAuth: Basic auth only (app-specific password); Google / iCloud OAuth login flows are not supported.
|
|
107
|
+
- 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.
|
|
108
|
+
- No settings-page UI: this round is a node half-body; config only via cordis.patch.yml, no Web settings page.
|
|
109
|
+
- Calendar discovery: iCloud requires manually filling the full calendar collection URL; no principal auto-discovery or multi-calendar selection.
|
|
110
|
+
- Cancellation/timeout: tools rely on `timeoutMs` (60 seconds) for overall timeout; AbortSignal is not propagated to individual network requests.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pnpm install
|
|
116
|
+
pnpm test # 构建 + node --test
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Build output in `lib/`; tests in `test/*.test.mjs` (no real account needed).
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
[English](README.en.md)
|
|
2
|
+
|
|
1
3
|
# dsh-calendar
|
|
2
4
|
|
|
3
5
|
DSH 社区插件:通过 CalDAV 读写日历事件。提供 5 个面向模型的工具(calendar_list / calendar_create / calendar_update / calendar_delete / calendar_search),支持 Google / iCloud / Nextcloud 及任意 CalDAV 服务器。本轮为 node 半身,不含设置页 UI,配置全部走 profile 的 cordis.patch.yml。
|
|
@@ -87,7 +89,7 @@ iCloud:登录 appleid.apple.com → 登录与安全 → App 专用密码,生
|
|
|
87
89
|
|
|
88
90
|
## 工具清单
|
|
89
91
|
|
|
90
|
-
- `calendar_list`:列出某时间段事件(start/end,ISO 8601,缺省未来 7
|
|
92
|
+
- `calendar_list`:列出某时间段事件(start/end,ISO 8601,缺省未来 7 天)。默认展开重复事件(`expand` 默认 true,`maxOccurrences` 默认 30、clamp 1-200):每个实例独立成行,带 `isOccurrence: true` 与 `seriesStart`;非重复事件保持 `isOccurrence: false`。`expand=false` 时重复事件按原始单条返回并带 `rrule`
|
|
91
93
|
- `calendar_create`:新建事件(summary/start/end 必填,description/location/allDay 可选)
|
|
92
94
|
- `calendar_update`:按 uid 改事件(summary/start/end/description/location/allDay 可选,未提供保留原值)
|
|
93
95
|
- `calendar_delete`:按 uid 删事件
|
|
@@ -101,7 +103,8 @@ iCloud:登录 appleid.apple.com → 登录与安全 → App 专用密码,生
|
|
|
101
103
|
|
|
102
104
|
## 已知限制
|
|
103
105
|
|
|
104
|
-
-
|
|
106
|
+
- 重复事件展开:calendar_list 默认用 ICAL.RecurExpansion 展开 RRULE(`expand=true`),受 `maxOccurrences` 封顶;calendar_search 仍返回原始系列(不展开)。
|
|
107
|
+
- 不支持单次实例的改/删:calendar_update / calendar_delete 针对整个重复系列(按 uid 操作),无法只修改或删除某一次发生(不支持 RECURRENCE-ID 实例级操作)。
|
|
105
108
|
- 不做 OAuth:仅支持 Basic 认证(应用专用密码),不支持 Google / iCloud 的 OAuth 登录流程。
|
|
106
109
|
- 时区规则:带 TZID(命名时区)的事件输出会转成 UTC(Z);全天边界、夏令时等复杂时区规则不做精细化处理。
|
|
107
110
|
- 无设置页 UI:本轮为 node 半身,配置只走 cordis.patch.yml,不提供 Web 设置页。
|
|
@@ -116,3 +119,9 @@ pnpm test # 构建 + node --test
|
|
|
116
119
|
```
|
|
117
120
|
|
|
118
121
|
构建产物在 `lib/`,测试在 `test/*.test.mjs`(不依赖真实账号)。
|
|
122
|
+
|
|
123
|
+
## 相关插件
|
|
124
|
+
|
|
125
|
+
- [dsh-calendar](https://github.com/STARDUSTLC666/dsh-calendar) — CalDAV 日历五件套
|
|
126
|
+
- [dsh-slack](https://github.com/STARDUSTLC666/dsh-slack) — Slack 通知/收件箱
|
|
127
|
+
- [dsh-dingtalk](https://github.com/STARDUSTLC666/dsh-dingtalk) — 钉钉群通知(零依赖)
|
package/lib/caldav.d.ts
CHANGED
|
@@ -19,11 +19,16 @@ export declare class CalendarService {
|
|
|
19
19
|
constructor(config: ResolvedConfig);
|
|
20
20
|
private client;
|
|
21
21
|
private calendar;
|
|
22
|
-
/**
|
|
23
|
-
list(startIso: string, endIso: string
|
|
22
|
+
/** 列出某时间段内的事件;expand 为 true 时在窗口内展开 RRULE。 */
|
|
23
|
+
list(startIso: string, endIso: string, options?: {
|
|
24
|
+
expand?: boolean;
|
|
25
|
+
maxOccurrences?: number;
|
|
26
|
+
}): Promise<CalendarEvent[]>;
|
|
24
27
|
/** 列出全部事件(客户端过滤用)。 */
|
|
25
28
|
all(): Promise<CalendarEvent[]>;
|
|
26
29
|
private toEvents;
|
|
30
|
+
/** 列出并展开:每个对象经 expandEventFromICal 展开为若干实例行。 */
|
|
31
|
+
private toExpandedEvents;
|
|
27
32
|
/** 按 uid(href)找到服务器对象(含 etag 与原始 data)。 */
|
|
28
33
|
private findObject;
|
|
29
34
|
/** 新建事件,返回带 href/uid 的事件。 */
|
package/lib/caldav.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @module dsh-calendar/caldav
|
|
6
6
|
*/
|
|
7
7
|
import { createDAVClient } from 'tsdav';
|
|
8
|
-
import { buildICalString, generateUid, parseEventFromICal, } from './ical.js';
|
|
8
|
+
import { buildICalString, expandEventFromICal, generateUid, parseEventFromICal, } from './ical.js';
|
|
9
9
|
/** CalDAV 操作错误:带中文指引。 */
|
|
10
10
|
export class CalDAVError extends Error {
|
|
11
11
|
status;
|
|
@@ -53,8 +53,10 @@ export class CalendarService {
|
|
|
53
53
|
calendar() {
|
|
54
54
|
return { url: this.collectionUrl };
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
57
|
-
async list(startIso, endIso) {
|
|
56
|
+
/** 列出某时间段内的事件;expand 为 true 时在窗口内展开 RRULE。 */
|
|
57
|
+
async list(startIso, endIso, options) {
|
|
58
|
+
const expand = options?.expand !== false;
|
|
59
|
+
const maxOccurrences = options?.maxOccurrences ?? 30;
|
|
58
60
|
try {
|
|
59
61
|
const client = await this.client();
|
|
60
62
|
const objects = await client.fetchCalendarObjects({
|
|
@@ -62,7 +64,9 @@ export class CalendarService {
|
|
|
62
64
|
timeRange: { start: startIso, end: endIso },
|
|
63
65
|
urlFilter: (url) => typeof url === 'string' && url.length > 0,
|
|
64
66
|
});
|
|
65
|
-
return
|
|
67
|
+
return expand
|
|
68
|
+
? this.toExpandedEvents(objects, startIso, endIso, maxOccurrences)
|
|
69
|
+
: this.toEvents(objects);
|
|
66
70
|
}
|
|
67
71
|
catch (error) {
|
|
68
72
|
throw translateError(error, '读取日历');
|
|
@@ -91,6 +95,14 @@ export class CalendarService {
|
|
|
91
95
|
}
|
|
92
96
|
return events;
|
|
93
97
|
}
|
|
98
|
+
/** 列出并展开:每个对象经 expandEventFromICal 展开为若干实例行。 */
|
|
99
|
+
toExpandedEvents(objects, startIso, endIso, maxOccurrences) {
|
|
100
|
+
const events = [];
|
|
101
|
+
for (const object of objects) {
|
|
102
|
+
events.push(...expandEventFromICal(String(object.data ?? ''), object.url, object.etag, startIso, endIso, maxOccurrences));
|
|
103
|
+
}
|
|
104
|
+
return events;
|
|
105
|
+
}
|
|
94
106
|
/** 按 uid(href)找到服务器对象(含 etag 与原始 data)。 */
|
|
95
107
|
async findObject(uid) {
|
|
96
108
|
const client = await this.client();
|
package/lib/ical.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* iCal 解析与生成:用 ical.js 处理 VEVENT
|
|
2
|
+
* iCal 解析与生成:用 ical.js 处理 VEVENT 的字段提取、round-trip 序列化与重复展开。
|
|
3
3
|
* 输入输出统一 ISO 8601(含时区偏移):全天事件用 YYYY-MM-DD,定时事件转 UTC(Z)。
|
|
4
4
|
*
|
|
5
5
|
* @module dsh-calendar/ical
|
|
6
6
|
*/
|
|
7
|
-
/**
|
|
7
|
+
/** 一个暴露给模型的日历事件(或重复系列的一个展开实例)。 */
|
|
8
8
|
export interface CalendarEvent {
|
|
9
9
|
/** 稳定标识(CalDAV href),calendar_update / calendar_delete 用它。 */
|
|
10
10
|
uid: string;
|
|
@@ -22,8 +22,12 @@ export interface CalendarEvent {
|
|
|
22
22
|
/** 结束时间,同上;无 DTEND/DURATION 时等于 start。 */
|
|
23
23
|
end: string;
|
|
24
24
|
allDay: boolean;
|
|
25
|
-
/**
|
|
25
|
+
/** 重复规则原样返回(未展开时);展开后的实例不带此字段。 */
|
|
26
26
|
rrule?: string;
|
|
27
|
+
/** 是否为重复系列展开出的实例;非重复事件为 false。 */
|
|
28
|
+
isOccurrence?: boolean;
|
|
29
|
+
/** 系列原开始时间(仅 isOccurrence 为 true 的实例存在)。 */
|
|
30
|
+
seriesStart?: string;
|
|
27
31
|
status?: string;
|
|
28
32
|
url?: string;
|
|
29
33
|
created?: string;
|
|
@@ -47,6 +51,18 @@ export interface EventFields {
|
|
|
47
51
|
* @param etag - 服务器 ETag。
|
|
48
52
|
*/
|
|
49
53
|
export declare function parseEventFromICal(data: string, href: string, etag?: string): CalendarEvent | null;
|
|
54
|
+
/**
|
|
55
|
+
* 解析并(可选)展开一个 VEVENT:非重复事件原样返回(isOccurrence: false);
|
|
56
|
+
* 重复事件用 ICAL.RecurExpansion 在 [rangeStart, rangeEnd] 内展开,最多返回
|
|
57
|
+
* maxOccurrences 个实例(isOccurrence: true + seriesStart)。
|
|
58
|
+
* @param data - iCal 文本。
|
|
59
|
+
* @param href - CalDAV 对象 href,作为稳定 uid。
|
|
60
|
+
* @param etag - 服务器 ETag。
|
|
61
|
+
* @param rangeStart - 查询窗口起始(ISO 8601)。
|
|
62
|
+
* @param rangeEnd - 查询窗口结束(ISO 8601)。
|
|
63
|
+
* @param maxOccurrences - 每个事件最多展开的实例数(防死循环)。
|
|
64
|
+
*/
|
|
65
|
+
export declare function expandEventFromICal(data: string, href: string, etag: string | undefined, rangeStart: string, rangeEnd: string, maxOccurrences: number): CalendarEvent[];
|
|
50
66
|
/** 生成随机 iCal UID(带 host 后缀,形如 UUID)。 */
|
|
51
67
|
export declare function generateUid(): string;
|
|
52
68
|
/** 把字段生成一段完整 iCal 文本(单个 VEVENT)。 */
|
package/lib/ical.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* iCal 解析与生成:用 ical.js 处理 VEVENT
|
|
2
|
+
* iCal 解析与生成:用 ical.js 处理 VEVENT 的字段提取、round-trip 序列化与重复展开。
|
|
3
3
|
* 输入输出统一 ISO 8601(含时区偏移):全天事件用 YYYY-MM-DD,定时事件转 UTC(Z)。
|
|
4
4
|
*
|
|
5
5
|
* @module dsh-calendar/ical
|
|
@@ -31,10 +31,22 @@ function icalTimeToIso(time) {
|
|
|
31
31
|
}
|
|
32
32
|
return time.toJSDate().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
33
33
|
}
|
|
34
|
+
/** 把 ISO 字符串转成 epoch 毫秒,用于时间窗口比较。 */
|
|
35
|
+
function isoToEpochMs(value) {
|
|
36
|
+
return new Date(value).getTime();
|
|
37
|
+
}
|
|
34
38
|
function timePropertyValue(vevent, name) {
|
|
35
39
|
const value = vevent.getFirstPropertyValue(name);
|
|
36
40
|
return value instanceof ICAL.Time ? value : undefined;
|
|
37
41
|
}
|
|
42
|
+
/** 计算事件时长:优先 DTEND,其次 DURATION;两者皆无返回 null(时长视为 0)。 */
|
|
43
|
+
function veventDuration(vevent, dtstart) {
|
|
44
|
+
const dtend = timePropertyValue(vevent, 'dtend');
|
|
45
|
+
if (dtend !== undefined)
|
|
46
|
+
return dtend.subtractDate(dtstart);
|
|
47
|
+
const duration = vevent.getFirstPropertyValue('duration');
|
|
48
|
+
return duration instanceof ICAL.Duration ? duration : null;
|
|
49
|
+
}
|
|
38
50
|
/**
|
|
39
51
|
* 解析一段 iCal 文本中的首个 VEVENT 为 CalendarEvent;解析失败返回 null。
|
|
40
52
|
* @param data - iCal 文本(通常来自服务器的 calendar-data)。
|
|
@@ -52,6 +64,10 @@ export function parseEventFromICal(data, href, etag) {
|
|
|
52
64
|
const vevent = vcal.getFirstSubcomponent('vevent');
|
|
53
65
|
if (vevent === null)
|
|
54
66
|
return null;
|
|
67
|
+
return veventToEvent(vevent, href, etag);
|
|
68
|
+
}
|
|
69
|
+
/** 把单个 VEVENT 组件映射为 CalendarEvent(不展开重复)。 */
|
|
70
|
+
function veventToEvent(vevent, href, etag) {
|
|
55
71
|
const icalUid = asString(vevent.getFirstPropertyValue('uid'));
|
|
56
72
|
const summary = asString(vevent.getFirstPropertyValue('summary')) ?? '';
|
|
57
73
|
const description = asString(vevent.getFirstPropertyValue('description'));
|
|
@@ -98,6 +114,88 @@ export function parseEventFromICal(data, href, etag) {
|
|
|
98
114
|
};
|
|
99
115
|
return event;
|
|
100
116
|
}
|
|
117
|
+
/** 由一次 occurrence 起始时间构造实例行。 */
|
|
118
|
+
function buildOccurrence(base, occurrenceStart, duration) {
|
|
119
|
+
const start = icalTimeToIso(occurrenceStart);
|
|
120
|
+
let end = start;
|
|
121
|
+
if (duration !== null) {
|
|
122
|
+
const endTime = occurrenceStart.clone();
|
|
123
|
+
endTime.addDuration(duration);
|
|
124
|
+
end = icalTimeToIso(endTime);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
uid: base.uid,
|
|
128
|
+
href: base.href,
|
|
129
|
+
summary: base.summary,
|
|
130
|
+
start,
|
|
131
|
+
end,
|
|
132
|
+
allDay: base.allDay,
|
|
133
|
+
isOccurrence: true,
|
|
134
|
+
seriesStart: base.start,
|
|
135
|
+
...(base.icalUid !== undefined ? { icalUid: base.icalUid } : {}),
|
|
136
|
+
...(base.etag !== undefined ? { etag: base.etag } : {}),
|
|
137
|
+
...(base.description !== undefined ? { description: base.description } : {}),
|
|
138
|
+
...(base.location !== undefined ? { location: base.location } : {}),
|
|
139
|
+
...(base.status !== undefined ? { status: base.status } : {}),
|
|
140
|
+
...(base.url !== undefined ? { url: base.url } : {}),
|
|
141
|
+
...(base.created !== undefined ? { created: base.created } : {}),
|
|
142
|
+
...(base.lastModified !== undefined ? { lastModified: base.lastModified } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 解析并(可选)展开一个 VEVENT:非重复事件原样返回(isOccurrence: false);
|
|
147
|
+
* 重复事件用 ICAL.RecurExpansion 在 [rangeStart, rangeEnd] 内展开,最多返回
|
|
148
|
+
* maxOccurrences 个实例(isOccurrence: true + seriesStart)。
|
|
149
|
+
* @param data - iCal 文本。
|
|
150
|
+
* @param href - CalDAV 对象 href,作为稳定 uid。
|
|
151
|
+
* @param etag - 服务器 ETag。
|
|
152
|
+
* @param rangeStart - 查询窗口起始(ISO 8601)。
|
|
153
|
+
* @param rangeEnd - 查询窗口结束(ISO 8601)。
|
|
154
|
+
* @param maxOccurrences - 每个事件最多展开的实例数(防死循环)。
|
|
155
|
+
*/
|
|
156
|
+
export function expandEventFromICal(data, href, etag, rangeStart, rangeEnd, maxOccurrences) {
|
|
157
|
+
const base = parseEventFromICal(data, href, etag);
|
|
158
|
+
if (base === null)
|
|
159
|
+
return [];
|
|
160
|
+
let vcal;
|
|
161
|
+
try {
|
|
162
|
+
vcal = new ICAL.Component(ICAL.parse(data));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return [{ ...base, isOccurrence: false }];
|
|
166
|
+
}
|
|
167
|
+
const vevent = vcal.getFirstSubcomponent('vevent');
|
|
168
|
+
if (vevent === null)
|
|
169
|
+
return [{ ...base, isOccurrence: false }];
|
|
170
|
+
if (!vevent.hasProperty('rrule') && !vevent.hasProperty('rdate')) {
|
|
171
|
+
return [{ ...base, isOccurrence: false }];
|
|
172
|
+
}
|
|
173
|
+
const dtstart = timePropertyValue(vevent, 'dtstart');
|
|
174
|
+
if (dtstart === undefined)
|
|
175
|
+
return [{ ...base, isOccurrence: false }];
|
|
176
|
+
const duration = veventDuration(vevent, dtstart);
|
|
177
|
+
const expansion = new ICAL.RecurExpansion({ component: vevent, dtstart });
|
|
178
|
+
const rangeStartMs = isoToEpochMs(rangeStart);
|
|
179
|
+
const rangeEndMs = isoToEpochMs(rangeEnd);
|
|
180
|
+
const occurrences = [];
|
|
181
|
+
try {
|
|
182
|
+
while (occurrences.length < maxOccurrences) {
|
|
183
|
+
const next = expansion.next();
|
|
184
|
+
if (next === null || next === undefined)
|
|
185
|
+
break;
|
|
186
|
+
const occMs = next.toUnixTime() * 1000;
|
|
187
|
+
if (occMs > rangeEndMs)
|
|
188
|
+
break;
|
|
189
|
+
if (occMs < rangeStartMs)
|
|
190
|
+
continue;
|
|
191
|
+
occurrences.push(buildOccurrence(base, next, duration));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// 规则无法满足或迭代异常时,返回已成功展开的部分实例。
|
|
196
|
+
}
|
|
197
|
+
return occurrences;
|
|
198
|
+
}
|
|
101
199
|
/** 生成随机 iCal UID(带 host 后缀,形如 UUID)。 */
|
|
102
200
|
export function generateUid() {
|
|
103
201
|
return randomUUID();
|
package/lib/tools.js
CHANGED
|
@@ -31,6 +31,17 @@ function optionalInteger(args, key) {
|
|
|
31
31
|
const value = args[key];
|
|
32
32
|
return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
|
|
33
33
|
}
|
|
34
|
+
function booleanWithDefault(args, key, fallback) {
|
|
35
|
+
const value = args[key];
|
|
36
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
37
|
+
}
|
|
38
|
+
/** 读取整数并 clamp 到 [min, max];非法值返回 fallback。 */
|
|
39
|
+
function clampedInteger(args, key, fallback, min, max) {
|
|
40
|
+
const value = args[key];
|
|
41
|
+
if (typeof value !== 'number' || !Number.isInteger(value))
|
|
42
|
+
return fallback;
|
|
43
|
+
return Math.min(max, Math.max(min, value));
|
|
44
|
+
}
|
|
34
45
|
function assertIsoTime(value, label) {
|
|
35
46
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
36
47
|
return;
|
|
@@ -60,10 +71,12 @@ export function buildCalendarTools(config) {
|
|
|
60
71
|
const service = () => new CalendarService(resolveConfig(config));
|
|
61
72
|
const list = {
|
|
62
73
|
name: 'calendar_list',
|
|
63
|
-
description: '列出某时间段内的日历事件(默认未来 7 天)。start/end 为 ISO 8601 时间(含时区偏移,如 2025-01-01T09:00:00+08:00),全天事件返回 YYYY-MM-DD
|
|
74
|
+
description: '列出某时间段内的日历事件(默认未来 7 天)。start/end 为 ISO 8601 时间(含时区偏移,如 2025-01-01T09:00:00+08:00),全天事件返回 YYYY-MM-DD。默认展开重复事件(RRULE):每个实例作为独立行返回,start/end 为该次发生时间,并带 isOccurrence=true 与 seriesStart(系列原开始时间);非重复事件保持单行且 isOccurrence=false。expand=false 时重复事件按原始单条返回并带 rrule 字段。maxOccurrences 为每个重复事件的展开次数上限。返回每个事件的稳定标识 uid,供 calendar_update / calendar_delete 使用。',
|
|
64
75
|
parameters: compileParameters({
|
|
65
76
|
start: { type: 'string', description: '起始时间(ISO 8601,含时区偏移)。缺省为当前时间(UTC)。' },
|
|
66
77
|
end: { type: 'string', description: '结束时间(ISO 8601,含时区偏移)。缺省为 start 后 7 天。' },
|
|
78
|
+
expand: { type: 'boolean', description: '是否展开重复事件(RRULE)。默认 true;设为 false 时重复事件按原始单条返回并带 rrule 字段。' },
|
|
79
|
+
maxOccurrences: { type: 'integer', description: '每个重复事件最多展开的实例数上限(默认 30,自动 clamp 到 1-200),防止无终止规则死循环。' },
|
|
67
80
|
}),
|
|
68
81
|
output: {
|
|
69
82
|
schema: {
|
|
@@ -93,7 +106,9 @@ export function buildCalendarTools(config) {
|
|
|
93
106
|
const end = optionalString(input, 'end') ?? isoNoMillis(defaultEnd.toISOString());
|
|
94
107
|
assertIsoTime(start, 'start');
|
|
95
108
|
assertIsoTime(end, 'end');
|
|
96
|
-
const
|
|
109
|
+
const expand = booleanWithDefault(input, 'expand', true);
|
|
110
|
+
const maxOccurrences = clampedInteger(input, 'maxOccurrences', 30, 1, 200);
|
|
111
|
+
const events = await service().list(start, end, { expand, maxOccurrences });
|
|
97
112
|
return { count: events.length, start, end, events };
|
|
98
113
|
},
|
|
99
114
|
timeoutMs: TIMEOUT_MS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-calendar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DSH 日历工具插件:通过 CalDAV 读写日历事件(Google / iCloud / Nextcloud / 自定义)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -58,5 +58,6 @@
|
|
|
58
58
|
"url": "https://github.com/STARDUSTLC666/dsh-calendar/issues"
|
|
59
59
|
},
|
|
60
60
|
"homepage": "https://github.com/STARDUSTLC666/dsh-calendar#readme",
|
|
61
|
-
"author": "stardustlc"
|
|
61
|
+
"author": "stardustlc",
|
|
62
|
+
"packageManager": "pnpm@11.7.0"
|
|
62
63
|
}
|