dsh-calendar 0.13.4 → 0.14.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/CHANGELOG.md +14 -0
- package/README.en.md +10 -4
- package/README.md +10 -4
- package/docs/AVAILABILITY.md +36 -0
- package/lib/availability-ical.d.ts +3 -0
- package/lib/availability-ical.js +69 -0
- package/lib/availability.d.ts +55 -0
- package/lib/availability.js +128 -0
- package/lib/caldav.d.ts +3 -0
- package/lib/caldav.js +39 -0
- package/lib/client.js +61 -5
- package/lib/ical.d.ts +6 -1
- package/lib/ical.js +43 -10
- package/lib/tools.d.ts +1 -1
- package/lib/tools.js +23 -1
- package/lib/web.d.ts +2 -0
- package/lib/web.js +17 -2
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 0.14.0 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
- 新增「找空闲时段」与只读 `calendar_find_time`:设置工作日、时间、时长与缓冲,选择候选后进入原有新建草稿,保存仍由用户确认。
|
|
6
|
+
- 处理全天结束边界、透明 / 取消日程、重复例外、跨窗口重叠、查询时区与夏令时;不完整或超预算的日历响应不作为空闲依据。
|
|
7
|
+
- 查询可取消,旧响应不覆盖新条件;候选说明当前日历范围、读取时间与数量上限。自动安装仍默认关闭。
|
|
8
|
+
- 候选显示 UTC 偏移以区分夏令时的重复钟点;新建和拖拽冲突提示同步排除透明及取消日程。
|
|
9
|
+
- Add read-only availability suggestions, explicit scope and draft-only selection; preserve complete recurrence and timezone checks.
|
|
10
|
+
|
|
11
|
+
|
|
3
12
|
[返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
|
|
4
13
|
|
|
5
14
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
15
|
|
|
16
|
+
## 0.13.5 (2026-10-29)
|
|
17
|
+
|
|
18
|
+
- 补齐中英文使用流程:准备、入口、具体操作与结果核对;新增累计 npm 下载徽章并保留月统计。
|
|
19
|
+
- Add practical getting-started workflows and lifetime npm download badges alongside monthly counts.
|
|
20
|
+
|
|
7
21
|
## 0.13.4 (2026-10-29)
|
|
8
22
|
|
|
9
23
|
- 同步仓库与 npm 的简明介绍、中英文 README 和反馈入口;本次为文档与包元数据更新。
|
package/README.en.md
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
Connect CalDAV in the DSH calendar workbench to view, create and reschedule events. Use month, week or agenda views, and preview ICS files before choosing what to import.
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar)
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar)
|
|
10
|
+
|
|
11
|
+
[Download statistics](https://github.com/STARDUSTLC666/dsh-suite/blob/npm-downloads/README.md)
|
|
10
12
|
|
|
11
13
|
Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-calendar/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-calendar/pulls).
|
|
12
14
|
|
|
@@ -14,6 +16,9 @@ Open the workbench from the native DSH sidebar. See the [workbench guide](docs/W
|
|
|
14
16
|
|
|
15
17
|
## What it does
|
|
16
18
|
|
|
19
|
+
- Find available times in the connected calendar, then fill a new-event draft for review. Set duration, working hours/days and buffers; see [availability](docs/AVAILABILITY.md#english).
|
|
20
|
+
|
|
21
|
+
|
|
17
22
|
- Month, week and agenda views with drag and keyboard rescheduling.
|
|
18
23
|
- Create, search, update and delete events, including recurring occurrences.
|
|
19
24
|
- Import an ICS file or pasted content after reviewing timezones, duplicates and overlap hints.
|
|
@@ -31,9 +36,10 @@ For the web version, replace `desktop` with `web`. Restart DSH after installatio
|
|
|
31
36
|
|
|
32
37
|
## Start using it
|
|
33
38
|
|
|
34
|
-
Open
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
1. Open Calendar in the sidebar; choose a provider in connection settings and enter your account credentials.
|
|
40
|
+
2. Test the connection, select a calendar and verify its time zone before using month, week or agenda views.
|
|
41
|
+
3. Before creating or editing an event, check its date, time and destination calendar, then confirm.
|
|
42
|
+
4. Preview and select ICS entries before importing; review skipped and failed entries before retrying.
|
|
37
43
|
|
|
38
44
|
## Requirements and configuration
|
|
39
45
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
在 DSH 日历工作台连接 CalDAV,查看、创建和调整日程。支持月历、周视图与议程,以及先预览再选择导入的 ICS 日历文件。
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar)
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar)
|
|
10
|
+
|
|
11
|
+
[下载统计说明](https://github.com/STARDUSTLC666/dsh-suite/blob/npm-downloads/README.md)
|
|
10
12
|
|
|
11
13
|
欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-calendar/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-calendar/pulls)。
|
|
12
14
|
|
|
@@ -14,6 +16,9 @@
|
|
|
14
16
|
|
|
15
17
|
## 功能
|
|
16
18
|
|
|
19
|
+
- 查找当前日历的空闲时段,选择后进入新建草稿再确认保存;可设时长、工作时间 / 工作日与缓冲,见[使用说明](docs/AVAILABILITY.md)。
|
|
20
|
+
|
|
21
|
+
|
|
17
22
|
- 月、周与议程视图,支持拖拽和键盘改期。
|
|
18
23
|
- 创建、搜索、更新、删除日程,并展开重复事件。
|
|
19
24
|
- 导入 ICS 文件或粘贴内容,核对时区、重复项和重叠提示后分批确认。
|
|
@@ -31,9 +36,10 @@ dsh plugin --profile desktop add dsh-calendar
|
|
|
31
36
|
|
|
32
37
|
## 开始使用
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
1. 打开左侧栏“日历”,在连接设置中选择服务商并填写自己的账号与授权信息。
|
|
40
|
+
2. 测试连接并选择目标日历,核对时区后读取月、周或议程视图。
|
|
41
|
+
3. 新建或调整日程时,先核对日期、时间和目标日历,再确认保存。
|
|
42
|
+
4. 导入 ICS 时先预览并选择条目,再确认导入;检查跳过项和失败项后按需重试。
|
|
37
43
|
|
|
38
44
|
## 依赖与配置
|
|
39
45
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# 找空闲时段
|
|
2
|
+
|
|
3
|
+
1. 从 DSH 侧栏打开「日历」,先连接并测试自己的 CalDAV 日历。
|
|
4
|
+
2. 点击「找空闲时段」,选择起止日期、日程时长、每天的工作时间和工作日;按需设置定时日程前后缓冲。
|
|
5
|
+
3. 点击「查找空闲」。页面明确显示浏览器时区、读取时间与占用日程数量。修改条件会清除旧候选;查询中可取消。
|
|
6
|
+
4. 点击「用此时段新建」,只会填写原有日程草稿。补充标题、地点等内容并核对时间,再明确保存;关闭草稿不会创建日程。
|
|
7
|
+
|
|
8
|
+
查询最多 31 天,时长 5–480 分钟,缓冲 0–120 分钟。界面按浏览器时区解释工作时间、全天日期和未指定时区的浮动日程;已带 TZID / UTC 的原日程保留其时区。时长按实际经过分钟计算,夏令时变化可能使本地钟点差不同。每天只提供一个不跨日的工作窗口,候选也不跨本地日期。默认周一至周五 09:00–18:00,每 15 分钟一个候选,展示最早 20 个;候选可以相互重叠,数量达到上限会说明。
|
|
9
|
+
|
|
10
|
+
只读取当前连接的一个日历集合,不查询参会者、其他日历或资源的忙闲。读取时间之后日程仍可能变化,候选不是预约;保存前请核对当前日程。透明(`TRANSP:TRANSPARENT`)及取消的日程不占用时间,全天结束日期不包含在事件内;未写结束日期的全天事件按一天计算。缓冲只应用于定时占用事件。
|
|
11
|
+
|
|
12
|
+
完整展开重复系列及改期 / 取消例外,并保留查询开始前已开始、但仍与窗口重叠的实例。日历资源缺失或不合法、无法识别时区、展开截断或超过预算时,查询会失败,不用部分列表推断空闲。当前不支持 `RANGE=THISANDFUTURE`、PERIOD 类型 RDATE 或浮动的重复结束时间;请先在原日历明确或拆分这些规则。
|
|
13
|
+
|
|
14
|
+
单次最多读取 1000 个资源、总计 8 MiB;单资源最多 512 KiB、2000 个例外 / 实例;总计最多 10000 个展开日程。查询有 20 秒期限,每个系列最多 100000 次迭代。超限可缩小日期范围后重试。
|
|
15
|
+
|
|
16
|
+
对话中可要求:“找下周工作日一小时的空闲,工作时间 09:00–18:00,前后留 15 分钟,先列候选,不要创建。”`calendar_find_time` 使用相同连接与计算,要求带时区的 `start`、`end` 及 IANA `timeZone`,不会创建、修改日程或发送邀请。
|
|
17
|
+
|
|
18
|
+
协议语义参考 [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545)。
|
|
19
|
+
|
|
20
|
+
## 验证范围
|
|
21
|
+
|
|
22
|
+
Windows 本地 221 项测试通过,包括夏令时、UTC 偏移、全天边界、透明 / 取消、重复改期、查询边界重叠、预算、取消及旧响应保护。真实回环 HTTP CalDAV 服务器验证了 calendar-query / calendar-multiget、无 `.ics` 后缀资源、损坏响应拒绝与取消。
|
|
23
|
+
|
|
24
|
+
官方 DSH 0.2.1-rc.5、0.2.2-alpha.2 中,每版覆盖中英文与桌面 / 390px 窄屏操作:查询、无结果、候选进入草稿、取消不写入、明确保存后本机 PUT,另检查不同浏览器时区。20 个插件均加载为 active。测试账号和日历均为模拟数据;没有真实日历服务账号、Electron 原生窗口或模型自主选择工具的验收。
|
|
25
|
+
|
|
26
|
+
## English
|
|
27
|
+
|
|
28
|
+
Open **Calendar → Find available time**, set dates, duration, working hours/days and optional buffers, then choose **Find times**. The page shows the browser time zone and the time of the read. Changed filters clear old candidates; a pending search can be cancelled. **Create with this time** only fills the existing new-event draft. Review its title, dates and details before saving.
|
|
29
|
+
|
|
30
|
+
Only the connected calendar collection is read. Candidates are not reservations and do not establish availability for other calendars, attendees or resources. Transparent and cancelled events do not block time. All-day end dates are exclusive; an all-day event without an end occupies one local day. Floating times use the query zone, while explicit source zones are preserved. Duration uses elapsed minutes across DST changes. The UI uses the browser zone and one daily working window; candidates do not cross a local date. Defaults: weekdays 09:00–18:00, 60-minute duration, 15-minute candidate spacing, earliest 20 candidates. Alternatives may overlap.
|
|
31
|
+
|
|
32
|
+
The read-only `calendar_find_time` tool uses the same service. Provide offset-aware `start`/`end` and IANA `timeZone`; optional parameters set duration, hours, weekdays, timed-event buffers, spacing and result limit. It performs no calendar mutation or invitation delivery.
|
|
33
|
+
|
|
34
|
+
Limits: 31 days; 5–480-minute duration; 0–120-minute buffer; 1000 resources / 8 MiB total; 512 KiB per resource; 2000 recurrence instances per resource and 10000 total; 100000 iterations per series; 20-second deadline. Incomplete/invalid data or exhausted budgets fail explicitly. Ranged recurrence changes, PERIOD RDATE and floating recurrence end times require correction in the source calendar. The feature does not infer free time from a truncated list.
|
|
35
|
+
|
|
36
|
+
Validation: 221 Windows tests, a real loopback HTTP CalDAV fixture, and official DSH RC.5 / alpha.2 Web profiles in Chinese/English at desktop and 390px widths. Query, no-result, draft-only choice, cancellation and explicit PUT were exercised, including a different browser time zone. All 20 plugins loaded as active. Real provider accounts, the Electron window and autonomous model tool selection were not tested.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type CalendarEvent } from './ical.js';
|
|
2
|
+
/** Isolate floating times to the selected viewer zone before the existing expander hydrates them. */
|
|
3
|
+
export declare function busyResource(data: string, href: string, start: string, end: string, timeZone: string, deadline?: number): CalendarEvent[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import ICAL from 'ical.js';
|
|
2
|
+
import { expandEventFromICal } from './ical.js';
|
|
3
|
+
import { assertIsoTime } from './time.js';
|
|
4
|
+
/** Isolate floating times to the selected viewer zone before the existing expander hydrates them. */
|
|
5
|
+
export function busyResource(data, href, start, end, timeZone, deadline = Date.now() + 20000) {
|
|
6
|
+
if (!data.trim() || Buffer.byteLength(data) > 512 * 1024)
|
|
7
|
+
throw new Error('日历资源缺失或超过 512 KiB,无法完整查询空闲');
|
|
8
|
+
let depth = 0;
|
|
9
|
+
for (const line of data.replace(/\r\n/g, '\n').split('\n')) {
|
|
10
|
+
if (/^BEGIN:/i.test(line))
|
|
11
|
+
depth++;
|
|
12
|
+
if (/^END:/i.test(line))
|
|
13
|
+
depth--;
|
|
14
|
+
if (depth < 0 || depth > 16)
|
|
15
|
+
throw new Error('日历资源结构不完整,未给出空闲建议');
|
|
16
|
+
}
|
|
17
|
+
if (depth)
|
|
18
|
+
throw new Error('日历资源未结束,未给出空闲建议');
|
|
19
|
+
let calendar;
|
|
20
|
+
try {
|
|
21
|
+
calendar = new ICAL.Component(ICAL.parse(data));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error('日历资源无法解析,未给出空闲建议');
|
|
25
|
+
}
|
|
26
|
+
const events = calendar.getAllSubcomponents('vevent'), masters = events.filter(e => !e.hasProperty('recurrence-id'));
|
|
27
|
+
if (calendar.name !== 'vcalendar' || masters.length !== 1 || events.length > 2000)
|
|
28
|
+
throw new Error('日历资源缺少唯一主日程或例外过多,未给出空闲建议');
|
|
29
|
+
const uid = String(masters[0].getFirstPropertyValue('uid') ?? '');
|
|
30
|
+
if (!uid)
|
|
31
|
+
throw new Error('日历资源缺少 UID,未给出空闲建议');
|
|
32
|
+
const keys = new Set();
|
|
33
|
+
for (const event of events) {
|
|
34
|
+
if (String(event.getFirstPropertyValue('uid') ?? '') !== uid)
|
|
35
|
+
throw new Error('同一资源混有多个系列,未给出空闲建议');
|
|
36
|
+
const recurrence = event.getFirstProperty('recurrence-id');
|
|
37
|
+
if (recurrence) {
|
|
38
|
+
const key = JSON.stringify(recurrence.toJSON());
|
|
39
|
+
if (keys.has(key) || recurrence.getParameter('range'))
|
|
40
|
+
throw new Error('重复例外有重复标识或范围改期,无法完整查询空闲');
|
|
41
|
+
keys.add(key);
|
|
42
|
+
}
|
|
43
|
+
if (!event.hasProperty('dtstart'))
|
|
44
|
+
throw new Error('日程缺少开始时间,未给出空闲建议');
|
|
45
|
+
const rule = event.getFirstPropertyValue('rrule');
|
|
46
|
+
if (rule instanceof ICAL.Recur && rule.until && !rule.until.isDate && rule.until.zone.tzid === 'floating')
|
|
47
|
+
throw new Error('浮动重复结束时间需先在原日历指定时区,未给出空闲建议');
|
|
48
|
+
for (const property of event.getAllProperties()) {
|
|
49
|
+
if (!['dtstart', 'dtend', 'recurrence-id', 'rdate', 'exdate'].includes(property.name))
|
|
50
|
+
continue;
|
|
51
|
+
for (const value of property.toJSON().slice(3))
|
|
52
|
+
if (typeof value === 'string')
|
|
53
|
+
assertIsoTime(/^\d{4}-\d{2}-\d{2}$/.test(value) || value.endsWith('Z') ? value : value + 'Z', '日程时间');
|
|
54
|
+
const values = property.getValues();
|
|
55
|
+
if (property.type === 'period')
|
|
56
|
+
throw new Error('暂不支持 PERIOD 类型的重复日期,未给出空闲建议');
|
|
57
|
+
for (const value of values)
|
|
58
|
+
if (value instanceof ICAL.Time) {
|
|
59
|
+
const text = value.toString();
|
|
60
|
+
assertIsoTime(value.isDate || text.endsWith('Z') ? text : text + 'Z', '日程时间');
|
|
61
|
+
}
|
|
62
|
+
if (!property.getParameter('tzid') && values.some(value => value instanceof ICAL.Time && !value.isDate && value.zone.tzid === 'floating'))
|
|
63
|
+
property.setParameter('tzid', timeZone);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (Date.now() > deadline)
|
|
67
|
+
throw new Error('空闲查询超时,请缩小范围');
|
|
68
|
+
return expandEventFromICal(calendar.toString(), href, undefined, start, end, 2000, { strict: true, overlap: true, deadline });
|
|
69
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CalendarEvent } from './ical.js';
|
|
2
|
+
export interface AvailabilityInput {
|
|
3
|
+
start: string;
|
|
4
|
+
end: string;
|
|
5
|
+
timeZone: string;
|
|
6
|
+
durationMinutes?: number;
|
|
7
|
+
workStart?: string;
|
|
8
|
+
workEnd?: string;
|
|
9
|
+
weekdays?: number[];
|
|
10
|
+
bufferMinutes?: number;
|
|
11
|
+
stepMinutes?: number;
|
|
12
|
+
limit?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface AvailabilityResult {
|
|
15
|
+
start: string;
|
|
16
|
+
end: string;
|
|
17
|
+
timeZone: string;
|
|
18
|
+
durationMinutes: number;
|
|
19
|
+
workStart: string;
|
|
20
|
+
workEnd: string;
|
|
21
|
+
weekdays: number[];
|
|
22
|
+
bufferMinutes: number;
|
|
23
|
+
stepMinutes: number;
|
|
24
|
+
checkedAt: string;
|
|
25
|
+
busyEventCount: number;
|
|
26
|
+
count: number;
|
|
27
|
+
hasMore: boolean;
|
|
28
|
+
slots: Array<{
|
|
29
|
+
start: string;
|
|
30
|
+
end: string;
|
|
31
|
+
}>;
|
|
32
|
+
scope: 'connected-calendar';
|
|
33
|
+
complete: true;
|
|
34
|
+
}
|
|
35
|
+
export declare function availabilityRequest(input: AvailabilityInput): {
|
|
36
|
+
startMs: number;
|
|
37
|
+
endMs: number;
|
|
38
|
+
formatter: Intl.DateTimeFormat;
|
|
39
|
+
timeZone: string;
|
|
40
|
+
durationMinutes: number;
|
|
41
|
+
workStart: {
|
|
42
|
+
text: string;
|
|
43
|
+
minute: number;
|
|
44
|
+
};
|
|
45
|
+
workEnd: {
|
|
46
|
+
text: string;
|
|
47
|
+
minute: number;
|
|
48
|
+
};
|
|
49
|
+
weekdays: number[];
|
|
50
|
+
bufferMinutes: number;
|
|
51
|
+
stepMinutes: number;
|
|
52
|
+
limit: number;
|
|
53
|
+
};
|
|
54
|
+
/** Only use with a complete, expanded collection; titles never leave this calculation. */
|
|
55
|
+
export declare function findAvailableTimes(events: CalendarEvent[], input: AvailabilityInput): AvailabilityResult;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { assertIsoTime } from './time.js';
|
|
2
|
+
const MINUTE = 60000;
|
|
3
|
+
export function availabilityRequest(input) {
|
|
4
|
+
for (const key of ['start', 'end']) {
|
|
5
|
+
if (typeof input[key] !== 'string')
|
|
6
|
+
throw new Error('请提供带时区的查询起止时间');
|
|
7
|
+
assertIsoTime(input[key], key);
|
|
8
|
+
if (!input[key].includes('T'))
|
|
9
|
+
throw new Error('空闲查询起止时间须包含时间与时区');
|
|
10
|
+
}
|
|
11
|
+
const startMs = Date.parse(input.start), endMs = Date.parse(input.end);
|
|
12
|
+
if (endMs <= startMs || endMs - startMs > 31 * 86400000)
|
|
13
|
+
throw new Error('空闲查询范围须大于 0 且不超过 31 天');
|
|
14
|
+
if (typeof input.timeZone !== 'string' || input.timeZone.length > 100)
|
|
15
|
+
throw new Error('请提供 IANA 时区,如 Asia/Shanghai');
|
|
16
|
+
let formatter;
|
|
17
|
+
try {
|
|
18
|
+
formatter = new Intl.DateTimeFormat('en-CA', { timeZone: input.timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error('无法识别查询时区,请使用 Asia/Shanghai 等 IANA 名称');
|
|
22
|
+
}
|
|
23
|
+
const integer = (value, fallback, min, max, label) => {
|
|
24
|
+
const n = value === undefined ? fallback : value;
|
|
25
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < min || n > max)
|
|
26
|
+
throw new Error(label + '必须在 ' + min + '–' + max + ' 之间');
|
|
27
|
+
return n;
|
|
28
|
+
};
|
|
29
|
+
const clock = (value, fallback) => {
|
|
30
|
+
const text = value === undefined ? fallback : value;
|
|
31
|
+
if (typeof text !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text))
|
|
32
|
+
throw new Error('工作时间须使用 HH:mm,不能跨日');
|
|
33
|
+
return { text, minute: Number(text.slice(0, 2)) * 60 + Number(text.slice(3)) };
|
|
34
|
+
};
|
|
35
|
+
const workStart = clock(input.workStart, '09:00'), workEnd = clock(input.workEnd, '18:00');
|
|
36
|
+
if (workEnd.minute <= workStart.minute)
|
|
37
|
+
throw new Error('工作结束时间必须晚于开始时间,暂不支持跨日工作窗口');
|
|
38
|
+
const weekdays = input.weekdays ?? [1, 2, 3, 4, 5];
|
|
39
|
+
if (!Array.isArray(weekdays) || !weekdays.length || weekdays.length > 7 || weekdays.some(n => !Number.isInteger(n) || n < 1 || n > 7))
|
|
40
|
+
throw new Error('请至少选择一个工作日(周一为 1,周日为 7)');
|
|
41
|
+
return { startMs, endMs, formatter, timeZone: formatter.resolvedOptions().timeZone,
|
|
42
|
+
durationMinutes: integer(input.durationMinutes, 60, 5, 480, '时长'),
|
|
43
|
+
workStart, workEnd, weekdays: [...new Set(weekdays)].sort(),
|
|
44
|
+
bufferMinutes: integer(input.bufferMinutes, 0, 0, 120, '缓冲分钟'),
|
|
45
|
+
stepMinutes: integer(input.stepMinutes, 15, 1, 60, '候选间隔分钟'), limit: integer(input.limit, 20, 1, 100, '候选数量') };
|
|
46
|
+
}
|
|
47
|
+
/** Only use with a complete, expanded collection; titles never leave this calculation. */
|
|
48
|
+
export function findAvailableTimes(events, input) {
|
|
49
|
+
const request = availabilityRequest(input), { formatter, startMs, endMs, durationMinutes, bufferMinutes, weekdays, workStart, workEnd } = request;
|
|
50
|
+
if (events.length > 10000)
|
|
51
|
+
throw new Error('日程实例超过 10000 条,请缩小查询范围;未给出空闲建议');
|
|
52
|
+
const timed = [], allDay = [];
|
|
53
|
+
const localDay = (time) => { const p = Object.fromEntries(formatter.formatToParts(time).map(p => [p.type, p.value])); return p.year + '-' + p.month + '-' + p.day; };
|
|
54
|
+
const firstDate = localDay(startMs), lastDate = localDay(endMs - 1);
|
|
55
|
+
let busyEventCount = 0;
|
|
56
|
+
for (const event of events) {
|
|
57
|
+
if (event.status?.toUpperCase() === 'CANCELLED' || event.transparency?.toUpperCase() === 'TRANSPARENT')
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
assertIsoTime(event.start, '日程开始');
|
|
61
|
+
assertIsoTime(event.end, '日程结束');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error('日程时间不完整,无法可靠计算空闲;请在原日历修正');
|
|
65
|
+
}
|
|
66
|
+
if (event.allDay) {
|
|
67
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(event.start) || !/^\d{4}-\d{2}-\d{2}$/.test(event.end) || event.end < event.start)
|
|
68
|
+
throw new Error('全天日程日期不完整,未给出空闲建议');
|
|
69
|
+
const end = event.end === event.start ? new Date(Date.parse(event.start) + 86400000).toISOString().slice(0, 10) : event.end;
|
|
70
|
+
if (event.start <= lastDate && end > firstDate) {
|
|
71
|
+
allDay.push([event.start, end]);
|
|
72
|
+
busyEventCount++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
if (!event.start.includes('T') || !event.end.includes('T'))
|
|
77
|
+
throw new Error('定时日程缺少时区,未给出空闲建议');
|
|
78
|
+
const a = Date.parse(event.start), b = Date.parse(event.end);
|
|
79
|
+
if (b < a)
|
|
80
|
+
throw new Error('日程结束早于开始,未给出空闲建议');
|
|
81
|
+
if (b > a && a - bufferMinutes * MINUTE < endMs && b + bufferMinutes * MINUTE > startMs) {
|
|
82
|
+
timed.push([a - bufferMinutes * MINUTE, b + bufferMinutes * MINUTE]);
|
|
83
|
+
busyEventCount++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
timed.sort((a, b) => a[0] - b[0]);
|
|
88
|
+
const merged = [];
|
|
89
|
+
for (const interval of timed) {
|
|
90
|
+
const last = merged.at(-1);
|
|
91
|
+
if (last && interval[0] <= last[1])
|
|
92
|
+
last[1] = Math.max(last[1], interval[1]);
|
|
93
|
+
else
|
|
94
|
+
merged.push([...interval]);
|
|
95
|
+
}
|
|
96
|
+
const first = Math.ceil(startMs / MINUTE) * MINUTE, minutes = Math.max(0, Math.floor((endMs - first) / MINUTE));
|
|
97
|
+
const local = Array.from({ length: minutes + 1 }, (_, i) => {
|
|
98
|
+
const parts = Object.fromEntries(formatter.formatToParts(first + i * MINUTE).map(p => [p.type, p.value]));
|
|
99
|
+
const date = parts.year + '-' + parts.month + '-' + parts.day;
|
|
100
|
+
return { date, minute: Number(parts.hour) * 60 + Number(parts.minute), weekday: (new Date(date + 'T00:00:00Z').getUTCDay() + 6) % 7 + 1 };
|
|
101
|
+
});
|
|
102
|
+
const blockedDates = new Set([...new Set(local.map(p => p.date))].filter(date => allDay.some(([a, b]) => date >= a && date < b)));
|
|
103
|
+
const blocked = new Uint32Array(minutes + 1);
|
|
104
|
+
for (let i = 0; i < minutes; i++) {
|
|
105
|
+
const p = local[i];
|
|
106
|
+
blocked[i + 1] = blocked[i] + Number(!weekdays.includes(p.weekday) || p.minute < workStart.minute || p.minute >= workEnd.minute || blockedDates.has(p.date));
|
|
107
|
+
}
|
|
108
|
+
const slots = [];
|
|
109
|
+
let intervalIndex = 0;
|
|
110
|
+
for (let i = 0; i + durationMinutes <= minutes; i++) {
|
|
111
|
+
const p = local[i];
|
|
112
|
+
if (p.minute % request.stepMinutes || p.date !== local[i + durationMinutes].date || blocked[i + durationMinutes] !== blocked[i])
|
|
113
|
+
continue;
|
|
114
|
+
const a = first + i * MINUTE, b = a + durationMinutes * MINUTE;
|
|
115
|
+
while (intervalIndex < merged.length && merged[intervalIndex][1] <= a)
|
|
116
|
+
intervalIndex++;
|
|
117
|
+
if (intervalIndex < merged.length && merged[intervalIndex][0] < b) {
|
|
118
|
+
i = Math.max(i, Math.ceil((merged[intervalIndex][1] - first) / MINUTE) - 1);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
slots.push({ start: new Date(a).toISOString(), end: new Date(b).toISOString() });
|
|
122
|
+
if (slots.length > request.limit)
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
return { start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString(), timeZone: request.timeZone, durationMinutes,
|
|
126
|
+
workStart: workStart.text, workEnd: workEnd.text, weekdays, bufferMinutes, stepMinutes: request.stepMinutes, checkedAt: new Date().toISOString(),
|
|
127
|
+
busyEventCount, count: Math.min(slots.length, request.limit), hasMore: slots.length > request.limit, slots: slots.slice(0, request.limit), scope: 'connected-calendar', complete: true };
|
|
128
|
+
}
|
package/lib/caldav.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @module dsh-calendar/caldav
|
|
6
6
|
*/
|
|
7
|
+
import { type AvailabilityInput, type AvailabilityResult } from './availability.js';
|
|
7
8
|
import type { ResolvedConfig } from './config.js';
|
|
8
9
|
import { type CalendarEvent, type EventFields } from './ical.js';
|
|
9
10
|
/** CalDAV 操作错误:带中文指引。 */
|
|
@@ -30,6 +31,8 @@ export declare class CalendarService {
|
|
|
30
31
|
end: string;
|
|
31
32
|
}): Promise<CalendarEvent[]>;
|
|
32
33
|
private toEvents;
|
|
34
|
+
/** A complete read of the connected collection; never writes or reserves events. */
|
|
35
|
+
availability(input: AvailabilityInput, signal?: AbortSignal): Promise<AvailabilityResult>;
|
|
33
36
|
/** 列出并展开:每个对象经 expandEventFromICal 展开为若干实例行。 */
|
|
34
37
|
private toExpandedEvents;
|
|
35
38
|
/** 按 uid(href)找到服务器对象(含 etag 与原始 data)。 */
|
package/lib/caldav.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { createDAVClient } from 'tsdav';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import ICAL from 'ical.js';
|
|
10
|
+
import { availabilityRequest, findAvailableTimes } from './availability.js';
|
|
11
|
+
import { busyResource } from './availability-ical.js';
|
|
10
12
|
import { createProxyFetch } from './proxy-fetch.js';
|
|
11
13
|
import { createOAuthFetch, OAuthError } from './oauth.js';
|
|
12
14
|
import { buildICalString, expandEventFromICal, ExpansionLimitError, generateUid, parseEventFromICal, updateICalString, isRecurringResource, } from './ical.js';
|
|
@@ -135,6 +137,43 @@ export class CalendarService {
|
|
|
135
137
|
}
|
|
136
138
|
return events;
|
|
137
139
|
}
|
|
140
|
+
/** A complete read of the connected collection; never writes or reserves events. */
|
|
141
|
+
async availability(input, signal) {
|
|
142
|
+
const request = availabilityRequest(input), deadline = Date.now() + 20000, cancel = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(20000)]);
|
|
143
|
+
cancel.throwIfAborted();
|
|
144
|
+
const start = new Date(request.startMs - 2 * 86400000).toISOString(), end = new Date(request.endMs + 2 * 86400000).toISOString();
|
|
145
|
+
try {
|
|
146
|
+
const client = await this.client();
|
|
147
|
+
cancel.throwIfAborted();
|
|
148
|
+
const objects = await client.fetchCalendarObjects({ calendar: this.calendar(), timeRange: { start, end }, urlFilter: (url) => typeof url === 'string' && url.length > 0, fetchOptions: { signal: cancel } });
|
|
149
|
+
cancel.throwIfAborted();
|
|
150
|
+
if (objects.length > 1000)
|
|
151
|
+
throw new Error('日历资源超过 1000 条,请缩小空闲查询范围');
|
|
152
|
+
const events = [];
|
|
153
|
+
let bytes = 0;
|
|
154
|
+
for (const object of objects) {
|
|
155
|
+
cancel.throwIfAborted();
|
|
156
|
+
const data = String(object.data ?? '');
|
|
157
|
+
bytes += Buffer.byteLength(data);
|
|
158
|
+
if (bytes > 8 * 1024 * 1024)
|
|
159
|
+
throw new Error('日历资源总量超过 8 MiB,请缩小空闲查询范围');
|
|
160
|
+
if (Date.now() > deadline)
|
|
161
|
+
throw new Error('空闲查询超时,请缩小范围');
|
|
162
|
+
events.push(...busyResource(data, object.url, start, end, request.timeZone, deadline));
|
|
163
|
+
if (events.length > 10000)
|
|
164
|
+
throw new Error('日程实例超过 10000 条,请缩小空闲查询范围');
|
|
165
|
+
}
|
|
166
|
+
cancel.throwIfAborted();
|
|
167
|
+
const result = findAvailableTimes(events, input);
|
|
168
|
+
if (Date.now() > deadline)
|
|
169
|
+
throw new Error('空闲查询超时,请缩小范围');
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
cancel.throwIfAborted();
|
|
174
|
+
throw translateError(error, '查询空闲时段', this.config);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
138
177
|
/** 列出并展开:每个对象经 expandEventFromICal 展开为若干实例行。 */
|
|
139
178
|
toExpandedEvents(objects, startIso, endIso, maxOccurrences) {
|
|
140
179
|
const events = [];
|
package/lib/client.js
CHANGED
|
@@ -597,6 +597,54 @@ window.__ModuleLoader__.load(__stardustUpdatesWrap({ id: "dsh-calendar", factory
|
|
|
597
597
|
"use strict";
|
|
598
598
|
const React = require("react");
|
|
599
599
|
const W = createWorkbenchUI(React);
|
|
600
|
+
const AVAILABILITY_CSS = `
|
|
601
|
+
.calendar-workbench .calendar-availability,.dshc-root .calendar-availability{border:1px solid var(--w-border,var(--dsh-border-color,#ddd));border-radius:10px;padding:20px;margin:18px 0;background:var(--w-bg,transparent)}
|
|
602
|
+
.calendar-availability h3{margin:0;font-size:17px}.calendar-availability .availability-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.calendar-availability p{font-size:13px;line-height:1.7;margin:10px 0;color:var(--w-muted,inherit)}
|
|
603
|
+
.calendar-availability .availability-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:18px 0}.calendar-availability label{display:grid;gap:6px;font-size:13px;min-width:0;align-content:start}.calendar-availability input{width:100%;min-width:0;box-sizing:border-box;font:inherit;color:inherit;background:var(--w-bg,transparent);border:1px solid var(--w-border,#ccc);border-radius:7px;padding:9px}
|
|
604
|
+
.calendar-availability fieldset{border:0;padding:0;margin:16px 0;min-width:0}.calendar-availability legend{font-size:13px;margin-bottom:8px}.calendar-availability .availability-days{display:flex;gap:12px;flex-wrap:wrap}.calendar-availability .availability-days label{display:flex;align-items:center;gap:6px}.calendar-availability input[type=checkbox]{width:auto;margin:0}.calendar-availability .availability-actions{display:flex;gap:10px;flex-wrap:wrap}.calendar-availability ol{padding:0;list-style:none;display:grid;gap:8px;margin:14px 0 0}.calendar-availability li{display:flex;justify-content:space-between;align-items:center;gap:12px;border-top:1px solid var(--w-border,#ddd);padding:10px 0;font-size:13px}.calendar-availability li span{min-width:0;overflow-wrap:anywhere}.calendar-availability [role=alert]{color:var(--dsw-alias-state-error-primary,#ad3636)}
|
|
605
|
+
@container dsh-workbench (max-width:760px){.calendar-availability .availability-fields{grid-template-columns:minmax(0,1fr)}.calendar-availability{padding:14px!important}.calendar-availability li{align-items:flex-start;flex-direction:column}}
|
|
606
|
+
@media(max-width:600px){.calendar-availability .availability-fields{grid-template-columns:minmax(0,1fr)}}
|
|
607
|
+
`;
|
|
608
|
+
|
|
609
|
+
function AvailabilityPanel({anchor,onClose,onChoose,onBusy}) {
|
|
610
|
+
const text=calendarText, dayString=d=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
611
|
+
const zone=Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
612
|
+
const [form,setForm]=React.useState(()=>{const now=new Date(),start=anchor>now?new Date(anchor):now,end=new Date(start);end.setDate(end.getDate()+6);return{from:dayString(start),to:dayString(end),durationMinutes:'60',workStart:'09:00',workEnd:'18:00',bufferMinutes:'0',weekdays:[1,2,3,4,5]}});
|
|
613
|
+
const [result,setResult]=React.useState(null),[error,setError]=React.useState(''),[busy,setBusy]=React.useState(false),active=React.useRef({id:0,controller:null});
|
|
614
|
+
React.useEffect(()=>{onBusy(busy);return()=>onBusy(false)},[busy]);
|
|
615
|
+
React.useEffect(()=>()=>{active.current.id++;active.current.controller?.abort()},[]);
|
|
616
|
+
const cancel=()=>{active.current.id++;active.current.controller?.abort();active.current.controller=null;setBusy(false)};
|
|
617
|
+
const close=()=>{cancel();onClose()};
|
|
618
|
+
const update=fields=>{cancel();setForm(value=>({...value,...fields}));setResult(null);setError('')};
|
|
619
|
+
const search=async event=>{
|
|
620
|
+
event.preventDefault();cancel();const controller=new AbortController(),id=++active.current.id;active.current.controller=controller;setBusy(true);setError('');setResult(null);
|
|
621
|
+
try{
|
|
622
|
+
const start=new Date(form.from+'T00:00:00'),end=new Date(form.to+'T00:00:00');end.setDate(end.getDate()+1);
|
|
623
|
+
if(dayString(new Date())===form.from)start.setTime(Math.max(start.getTime(),Date.now()));
|
|
624
|
+
const value=await api('availability',{start:start.toISOString(),end:end.toISOString(),timeZone:zone,durationMinutes:Number(form.durationMinutes),workStart:form.workStart,workEnd:form.workEnd,weekdays:form.weekdays,bufferMinutes:Number(form.bufferMinutes),limit:20},controller.signal);
|
|
625
|
+
if(active.current.id===id)setResult(value);
|
|
626
|
+
}catch(e){if(active.current.id===id&&!controller.signal.aborted){const message=e?.message || String(e);setError(UI_LANG==='en'&&/范围|31 天/.test(message)?'Choose an increasing date range of up to 31 days.':backendMessage(e))}}
|
|
627
|
+
finally{if(active.current.id===id){active.current.controller=null;setBusy(false)}}
|
|
628
|
+
};
|
|
629
|
+
const field=(key,label,type,extra={})=>h('label',{key},[h('span',{key:'label'},label),h('input',{key:'input',type,value:form[key],disabled:busy,onChange:e=>update({[key]:e.target.value}),required:true,...extra})]);
|
|
630
|
+
const weekdays=UI_LANG==='en'?['Mon','Tue','Wed','Thu','Fri','Sat','Sun']:['周一','周二','周三','周四','周五','周六','周日'];
|
|
631
|
+
const formatter=React.useMemo(()=>new Intl.DateTimeFormat(UI_LANG==='en'?'en-US':'zh-CN',{timeZone:zone,year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',timeZoneName:'shortOffset'}),[UI_LANG,zone]);
|
|
632
|
+
const format=iso=>formatter.format(new Date(iso));
|
|
633
|
+
return h('section',{className:'calendar-availability','aria-label':text('找空闲时段','Find available time'),'aria-busy':busy,onKeyDown:e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();close()}}},[
|
|
634
|
+
h('style',{key:'style'},AVAILABILITY_CSS),h('div',{key:'heading',className:'availability-heading'},[h('h3',{key:'title'},text('找空闲时段','Find available time')),h(W.Button,{key:'close',onClick:close},text('关闭','Close'))]),
|
|
635
|
+
h('p',{key:'scope'},text('按当前连接的日历查找,不代表其他日历或参会者空闲。选择候选只填写新建草稿。','Checks the connected calendar, not other calendars or attendees. Choosing a time only fills a new-event draft.')),
|
|
636
|
+
h('p',{key:'zone'},text('查询与显示时区:','Search and display time zone: ')+zone),
|
|
637
|
+
h('form',{key:'form',onSubmit:search},[
|
|
638
|
+
h('div',{key:'fields',className:'availability-fields'},[field('from',text('开始日期','Start date'),'date'),field('to',text('结束日期(包含当天)','End date (inclusive)'),'date'),field('durationMinutes',text('日程时长(分钟)','Duration (minutes)'),'number',{min:5,max:480,step:1}),field('workStart',text('工作开始','Work starts'),'time'),field('workEnd',text('工作结束','Work ends'),'time'),field('bufferMinutes',text('定时日程前后缓冲(分钟)','Buffer around timed events (minutes)'),'number',{min:0,max:120,step:1})]),
|
|
639
|
+
h('fieldset',{key:'weekdays',disabled:busy},[h('legend',{key:'legend'},text('工作日','Working days')),h('div',{key:'days',className:'availability-days'},weekdays.map((label,i)=>h('label',{key:i},[h('input',{key:'check',type:'checkbox',checked:form.weekdays.includes(i+1),onChange:e=>update({weekdays:e.target.checked?[...form.weekdays,i+1]:form.weekdays.filter(v=>v!==i+1)})}),label])))]),
|
|
640
|
+
h('div',{key:'actions',className:'availability-actions'},[h(W.Button,{key:'search',type:'submit',variant:'primary',disabled:busy},busy?text('正在查询…','Searching…'):text('查找空闲','Find times')),busy?h(W.Button,{key:'cancel',onClick:cancel},text('取消查询','Cancel search')):null])]),
|
|
641
|
+
error?h('p',{key:'error',role:'alert'},error):null,
|
|
642
|
+
result?h('div',{key:'results','aria-live':'polite'},[h('p',{key:'checked'},text('读取于 ','Checked at ')+format(result.checkedAt)+text(',已核对 ',' · Checked ')+result.busyEventCount+text(' 个占用日程。候选尚未预约,可相互重叠。',' busy events. Candidates are not reserved and may overlap.')),
|
|
643
|
+
result.count?h('ol',{key:'slots'},result.slots.map(slot=>h('li',{key:slot.start},[h('span',{key:'range'},format(slot.start)+' — '+format(slot.end)),h(W.Button,{key:'choose',onClick:()=>onChoose(slot)},text('用此时段新建','Create with this time'))]))):h('p',{key:'empty',role:'status'},text('查询范围内没有符合条件的空闲时段。可缩短时长、减少缓冲或调整工作时间后重试。','No available times match. Try a shorter duration, less buffer or different working hours.')),
|
|
644
|
+
result.hasMore?h('p',{key:'more'},text('仅显示最早 20 个候选,可缩小日期范围后再查。','Showing the earliest 20 candidates. Narrow the date range to refine the results.')):null]):null,
|
|
645
|
+
]);
|
|
646
|
+
}
|
|
647
|
+
|
|
600
648
|
function calendarText(zh, en) { return UI_LANG === 'en' ? en : zh; }
|
|
601
649
|
const CALENDAR_WORKBENCH_CSS = `
|
|
602
650
|
.calendar-workbench .dshc-root{font-size:14px;min-width:0;display:flex;flex-direction:column;flex:1}.calendar-workbench .dshc-shell{border:0;border-radius:0;padding:0;background:transparent;box-shadow:none;max-width:none}.calendar-workbench .calendar-toolbar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;padding:0 0 18px}.calendar-workbench .calendar-period{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.calendar-workbench .calendar-period strong{font-size:20px;min-width:140px;text-align:center;font-weight:600}.calendar-workbench .calendar-view-controls{display:flex;align-items:center;gap:4px}.calendar-workbench .calendar-view-controls .w-button{border-radius:6px;border-color:transparent;min-width:50px;padding:7px 12px}.calendar-workbench .calendar-view-controls [aria-pressed=true]{background:var(--w-selected);color:var(--w-accent)}.calendar-workbench .dshc-grid{gap:0;border-inline-start:1px solid var(--w-border);margin:0!important}.calendar-workbench .dshc-dow{padding:14px 0;border-block-start:1px solid var(--w-border);border-inline-end:1px solid var(--w-border);font-size:12px;color:var(--w-muted)}.calendar-workbench .dshc-cell{border-radius:0;min-height:clamp(88px,calc((100dvh - 300px)/6),140px);padding:10px 9px;background:transparent;border:0;border-block-start:1px solid var(--w-border);border-inline-end:1px solid var(--w-border)}.calendar-workbench .dshc-grid:last-child{border-block-end:1px solid var(--w-border)}.calendar-workbench .dshc-cell.out{background:var(--w-soft);opacity:.55}.calendar-workbench .dshc-daynum{font-size:12px;line-height:24px;color:var(--w-text);margin-bottom:6px}.calendar-workbench .dshc-cell.today{border-color:var(--w-border)}.calendar-workbench .dshc-cell.today .dshc-daynum b{width:24px;height:24px;padding:0;text-align:center;border-radius:50%;background:var(--w-accent);color:#fff;font-size:12px}.calendar-workbench .dshc-chip{border-radius:4px;padding:3px 5px;font-size:12px;background:var(--w-selected);color:var(--w-text)}.calendar-workbench .dshc-chip .t{font-size:11px}.calendar-workbench .dshc-footer{padding:16px 0;font-size:12px;color:var(--w-muted);margin:0;gap:16px;flex-wrap:wrap}.calendar-workbench .dshc-btn{min-height:36px;font-size:14px;border-radius:8px;border-color:var(--w-border);padding:8px 12px}.calendar-workbench .dshc-btn.primary{background:var(--w-primary);color:var(--w-on-primary);border-color:var(--w-primary)}.calendar-workbench .dshc-empty{max-width:580px;min-height:300px;display:flex;flex-direction:column;justify-content:center;margin:auto;font-size:14px;line-height:1.7}.calendar-workbench .dshc-empty h3{font-size:20px;margin-bottom:12px}.calendar-workbench .dshc-empty>div{flex-wrap:wrap}.calendar-workbench .dshc-alert{font-size:13px;border:1px solid var(--w-border);border-radius:8px;background:var(--w-soft);color:var(--w-text);padding:12px 14px;line-height:1.6}.calendar-workbench .dshc-alert.error{color:var(--dsw-alias-state-error-primary,#ad3636)}.calendar-workbench .dshc-import{border:0!important;border-block:1px solid var(--w-border)!important;border-radius:0!important;padding:24px 0!important;margin:0 0 24px!important}.calendar-workbench .dshc-import h3{font-size:18px;margin-bottom:16px}.calendar-workbench .dshc-import label{font-size:13px}.calendar-workbench .dshc-import select,.calendar-workbench .dshc-import textarea{font-size:14px}.calendar-workbench .dshc-agenda{max-height:none;padding:6px 0 24px}.calendar-workbench .dshc-daygroup{gap:0}.calendar-workbench .dshc-daylabel{padding:12px 0}.calendar-workbench .dshc-daylabel b{font-size:16px}.calendar-workbench .dshc-row{border-radius:0;background:transparent;border:0;border-block-end:1px solid var(--w-border);padding:16px 0;font-size:14px}.calendar-workbench .dshc-week{max-height:calc(100dvh - 265px);min-height:420px}.calendar-workbench .dshc-week .dshc-dayhead{font-size:12px;padding:6px}.calendar-workbench .dshc-time,.calendar-workbench .dshc-meta{font-size:12px;color:var(--w-muted)}.calendar-workbench .dshc-slot{font-size:12px;border-radius:4px}.calendar-workbench .dshc-draghint{font-size:12px;line-height:1.6;padding:12px 0}.calendar-workbench .dshc-skel{animation:none;background:var(--w-soft);border-radius:0;border:1px solid var(--w-border)}
|
|
@@ -1062,12 +1110,15 @@ let calendarHostContext;
|
|
|
1062
1110
|
}
|
|
1063
1111
|
/** 调插件自己的路由;错误码原样带出,方便上层区分「没配置」与「服务器拒绝」。 */
|
|
1064
1112
|
const API_TIMEOUT_MS = 15000;
|
|
1065
|
-
async function api(action, payload) {
|
|
1113
|
+
async function api(action, payload, signal) {
|
|
1066
1114
|
const init = { credentials: "same-origin", method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ action }, payload)) };
|
|
1067
1115
|
// 请求必须有上限:挂起的 Promise 会让乐观覆盖一直盖着、撤销条永不出现,
|
|
1068
1116
|
// 表现就是「界面改了、服务器没改」而用户以为成功。超时按失败处理(调用方会回滚并对账)。
|
|
1069
|
-
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function")
|
|
1070
|
-
init.signal = AbortSignal.timeout(
|
|
1117
|
+
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
|
|
1118
|
+
if (action === 'availability') init.signal = AbortSignal.timeout(25000);
|
|
1119
|
+
else init.signal = AbortSignal.timeout(API_TIMEOUT_MS);
|
|
1120
|
+
}
|
|
1121
|
+
if (signal) init.signal = init.signal && typeof AbortSignal.any === 'function' ? AbortSignal.any([init.signal, signal]) : signal;
|
|
1071
1122
|
const res = await fetch(ROUTE, init);
|
|
1072
1123
|
let body = null;
|
|
1073
1124
|
try {
|
|
@@ -1189,6 +1240,7 @@ let calendarHostContext;
|
|
|
1189
1240
|
}
|
|
1190
1241
|
/** 两个事件是否真的重叠(全天事件按当天整天算)。 */
|
|
1191
1242
|
function overlaps(a, b) {
|
|
1243
|
+
if ([a,b].some(event=>event.status?.toUpperCase()==='CANCELLED'||event.transparency?.toUpperCase()==='TRANSPARENT')) return false;
|
|
1192
1244
|
const as = startDate(a).getTime(), ae = endDate(a).getTime() || as;
|
|
1193
1245
|
const bs = startDate(b).getTime(), be = endDate(b).getTime() || bs;
|
|
1194
1246
|
return as < be && bs < ae;
|
|
@@ -2527,9 +2579,10 @@ __stardustUpdatesGuard(React, "dsh-calendar.import", { dirty: !!content.trim(),
|
|
|
2527
2579
|
const [conn, setConn] = useState(null);
|
|
2528
2580
|
const [connOpen, setConnOpen] = useState(false);
|
|
2529
2581
|
const [importOpen, setImportOpen] = useState(false), [importBusy, setImportBusy] = useState(false);
|
|
2582
|
+
const [availabilityOpen,setAvailabilityOpen]=useState(false),[availabilityBusy,setAvailabilityBusy]=useState(false);
|
|
2530
2583
|
const layerOpen = draft !== null || selected !== null || connOpen === true;
|
|
2531
2584
|
// Update protection: dsh-calendar
|
|
2532
|
-
__stardustUpdatesGuard(React, "dsh-calendar", { busy: busy || importBusy || layerOpen || importOpen });
|
|
2585
|
+
__stardustUpdatesGuard(React, "dsh-calendar", { busy: busy || importBusy || layerOpen || importOpen || availabilityOpen || availabilityBusy });
|
|
2533
2586
|
|
|
2534
2587
|
const rootRef = useRef(null);
|
|
2535
2588
|
/** 让 Launcher 知道本面板里有没有浮层:宿主 Portal 的 Esc 不能被面板级监听抢走。 */
|
|
@@ -2817,6 +2870,7 @@ __stardustUpdatesGuard(React, "dsh-calendar", { busy: busy || importBusy || laye
|
|
|
2817
2870
|
h(Btn, { key: "next", onClick: () => step(1), "aria-label": t("action.next") }, "\u203a"),
|
|
2818
2871
|
h(Btn, { key: "conn", disabled: configured === null, onClick: () => setConnOpen(true) }, t("conn.button")),
|
|
2819
2872
|
h(Btn, { key: "import", disabled: demo === true || importBusy, onClick: () => setImportOpen(value => !value) }, t("import.open")),
|
|
2873
|
+
h(Btn,{key:'availability',disabled:demo || configured!==true,onClick:()=>setAvailabilityOpen(v=>!v)},calendarText('找空闲时段','Find available time')),
|
|
2820
2874
|
demo === true
|
|
2821
2875
|
? h(Btn, { key: "exitdemo", onClick: () => setDemo(false) }, t("action.exitDemo"))
|
|
2822
2876
|
: h(Btn, { key: "reload", onClick: reload }, t("action.refresh")),
|
|
@@ -2873,6 +2927,7 @@ __stardustUpdatesGuard(React, "dsh-calendar", { busy: busy || importBusy || laye
|
|
|
2873
2927
|
: null,
|
|
2874
2928
|
compact === true ? toolbar : h("div", null, [toolbar]),
|
|
2875
2929
|
importOpen ? h(IcsImportPanel, { key: "ics-import", onBusy: setImportBusy, onClose: () => setImportOpen(false), onConnection: () => setConnOpen(true), onImported: reload }) : null,
|
|
2930
|
+
availabilityOpen ? h(AvailabilityPanel,{key:'availability',anchor,onBusy:setAvailabilityBusy,onClose:()=>setAvailabilityOpen(false),onChoose:slot=>{setAvailabilityOpen(false);setAnchor(new Date(slot.start));setFormError(null);setDraft({...draftOfEvent({start:slot.start,end:slot.end,allDay:false}),uid:undefined});}}) : null,
|
|
2876
2931
|
body(),
|
|
2877
2932
|
h("div", { className: "dshc-footer" }, [
|
|
2878
2933
|
h("span", null, t("count.events", { n: data.events.length }) + (data.events.some((e) => e.isOccurrence) ? " · " + t("count.occurrences", { n: data.events.filter((e) => e.isOccurrence).length }) : "")),
|
|
@@ -2932,6 +2987,7 @@ if(compact) return content;
|
|
|
2932
2987
|
return __stardustUpdatesInlinePage(React,h(W.Page,{workbench:props.workbench===true,title:t('section.title'),subtitle:calendarText('查看日程、安排时间和管理导入。','View events, plan time and manage imports.'),className:'calendar-workbench',actions:h(React.Fragment,null,
|
|
2933
2988
|
h(W.Button,{icon:'link',disabled:configured===null,onClick:()=>setConnOpen(true)},calendarText('日历连接','Calendar connection')),
|
|
2934
2989
|
h(W.Button,{icon:'upload',disabled:demo || importBusy,onClick:()=>setImportOpen(value=>!value)},t('import.open')),
|
|
2990
|
+
h(W.Button,{disabled:demo || configured!==true,onClick:()=>setAvailabilityOpen(v=>!v)},calendarText('找空闲时段','Find available time')),
|
|
2935
2991
|
h(W.Button,{variant:'primary',disabled:demo || configured!==true,title:configured!==true?calendarText('请先连接日历','Connect a calendar first'):undefined,onClick:()=>setDraft(draftAt(anchor))},t('action.new')))},h('style',null,CALENDAR_WORKBENCH_CSS),content),"dsh-calendar");
|
|
2936
2992
|
}
|
|
2937
2993
|
/** 月份平移:先跳到本月 1 号再加月,避免 1 月 31 日 +1 个月变成 3 月 3 日。 */
|
|
@@ -3062,7 +3118,7 @@ calendarHostContext=ctx;W.register(ctx,{id:'dsh-calendar.workbench',label:()=>t(
|
|
|
3062
3118
|
exports.apply = apply;
|
|
3063
3119
|
exports.inject = inject;
|
|
3064
3120
|
return module.exports;
|
|
3065
|
-
} },{"name":"dsh-calendar","version":"0.1.0","hasWorkbench":true,"packageVersion":"0.
|
|
3121
|
+
} },{"name":"dsh-calendar","version":"0.1.0","hasWorkbench":true,"packageVersion":"0.14.0","label":"日历","labelEn":"Calendar","suiteComponent":true}));
|
|
3066
3122
|
|
|
3067
3123
|
})();
|
|
3068
3124
|
|
package/lib/ical.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface CalendarEvent {
|
|
|
30
30
|
/** 系列原开始时间(仅 isOccurrence 为 true 的实例存在)。 */
|
|
31
31
|
seriesStart?: string;
|
|
32
32
|
status?: string;
|
|
33
|
+
transparency?: string;
|
|
33
34
|
url?: string;
|
|
34
35
|
created?: string;
|
|
35
36
|
lastModified?: string;
|
|
@@ -74,7 +75,11 @@ export declare function parseEventFromICal(data: string, href: string, etag?: st
|
|
|
74
75
|
* @param maxOccurrences - 每个事件最多展开的实例数(防死循环)。
|
|
75
76
|
* @throws {ExpansionLimitError} 迭代次数超出预算仍未能到达查询窗口时抛出。
|
|
76
77
|
*/
|
|
77
|
-
export declare function expandEventFromICal(data: string, href: string, etag: string | undefined, rangeStart: string, rangeEnd: string, maxOccurrences: number
|
|
78
|
+
export declare function expandEventFromICal(data: string, href: string, etag: string | undefined, rangeStart: string, rangeEnd: string, maxOccurrences: number, options?: {
|
|
79
|
+
strict?: boolean;
|
|
80
|
+
overlap?: boolean;
|
|
81
|
+
deadline?: number;
|
|
82
|
+
}): CalendarEvent[];
|
|
78
83
|
/** 生成随机 iCal UID(带 host 后缀,形如 UUID)。 */
|
|
79
84
|
export declare function generateUid(): string;
|
|
80
85
|
/** 把字段生成一段完整 iCal 文本(单个 VEVENT)。 */
|
package/lib/ical.js
CHANGED
|
@@ -105,6 +105,7 @@ function veventToEvent(vevent, href, etag) {
|
|
|
105
105
|
const description = asString(vevent.getFirstPropertyValue('description'));
|
|
106
106
|
const location = asString(vevent.getFirstPropertyValue('location'));
|
|
107
107
|
const status = asString(vevent.getFirstPropertyValue('status'));
|
|
108
|
+
const transparency = asString(vevent.getFirstPropertyValue('transp'));
|
|
108
109
|
const url = asString(vevent.getFirstPropertyValue('url'));
|
|
109
110
|
const created = timePropertyValue(vevent, 'created');
|
|
110
111
|
const lastModified = timePropertyValue(vevent, 'last-modified');
|
|
@@ -140,6 +141,7 @@ function veventToEvent(vevent, href, etag) {
|
|
|
140
141
|
...(location !== undefined ? { location } : {}),
|
|
141
142
|
...(rrule !== undefined ? { rrule } : {}),
|
|
142
143
|
...(status !== undefined ? { status } : {}),
|
|
144
|
+
...(transparency !== undefined ? { transparency } : {}),
|
|
143
145
|
...(url !== undefined ? { url } : {}),
|
|
144
146
|
...(created !== undefined ? { created: icalTimeToIso(created) } : {}),
|
|
145
147
|
...(lastModified !== undefined ? { lastModified: icalTimeToIso(lastModified) } : {}),
|
|
@@ -178,6 +180,7 @@ function buildOccurrence(base, occurrenceStart, duration) {
|
|
|
178
180
|
...(base.description !== undefined ? { description: base.description } : {}),
|
|
179
181
|
...(base.location !== undefined ? { location: base.location } : {}),
|
|
180
182
|
...(base.status !== undefined ? { status: base.status } : {}),
|
|
183
|
+
...(base.transparency !== undefined ? { transparency: base.transparency } : {}),
|
|
181
184
|
...(base.url !== undefined ? { url: base.url } : {}),
|
|
182
185
|
...(base.created !== undefined ? { created: base.created } : {}),
|
|
183
186
|
...(base.lastModified !== undefined ? { lastModified: base.lastModified } : {}),
|
|
@@ -198,6 +201,7 @@ function buildOverrideEvent(base, vevent) {
|
|
|
198
201
|
isOccurrence: true,
|
|
199
202
|
seriesStart: base.start,
|
|
200
203
|
...(parsed.icalUid === undefined && base.icalUid !== undefined ? { icalUid: base.icalUid } : {}),
|
|
204
|
+
...(parsed.transparency === undefined && base.transparency !== undefined ? { transparency: base.transparency } : {}),
|
|
201
205
|
};
|
|
202
206
|
}
|
|
203
207
|
const recurrenceId = timePropertyValue(vevent, 'recurrence-id');
|
|
@@ -234,21 +238,33 @@ function buildOverrideEvent(base, vevent) {
|
|
|
234
238
|
* @param maxOccurrences - 每个事件最多展开的实例数(防死循环)。
|
|
235
239
|
* @throws {ExpansionLimitError} 迭代次数超出预算仍未能到达查询窗口时抛出。
|
|
236
240
|
*/
|
|
237
|
-
export function expandEventFromICal(data, href, etag, rangeStart, rangeEnd, maxOccurrences) {
|
|
241
|
+
export function expandEventFromICal(data, href, etag, rangeStart, rangeEnd, maxOccurrences, options = {}) {
|
|
238
242
|
let vcal;
|
|
239
243
|
try {
|
|
240
244
|
vcal = new ICAL.Component(ICAL.parse(data));
|
|
241
245
|
}
|
|
242
246
|
catch {
|
|
247
|
+
if (options.strict)
|
|
248
|
+
throw new ExpansionLimitError('无法完整解析日历资源,未给出空闲建议');
|
|
243
249
|
return [];
|
|
244
250
|
}
|
|
245
251
|
prepareTimezones(vcal);
|
|
246
252
|
const master = masterVevent(vcal);
|
|
247
|
-
if (master === null)
|
|
253
|
+
if (master === null) {
|
|
254
|
+
if (options.strict)
|
|
255
|
+
throw new ExpansionLimitError('日历资源缺少主日程,未给出空闲建议');
|
|
248
256
|
return [];
|
|
257
|
+
}
|
|
249
258
|
const base = veventToEvent(master, href, etag);
|
|
250
|
-
if (base === null)
|
|
259
|
+
if (base === null) {
|
|
260
|
+
if (options.strict)
|
|
261
|
+
throw new ExpansionLimitError('日程缺少开始时间,未给出空闲建议');
|
|
251
262
|
return [];
|
|
263
|
+
}
|
|
264
|
+
if (options.strict && base.allDay && base.end === base.start)
|
|
265
|
+
base.end = new Date(Date.parse(base.start) + 86400000).toISOString().slice(0, 10);
|
|
266
|
+
if (options.strict && Date.parse(base.end) < Date.parse(base.start))
|
|
267
|
+
throw new ExpansionLimitError('日程结束早于开始,未给出空闲建议');
|
|
252
268
|
// 收集 RECURRENCE-ID -> 覆盖 VEVENT,展开时按原实例时间替换。
|
|
253
269
|
const overrides = new Map();
|
|
254
270
|
for (const candidate of vcal.getAllSubcomponents('vevent')) {
|
|
@@ -268,19 +284,29 @@ export function expandEventFromICal(data, href, etag, rangeStart, rangeEnd, maxO
|
|
|
268
284
|
const rangeStartMs = isoToEpochMs(rangeStart);
|
|
269
285
|
const rangeEndMs = isoToEpochMs(rangeEnd);
|
|
270
286
|
const inRange = (event) => {
|
|
287
|
+
if (options.strict && event.allDay && event.end === event.start)
|
|
288
|
+
event.end = new Date(Date.parse(event.start) + 86400000).toISOString().slice(0, 10);
|
|
289
|
+
if (options.strict && Date.parse(event.end) < Date.parse(event.start))
|
|
290
|
+
throw new ExpansionLimitError('重复例外结束早于开始,未给出空闲建议');
|
|
271
291
|
const startMs = isoToEpochMs(event.start);
|
|
292
|
+
if (options.overlap)
|
|
293
|
+
return startMs < rangeEndMs && isoToEpochMs(event.end) > rangeStartMs;
|
|
272
294
|
return !Number.isNaN(startMs) && startMs >= rangeStartMs && startMs <= rangeEndMs;
|
|
273
295
|
};
|
|
274
|
-
const duration = veventDuration(master, dtstart);
|
|
296
|
+
const duration = veventDuration(master, dtstart) ?? (options.strict && base.allDay ? ICAL.Duration.fromData({ days: 1 }) : null);
|
|
275
297
|
const expansion = new ICAL.RecurExpansion({ component: master, dtstart });
|
|
276
298
|
const occurrences = [];
|
|
277
299
|
const consumedOverrides = new Set();
|
|
278
|
-
const totalIterationCap = Math.max(100000, maxOccurrences * 1000);
|
|
300
|
+
const totalIterationCap = options.strict ? 100000 : Math.max(100000, maxOccurrences * 1000);
|
|
279
301
|
let budgetExhausted = false;
|
|
280
302
|
try {
|
|
281
303
|
let iterations = 0;
|
|
282
|
-
while (occurrences.length < maxOccurrences) {
|
|
304
|
+
while (options.strict || occurrences.length < maxOccurrences) {
|
|
305
|
+
if (options.strict && occurrences.length > maxOccurrences)
|
|
306
|
+
throw new ExpansionLimitError('重复日程实例过多,请缩小空闲查询范围');
|
|
283
307
|
iterations += 1;
|
|
308
|
+
if (options.strict && iterations % 256 === 1 && Date.now() > (options.deadline ?? Infinity))
|
|
309
|
+
throw new ExpansionLimitError('空闲查询展开超时,请缩小范围');
|
|
284
310
|
if (iterations > totalIterationCap) {
|
|
285
311
|
budgetExhausted = true;
|
|
286
312
|
break;
|
|
@@ -301,12 +327,16 @@ export function expandEventFromICal(data, href, etag, rangeStart, rangeEnd, maxO
|
|
|
301
327
|
occurrences.push(overrideEvent);
|
|
302
328
|
continue;
|
|
303
329
|
}
|
|
304
|
-
if (occMs < rangeStartMs)
|
|
330
|
+
if (!options.overlap && occMs < rangeStartMs)
|
|
305
331
|
continue;
|
|
306
|
-
|
|
332
|
+
const occurrence = buildOccurrence(base, next, duration);
|
|
333
|
+
if (inRange(occurrence))
|
|
334
|
+
occurrences.push(occurrence);
|
|
307
335
|
}
|
|
308
336
|
}
|
|
309
|
-
catch {
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (options.strict)
|
|
339
|
+
throw error instanceof ExpansionLimitError ? error : new ExpansionLimitError('重复规则无法完整展开,未给出空闲建议');
|
|
310
340
|
// 规则无法满足或迭代异常时,返回已成功展开的部分实例。
|
|
311
341
|
}
|
|
312
342
|
if (budgetExhausted) {
|
|
@@ -321,8 +351,11 @@ export function expandEventFromICal(data, href, etag, rangeStart, rangeEnd, maxO
|
|
|
321
351
|
const overrideEvent = buildOverrideEvent(base, overrideVevent);
|
|
322
352
|
if (overrideEvent === null || !inRange(overrideEvent))
|
|
323
353
|
continue;
|
|
324
|
-
if (occurrences.length >= maxOccurrences)
|
|
354
|
+
if (occurrences.length >= maxOccurrences) {
|
|
355
|
+
if (options.strict)
|
|
356
|
+
throw new ExpansionLimitError('重复日程例外过多,请缩小空闲查询范围');
|
|
325
357
|
break;
|
|
358
|
+
}
|
|
326
359
|
occurrences.push(overrideEvent);
|
|
327
360
|
}
|
|
328
361
|
return occurrences;
|
package/lib/tools.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export declare function assertTimeRange(start: string, end: string): void;
|
|
|
34
34
|
export declare function isoNoMillis(value: string): string;
|
|
35
35
|
/** 按开始时间升序稳定排序(CalDAV 服务端返回顺序不保证稳定)。 */
|
|
36
36
|
export declare function sortEvents(events: CalendarEvent[]): CalendarEvent[];
|
|
37
|
-
/**
|
|
37
|
+
/** 构建七个工具定义;每个 execute 惰性解析配置,缺失时抛出中文指引。 */
|
|
38
38
|
/** 配置来源:静态对象,或一个 getter(面板把新配置写进 settings 后,工具下一次调用就该用新的)。 */
|
|
39
39
|
export type CalendarConfigSource = CalendarConfig | undefined | (() => CalendarConfig | undefined);
|
|
40
40
|
export declare function buildCalendarTools(config: CalendarConfigSource, env?: NodeJS.ProcessEnv): CalendarToolDefinition[];
|
package/lib/tools.js
CHANGED
|
@@ -397,5 +397,27 @@ export function buildCalendarTools(config, env = process.env) {
|
|
|
397
397
|
},
|
|
398
398
|
timeoutMs: TIMEOUT_MS,
|
|
399
399
|
};
|
|
400
|
-
|
|
400
|
+
const findTime = {
|
|
401
|
+
name: 'calendar_find_time',
|
|
402
|
+
description: '只读查询当前连接日历的空闲时段,不创建或预约日程。须提供带时区的 start/end(最多 31 天)与 IANA timeZone;默认工作日周一至周五 09:00–18:00、60 分钟时长、15 分钟候选间隔。按查询时区解释全天和浮动时间,排除透明或取消事件,展开完整的重复例外;读取或展开不完整时明确失败,不把部分结果当空闲。bufferMinutes 仅用于定时日程前后缓冲。候选可重叠,只反映读取时当前连接的一个日历,不保证其他日历或参会者空闲;创建前需向用户核对。',
|
|
403
|
+
parameters: compileParameters({
|
|
404
|
+
start: { type: 'string', required: true, description: '查询开始,带 Z 或 UTC 偏移的 ISO 时间' },
|
|
405
|
+
end: { type: 'string', required: true, description: '查询结束,不包含端点,最多 31 天' },
|
|
406
|
+
timeZone: { type: 'string', required: true, description: '工作时间和全天日期使用的 IANA 时区,如 Asia/Shanghai' },
|
|
407
|
+
durationMinutes: { type: 'integer', description: '时长 5–480 分钟,默认 60' },
|
|
408
|
+
workStart: { type: 'string', description: '每天工作开始 HH:mm,默认 09:00' },
|
|
409
|
+
workEnd: { type: 'string', description: '每天工作结束 HH:mm,默认 18:00,不支持跨日窗口' },
|
|
410
|
+
weekdays: { type: 'array', items: 'integer', description: '工作日,周一 1 至周日 7;默认 [1,2,3,4,5]' },
|
|
411
|
+
bufferMinutes: { type: 'integer', description: '定时日程前后缓冲 0–120 分钟,默认 0' },
|
|
412
|
+
stepMinutes: { type: 'integer', description: '本地钟点候选间隔 1–60 分钟,默认 15' },
|
|
413
|
+
limit: { type: 'integer', description: '最多返回 1–100 个候选,默认 20' },
|
|
414
|
+
}),
|
|
415
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => {
|
|
416
|
+
const result = value;
|
|
417
|
+
return [{ type: 'text', text: ['当前连接日历的空闲候选(' + result.timeZone + ';读取于 ' + result.checkedAt + ';不代表其他日历或参会者空闲):', ...result.slots.map(s => s.start + ' ~ ' + s.end), result.hasMore ? '候选数量已达上限,可调整范围或数量后再查。' : result.count ? '候选仅供选择,尚未预约或创建日程。' : '完整查询范围内没有符合条件的时段。'].join('\n') }];
|
|
418
|
+
} },
|
|
419
|
+
execute: (args, exec) => service().availability(asRecord(args), executionSignal(exec)),
|
|
420
|
+
timeoutMs: TIMEOUT_MS,
|
|
421
|
+
};
|
|
422
|
+
return [list, create, update, remove, search, health, findTime];
|
|
401
423
|
}
|
package/lib/web.d.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { type CalendarConfig } from './config.js';
|
|
17
17
|
import { type CalendarSettingsValue } from './settings.js';
|
|
18
18
|
import type { CalendarEvent } from './ical.js';
|
|
19
|
+
import type { AvailabilityInput, AvailabilityResult } from './availability.js';
|
|
19
20
|
/** 面板与浏览器说话的同源路由。 */
|
|
20
21
|
export declare const SETTINGS_ROUTE = "/_dsh/dsh-calendar/settings";
|
|
21
22
|
/** Host 头裁决:undefined = 放行,否则给出拒绝原因。 */
|
|
@@ -27,6 +28,7 @@ export declare function postVerdict(headers: Record<string, unknown>): {
|
|
|
27
28
|
} | undefined;
|
|
28
29
|
/** 面板需要的服务面;测试里可以塞假的进来。 */
|
|
29
30
|
export interface CalendarServiceLike {
|
|
31
|
+
availability?(input: AvailabilityInput, signal?: AbortSignal): Promise<AvailabilityResult>;
|
|
30
32
|
get?(uid: string, signal?: AbortSignal): Promise<CalendarEvent>;
|
|
31
33
|
list(start: string, end: string, options?: unknown, signal?: AbortSignal): Promise<CalendarEvent[]>;
|
|
32
34
|
create(fields: unknown, signal?: AbortSignal): Promise<CalendarEvent>;
|
package/lib/web.js
CHANGED
|
@@ -334,18 +334,27 @@ export class CalendarSettingsBackend {
|
|
|
334
334
|
});
|
|
335
335
|
return;
|
|
336
336
|
}
|
|
337
|
+
const requestController = new AbortController();
|
|
338
|
+
const disconnect = () => { if (!res.writableEnded)
|
|
339
|
+
requestController.abort(new Error('空闲查询已取消')); };
|
|
340
|
+
res.once?.('close', disconnect);
|
|
337
341
|
try {
|
|
338
342
|
const body = await readJsonBody(req);
|
|
339
|
-
const value = await this.dispatch(asRecord(body));
|
|
343
|
+
const value = await this.dispatch(asRecord(body), requestController.signal);
|
|
340
344
|
this.responseJson(res, 200, { ok: true, value });
|
|
341
345
|
}
|
|
342
346
|
catch (error) {
|
|
347
|
+
if (res.destroyed)
|
|
348
|
+
return;
|
|
343
349
|
const message = error instanceof Error ? error.message : String(error);
|
|
344
350
|
this.responseJson(res, 400, { ok: false, error: { code: 'rejected', message } });
|
|
345
351
|
}
|
|
352
|
+
finally {
|
|
353
|
+
res.off?.('close', disconnect);
|
|
354
|
+
}
|
|
346
355
|
}
|
|
347
356
|
/** 动作分发。每个动作自己校验参数,错误信息面向用户(中文,面板直接显示)。 */
|
|
348
|
-
async dispatch(body) {
|
|
357
|
+
async dispatch(body, signal) {
|
|
349
358
|
const action = typeof body.action === 'string' ? body.action : '';
|
|
350
359
|
if (action === 'status' || action === 'connection')
|
|
351
360
|
return this.connection();
|
|
@@ -384,6 +393,12 @@ export class CalendarSettingsBackend {
|
|
|
384
393
|
}));
|
|
385
394
|
return { count: events.length, start, end, events };
|
|
386
395
|
}
|
|
396
|
+
if (action === 'availability') {
|
|
397
|
+
const service = this.service();
|
|
398
|
+
if (!service.availability)
|
|
399
|
+
throw new Error('当前日历连接不支持完整的空闲查询,请更新插件');
|
|
400
|
+
return service.availability(body, signal);
|
|
401
|
+
}
|
|
387
402
|
if (action === 'create') {
|
|
388
403
|
const fields = eventFields(body, true);
|
|
389
404
|
return { event: await this.service().create(fields) };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-calendar",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH 日历工作台:CalDAV
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "DSH 日历工作台:CalDAV 日程、月 / 周 / 议程视图、空闲时段建议与 ICS 预览导入。欢迎提交 issues 和 PR。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"plugin",
|
|
50
50
|
"calendar",
|
|
51
51
|
"caldav",
|
|
52
|
-
"ical"
|
|
52
|
+
"ical",
|
|
53
|
+
"availability"
|
|
53
54
|
],
|
|
54
55
|
"license": "MIT",
|
|
55
56
|
"engines": {
|