diff --git a/.github/workflows/validate-pinned-dsh-source.yml b/.github/workflows/validate-pinned-dsh-source.yml index 4b4cc67a..f3ad9199 100644 --- a/.github/workflows/validate-pinned-dsh-source.yml +++ b/.github/workflows/validate-pinned-dsh-source.yml @@ -124,6 +124,8 @@ jobs: if ($LASTEXITCODE) { throw 'Distribution config patch checks failed' } node --test core/dsh-plugins/tool-browser/test/*.test.mjs if ($LASTEXITCODE) { throw 'Managed browser lifecycle and search configuration checks failed' } + node --test core/dsh-plugins/calendar/test/*.test.mjs + if ($LASTEXITCODE) { throw 'Calendar record and local storage checks failed' } node --test core/scripts/test/session-search-runtime.test.mjs if ($LASTEXITCODE) { throw 'Session search live persistence regression checks failed' } node --test core/dsh-host/test/native-020.test.mjs core/dsh-electron/tests/update-channel-migration.test.mjs core/dsh-electron/tests/alpha-update-migration.test.mjs diff --git a/config/assembly.eduwork.json b/config/assembly.eduwork.json index b650f8f1..2c7330af 100644 --- a/config/assembly.eduwork.json +++ b/config/assembly.eduwork.json @@ -173,6 +173,10 @@ { "source": "dsh-plugins/media-openai", "package": "dsh-media-openai" + }, + { + "source": "dsh-plugins/calendar", + "package": "dsh-calendar" } ], "skills": [ diff --git a/config/distributions/generic.json b/config/distributions/generic.json index 009b0678..683261d6 100644 --- a/config/distributions/generic.json +++ b/config/distributions/generic.json @@ -129,6 +129,12 @@ "source": "dsh-plugins/activity-insights-native", "id": "eduwork-activity-insights-native", "name": "@chatecnu-work/dsh-activity-insights-native" + }, + { + "root": "core", + "source": "dsh-plugins/calendar", + "id": "eduwork-calendar", + "name": "@eduwork/dsh-calendar" } ], "skills": [ @@ -229,6 +235,14 @@ } ] }, + { + "insert": [ + { + "id": "eduwork-calendar-tools", + "name": "@eduwork/dsh-calendar/tools" + } + ] + }, { "id": "ui-brand-official", "disabled": true diff --git a/docs/images/README.md b/docs/images/README.md index c71d978a..92ea62e3 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -4,11 +4,13 @@ | 文件 | 展示内容 | | --- | --- | -| `readme-hero.svg` · `readme-hero-en.svg` | 保留的早期品牌插画;当前首页使用 `assets/eduwork/icon-red.svg` 与下列五张实机截图。 | +| `readme-hero.svg` · `readme-hero-en.svg` | 保留的早期品牌插画;当前首页使用 `assets/eduwork/icon-red.svg` 与 `workspace`、`studio`、`quiz`、`skills`、`litellm-models` 五张实机截图。 | | `workspace.png` | 公版工作区:调研对话与生成的 HTML 网页预览。 | | `studio.png` | 公版 Studio:成果类型入口与最近生成的测验。 | | `quiz.png` | 测验答题、解析与来源依据。 | | `skills.png` | 技能中心。 | | `litellm-models.png` | LiteLLM 登录后选择获授权的模型;红框标出 LiteLLM 分组。 | +| `calendar-week.png` | 公版日历:周视图的日程块与重叠分列,以及画在日期表头里的全天记录。 | +| `calendar-agent.png` | 模型调用日历工具后,写入停在待确认状态。 | 截图使用公版红色主题与演示资料,展示功能与操作入口;图中生成内容不作为事实参考。中英文文档共用图片;更新时同步替代文本,并避免包含个人或机构私有数据。 diff --git a/docs/images/README_EN.md b/docs/images/README_EN.md index a765129c..983fd702 100644 --- a/docs/images/README_EN.md +++ b/docs/images/README_EN.md @@ -4,11 +4,13 @@ | File | Content | | --- | --- | -| `readme-hero.svg` · `readme-hero-en.svg` | Earlier brand illustrations retained as assets; the current homepage uses `assets/eduwork/icon-red.svg` and the five application screenshots below. | +| `readme-hero.svg` · `readme-hero-en.svg` | Earlier brand illustrations retained as assets; the current homepage uses `assets/eduwork/icon-red.svg` and the `workspace`, `studio`, `quiz`, `skills` and `litellm-models` screenshots. | | `workspace.png` | Public-edition workspace: research conversation and a generated HTML page preview | | `studio.png` | Public-edition Studio: output types and a recently generated quiz | | `quiz.png` | Quiz answers, explanations, and source evidence | | `skills.png` | Skill management | | `litellm-models.png` | Selecting an authorized model after LiteLLM sign-in; the red box highlights the LiteLLM group | +| `calendar-week.png` | Public-edition calendar: entries in the week view, overlapped entries side by side, and whole-day records drawn in the day headers | +| `calendar-agent.png` | A calendar write left waiting for confirmation after the model calls a tool | Screenshots show the public edition's red theme with demonstration material. They illustrate features and controls; generated content in them is not a factual reference. Chinese and English documents share the same images. Update alternative text with the images and exclude personal or institution-private information. diff --git a/docs/images/calendar-agent.png b/docs/images/calendar-agent.png new file mode 100644 index 00000000..6d71f1ff Binary files /dev/null and b/docs/images/calendar-agent.png differ diff --git a/docs/images/calendar-week.png b/docs/images/calendar-week.png new file mode 100644 index 00000000..413c8e44 Binary files /dev/null and b/docs/images/calendar-week.png differ diff --git a/docs/proposals/calendar/README.md b/docs/proposals/calendar/README.md new file mode 100644 index 00000000..628b841c --- /dev/null +++ b/docs/proposals/calendar/README.md @@ -0,0 +1,263 @@ +# 日历与日程模块提案 + +**简体中文** | [English](README_EN.md) + +状态:本提案的第 1–4 步已实现,代码与用法见插件 [README](../../../dsh-plugins/calendar/README.md);第 5 步(机构注入入口)尚未开始。字段名与配置项以插件文档与代码为准,本提案记录边界与取舍;实际执行的验证与未验证范围见[开发拆分与验收](#开发拆分与验收)。 + +归属:公版(EduWork)。机构版的数据接入另案,边界见[机构版边界](#机构版边界)。 + +## 目标 + +在公版引入一个可独立运行的日历与日程插件:提供周视图、本机事件存储、ICS 导入导出,以及一个**开放的数据结构**,供机构版注入学校数据(校历、课表)。课表是日历模块的一种数据来源,不是公版专属概念。 + +插件可被其他 DSH 应用单独使用,不要求安装 EduWork 桌面端;不引入运行时依赖,不依赖机构凭据、私有网络或相邻机构仓库。 + +## 插件身份 + +| 项 | 值 | +| --- | --- | +| 发行标识 | `eduwork-calendar` | +| 包名 | `@eduwork/dsh-calendar` | +| 源码 | `dsh-plugins/calendar` | +| 面板标识 | `calendar` | +| 形态 | 内置源码扩展:装配时从源码构建,不单独发布 npm 包 | + +命名取舍:这一能力在公版面向的是"日历与日程",不是某一所学校的课表;课表与校历都是它的数据来源。上游已有 `@deepseek-ai/dsh-schedule`(提醒引擎)与 `@deepseek-ai/dsh-client-ui-schedule`(会话页头的只读提醒目录),为避免概念重叠,本插件不使用 `schedule` 命名。 + +可选性:公版插件默认随发行安装。本插件按现有 UI 扩展的方式声明**可整体移除且不侵入核心**(`package.json` 的 `chatecnuWork` 声明:`kind` 为 `ui-extension`、`removable` 为真、`uses` 列出所用槽位)。移除它不影响对话、Studio 与其他能力;是否存在面向用户的开关由设置页既有能力决定,不在本提案内。 + +## 范围 + +纳入本提案: + +| 能力 | 说明 | +| --- | --- | +| 周视图 | 第一版视图:按周展示事件,支持前后翻周、当前周与今天标记 | +| 本机事件存储 | 事件仅保存在本机,跨会话保留 | +| ICS 导入与导出 | 解析与生成最小 `VEVENT` 集合,对应常见的校历与课表数据 | +| 开放数据结构 | 公版定义的日程契约,机构版按此注入数据 | +| 侧栏入口 | 复用现有 `sidebar.panellist` 与 `main` 插槽(已验证) | + +不在本提案内: + +| 事项 | 去向 | +| --- | --- | +| 按时间点触发提醒 | 独立提案,见[提醒边界](#提醒边界) | +| 教务系统抓取、校历、节假日调休 | 机构版,见[机构版边界](#机构版边界) | +| 月视图、议程视图、拖拽编辑 | 后续按需 | +| CalDAV、ICS 订阅同步、会议邀请 | 不在范围内 | +| 课堂纪要、作业等课程挂载能力 | 后续里程碑 | + +## 与既有实现的关系 + +### 官方底座 + +| 包 | 作用 | 与本文的关系 | +| --- | --- | --- | +| `@deepseek-ai/dsh-schedule` | 宿主侧提醒引擎:一次性、定期间隔与日历规则提醒,跨重启存活 | 提醒(另案)的底座;本提案不复制调度能力 | +| `@deepseek-ai/dsh-client-ui-schedule` | 会话页头的只读提醒目录 | 与日历面板职责不同,不替换它 | + +两者都只覆盖"提醒的存在与到期时间",不提供日历视图、事件存储与数据注入入口,因此本提案与其功能不重叠。 + +### 第三方实现 + +调研记录见 issue #48(2026-09-27)。结论是没有现成的课表插件,也没有一个实现同时满足"本地存储 + 提醒 + 数据注入 + 周视图"。与本提案相关的候选类型: + +| 候选类型 | 值得参考的部分 | 直接引入的顾虑 | +| --- | --- | --- | +| 多视图日历(周/月/议程、拖拽) | 视图布局与交互设计 | 停在较早的 DSH API 版本线;单人维护;自带数据模型与样式 | +| 以 CalDAV 与 RRULE 为主的日历工具集 | ICS 兼容与 RRULE 展开的实现思路 | 较新版本已不提供 UI;内部模型与本提案的开放契约不同 | +| iCal 订阅与提前提醒类 | 订阅导入的产品形态 | 依赖外部订阅源;提醒通道与本仓库的通知契约不同 | +| 节假日与农历类 | 节假日数据来源 | 校历与调休应以学校数据为准,属机构版 | + +引入任何候选前需要重新核对其许可证、版本线与维护状态。 + +### 复用路径与准入条件 + +本仓库已有四类成熟的复用机制,都可以用于日历能力,但准入条件不同: + +| 路径 | 仓库内先例 | 准入条件 | 代价 | +| --- | --- | --- | --- | +| 参考设计、自行实现 | 多处 | 无 | 无许可与版本负担;工作量集中在视图与契约 | +| 源码内置 | `dsh-host/vendor/jsonc-parser`(保留许可证与 README) | 保留许可证与出处,纳入源码审计 | 需长期跟随上游修复 | +| fork 为独立 npm 包 | `@eduwork/dsh-literature`(fork 自第三方并接续维护) | 迁入 `packages/`、改名、适配 DSH 基线、**发布 npm**、补组件锁;需要维护者配合 | 依赖与发布流程变重 | +| 运行时补丁 | 会话检索回填 | 记录补丁标识、包版本与前后 SHA-256 | 只适用于小范围修复 | + +### 本提案的取法:复用设计,自行实现 + +本提案主张**复用设计、自行实现**,理由: + +- 调研结论本身指向这条路径:现成实现都不满足"本地存储 + 提醒 + 数据注入 + 周视图"四条,而其中最完整的候选,强项是月视图、议程视图与拖拽,恰好不在本提案范围。 +- 直接引入会把许可、版本线、依赖锁与长期跟进上游的责任一并带入,而本提案真正依赖的开放契约、Host 侧注入入口与通知对接,第三方并不提供。 +- 自行实现可以沿用现有主题变量与界面基础组件,风格与现有面板天然一致,不需要"先接入再改造"。 + +复用的具体内容:只参考信息结构与交互——一周的排布、当前时间与今天的标记、事件块的信息层次、翻周与空状态。视觉与代码在本仓库自行编写。 + +约束: + +- 保持零运行时依赖,与 `dsh-plugins/` 现有插件一致(它们均未声明运行时 `dependencies`)。 +- ICS 与 RRULE 只实现[重复规则边界](#重复规则边界)列出的子集,思路参考调研结论,不引入第三方解析依赖。 +- 若维护者更希望以 fork 或源码内置的方式复用某个既有实现,请在评审时指定候选,我们按上表对应的准入条件另开提案;这会改变依赖锁与发布安排。 + +## 事件数据结构 + +公开契约的核心。字段按 ICS 语义设计,避免绑定任何单一数据源。 + +| 字段 | 类型 | 必填 | ICS 对应 | 说明 | +| --- | --- | --- | --- | --- | +| `uid` | string | 是 | `UID` | 事件稳定标识;导入时保留原值,生成时使用本机唯一值 | +| `title` | string | 是 | `SUMMARY` | 标题,如"组会""体检";机构注入的课表里就是课程名 | +| `start` | string | 是 | `DTSTART` | 本地时间 `YYYY-MM-DDTHH:mm`,或日期 `YYYY-MM-DD` | +| `end` | string | 否 | `DTEND` | 缺省时按日期事件或零时长处理 | +| `location` | string | 否 | `LOCATION` | 教室等地点 | +| `description` | string | 否 | `DESCRIPTION` | 备注 | +| `recurrence` | object | 否 | `RRULE` | 见[重复规则边界](#重复规则边界) | +| `exceptions` | string[] | 否 | `EXDATE` | 被取消的实例 | +| `source` | string | 否 | — | 来源标识:手工录入、导入或机构注入 | +| `extensions` | object | 否 | `X-*` | 未识别字段的原样保留区 | + +示例(合成数据): + +```json +{ + "schemaVersion": 2, + "events": [ + { + "uid": "demo-event-1", + "title": "示例讲座", + "start": "2026-03-02T08:00", + "end": "2026-03-02T09:35", + "location": "示例楼 101", + "recurrence": { "freq": "weekly", "count": 16, "byDay": ["mo"] }, + "source": "manual" + } + ] +} +``` + +约定: + +- 本提案主张的是**结构开放**:字段可增、未识别字段原样保留、结构带 `schemaVersion`、新增可选字段不提升主版本。 +- 字段命名与时间语义(第一版按本地时间处理,不做时区换算)属于实现细节,在评审中确定,不影响上述结论。 +- 公开契约只定义数据形状,不定义存储位置与文件名。 + +## 为什么公版里没有课程实体 + +第一版曾把课程作为公开契约的一部分(一张 `courses` 表,事件通过 `courseId` 引用)。现在公版里只有日程这一种记录,理由是课程不是日历的通用概念: + +- 日历的数据单位是"某一时刻有安排",一门课只是这条安排的来源与分组方式。课程表、教师、默认教室属于教务数据,公版不含任何学校专属逻辑。 +- 一个"课程"实体只有在有人替它维护教学班、学期与培养方案时才有内容;公版拿不到这些,于是这张表除了在导入导出时往返一次,没有别的用途。 +- 机构版把课表作为**数据来源**注入:一门课的一次课就是一条日程,靠 `source` 归属与着色,靠 `recurrence` 表示每周重复。同一个"日程"结构因此也装得下邮件、活动与个人安排,不需要为每种来源加一个实体。 + +将来的课堂纪要、作业与成绩等能力按课程挂载,需要时由机构注入侧提供课程标识(放在 `extensions` 或 `source` 作用域内),不改变公版的日程契约。 + +## 重复规则边界 + +`recurrence` 只支持日程与校历真正需要的子集: + +- 支持:`weekly`、`interval`、`count`、`until`、`byDay` +- 不支持:`monthly`、`yearly`、`BYMONTHDAY`、`BYSETPOS` 及复杂 `RDATE` 组合 +- 不支持的规则在导入时保留原始 `RRULE` 字符串并降级为单次事件,不静默丢弃 + +"第几教学周、单双周、节假日调休"依赖校历,属于机构版数据;公版只接受"学期起始日期"这类通用输入。 + +## ICS 边界 + +导入与导出使用自研的最小实现,不引入第三方日历解析依赖。 + +- 导入:`VCALENDAR`、`VEVENT`、`DTSTART`、`DTEND`(日期与本地时间)、`SUMMARY`、`LOCATION`、`DESCRIPTION`、`UID`、`RRULE`、`EXDATE`、`RECURRENCE-ID`;行折叠、转义与 UTF-8 按 RFC 5545 处理 +- 导出:生成可被常见日历应用读取的最小 `VEVENT` 集合;有单次改动的重复日程同时导出主事件与带 `RECURRENCE-ID` 的单次事件 +- 早期形状的 `X-EDUWORK-COURSE*` 与 `X-EDUWORK-NOTE` 属性在导入时识别后丢弃:课程不再是公版概念,它们的取值改为写进日程自身的 `SUMMARY`/`LOCATION`/`DESCRIPTION`,不会随导出再写回 +- 单次改动与取消:`RECURRENCE-ID` 指向的那一次写入日程的 `overrides` 或 `exceptions`,不改动系列本身;没有 `RRULE` 的日程直接改写记录,不写单次覆盖 +- 不做 `VTIMEZONE` 完整实现、`VALARM`(提醒由通知管道负责)、CalDAV 与订阅 +- 导入是显式用户动作,不做后台抓取 + +## 展示层边界 + +本提案的第一个可交付结论(已完成可行性验证): + +- 公版左侧栏的面板列表插槽是 `sidebar.panellist`,列表槽,由 `@deepseek-ai/dsh-client-ui-sidebar` 拥有。登记项需要 `id`,可选 `order` 与 `label`;行、按钮、提示与高亮由侧栏负责渲染。 +- 点击登记项时侧栏调用 `selectPanel(id)`,主区切换到 `main` 插槽中 `key` 等于同一 `id` 的注册项。 +- **结论:产品需要的左侧日历入口不需要新增"左侧面板"能力**,复用上述两个插槽即可。 +- 侧栏标签文案与是否支持按发行配置覆盖,见[尚待明确](#尚待明确)。 + +![周视图:整日小时轴上的日程块,同一时段重叠的并排分列;不带时间的记录画在日期表头(10/6–10/8 与 10/9)](../../images/calendar-week.png) + +## Agent 读写边界 + +面板不是唯一的写入方:`lib/tools.js` 把同一份 `calendar` 服务以五个工具开放给会话里的模型——`calendar_list_events` 读一个区间,`calendar_create_event`、`calendar_update_event`、`calendar_delete_event` 与 `calendar_occurrence` 写,最后一个只处理系列的某一次(改期、复位、取消、恢复)。 + +三条边界: + +- **区间必须有限**:不写 `from` 就从今天往前 180 天,不写 `to` 就给一年,单次最多 3660 天。没有结束日期的每周系列会一直展开到给定的上界,不设上界等于要四千年的日期。 +- **写入只带要改的字段**:留空保持原值,空字符串才是清除;挪动开始时间时保留原时长。要求整条替换的接口对模型不友好——漏一个字段就等于静默丢掉它。 +- **写入的默认答案是需要确认**:在 `tools/pre-execute` 上按工具名判定,读取放行、写入询问、已经「无审批」的会话放行、没有会话上下文的写入拒绝(`lib/tool-permission.js`)。工具与面板写同一份记录、走同一套校验,不新增第二条数据通路。 + +单次改期与整条修改分成两个工具,正好对应 `RECURRENCE-ID` 与系列本身:改哪一次由模型显式选择,而不是由一个 `scope` 参数隐式决定。 + +![模型调用 calendar_create_event 后,写入停在待确认状态,由用户决定拒绝或允许一次](../../images/calendar-agent.png) + +## 数据形态与存储 + +事件由 Host 半持有,客户端半读取快照并渲染。理由: + +- 机构注入需要 Host 侧入口,客户端独占存储无法被注入。 +- 后续的 Agent 工具与提醒需要同一份数据。 +- 提醒(另案)在 Host 侧订阅同一份数据,不重复解析。 + +本机存储的具体后端(宿主存储服务或插件自有文件)在实现阶段确定,不进入公开契约。 + +隐私: + +- 事件仅保存在本机,不上传、不写入日志。 +- 提醒只提示标题与地点,不携带描述正文,沿用桌面通知的既有默认。 +- 机构数据接入沿用现有安全约束:令牌不进入插件、不落盘、不写日志。 + +## 提醒边界 + +按时间点触发提醒不在本提案内,因为它跨越插件与桌面壳边界: + +- 现有[桌面通知](../../DESKTOP_NOTIFICATIONS.md)只有需求确认、本轮完成、最终失败与 Studio 成果四类,没有"按时间点"通道;扩展需要新增类别并调整本地事件桥。 +- 到点提醒在冷会话(应用驻留托盘但会话不活跃)能否投递取决于运行时基线;现有发行线与候选线在这项能力上不同,需要核心团队确认目标基线。 +- 因此提醒独立成案:先确认事件契约与可用基线,再实现。 + +## 机构版边界 + +- 公版不含任何学校专属逻辑:不含教务系统地址、抓取实现、校历与节假日规则。 +- 数据来源目前只是一个字符串 `source`(归属与着色按它分组):注入侧写入、按来源作用域替换、客户端按来源着色都已可用,但**没有来源注册机制**——没有地方登记来源的名称、颜色与同步策略。这是路线图里"数据源注册"尚未交付的一半,形态见[尚待明确](#尚待明确)。 +- 机构版按[发行版边界](../../EDITIONS.md)在 `edition/plugins/` 提供适配器:把学校数据转换为上述公开结构,并通过公版提供的 Host 侧入口注入。 +- 机构版升锁依赖公版**发版**:装配在机构模式校验公版提交与源码回执,因此机构集成在公版合入并发布后进行,不在本提案内。 +- 离线路径同样可行:机构侧把教务系统页面另存后解析为 ICS 或上述 JSON,再走通用导入,不依赖校方接口。 + +## 兼容性边界 + +- 新增一个独立插件,不修改现有插件的公开导出、配置标识和数据路径。 +- 不新增运行时依赖,不改依赖锁,也不新增需要单独发布的 npm 包(内置源码扩展在装配时重建)。 +- 需要评审的公开接口有两处:日程数据结构,以及客户端半读取与写入数据的远端方法面(当前 10 个方法)。两者都定义在 `lib/typert-schemas.js` 与 `lib/typert-descriptors.js`,主机与客户端共用同一份描述。 +- 发行清单新增一行插件登记,不改变既有插件的顺序与配置。 +- 无已发布的既有数据格式:本插件首次引入。开发期间记录契约去掉过 `courseId`,这类旧记录由存储层读取兼容,并在开库时改写成当前形状一次。 +- 不改桌面壳、更新器与机构仓;提醒另案。 +- 桌面端行为需要独立验收;本提案的实现先在 Web 组装上验证。 + +## 开发拆分与验收 + +1. 插件骨架与登记:侧栏入口与主区面板可用(已交付)。 +2. 日程模型与本机存储:合成数据往返、重启后保留、`node --test` 单元测试(已交付)。 +3. 周视图:翻周、当前周与今天标记;界面验证脚本与截图(已交付)。 +4. ICS 导入导出:合成样本的字段映射、行折叠与转义、导出结果语法校验;往返不丢未识别字段(已交付)。 +5. 机构注入入口:在公版定义 Host 侧入口,用合成适配器验证,不引入机构数据(未开始)。 +6. Agent 读写接口:五个工具覆盖读区间与四类写入,写入统一过确认;单元测试与装配登记(已交付)。 + +已执行的验证:插件单元测试在两条固定运行时线(通用 Web 的 `release-v0.1.5-rc.2` 与桌面候选的 `candidate-v0.2.0-rc.2`)上均为 81 例通过;通用 Web 组装后在本机产品里逐步走通界面;桌面候选线的界面只在候选组装流水线里重建与校验,未在本机跑完整候选产品。 + +每个 PR 报告实际执行的验证和未验证范围。模块检查沿用现有 CI;真实登录、Office、音视频与桌面行为按改动范围在本地专项验收。macOS 桌面通知需要实机验收,浏览器测试不能替代。 + +## 尚待明确 + +- 日程契约的字段最终形态与命名。 +- 机构注入入口的形态:Host 侧服务、发行配置,或两者并存。 +- **数据源注册的形态**:来源的名称、颜色、顺序与同步策略登记在哪里(插件配置、发行配置还是机构适配器自述),以及未登记的来源如何退化。当前只有 `source` 字符串,按来源着色与按来源作用域替换已实现,注册本身未实现。 +- 提醒契约的归属:新增通知类别还是复用现有类别,以及目标运行时基线。 +- 侧栏标签文案,以及发行清单是否支持按插件覆盖配置。 +- 是否需要以 fork 或源码内置的方式复用既有实现;如需,请指定候选。 +- 月视图与其他视图是否需要,以及对应排期。 diff --git a/docs/proposals/calendar/README_EN.md b/docs/proposals/calendar/README_EN.md new file mode 100644 index 00000000..e8063af8 --- /dev/null +++ b/docs/proposals/calendar/README_EN.md @@ -0,0 +1,263 @@ +# Calendar and schedule module proposal + +[简体中文](README.md) | **English** + +Status: steps 1–4 of this proposal are implemented; the code and its usage are documented in the plugin's [README](../../../dsh-plugins/calendar/README.md), and step 5 (the institution injection entry point) has not started. Field names and configuration keys follow the plugin documentation and code; this proposal records boundaries and trade-offs. The verification actually performed, and the parts left unverified, are listed under [Development split and acceptance](#development-split-and-acceptance). + +Ownership: public edition (EduWork). Institution data integration is a separate proposal; see [Institution boundary](#institution-boundary). + +## Goal + +Introduce a standalone calendar and schedule plugin in the public edition: a week view, local event storage, ICS import and export, and an **open data structure** that the institution edition can use to inject school data (academic calendar, class timetable). A timetable is one data source for the calendar module, not a public-edition concept of its own. + +The plugin can be used by other DSH applications without installing the EduWork desktop client. It adds no runtime dependency and does not depend on institution credentials, private networks or an adjacent institution repository. + +## Plugin identity + +| Item | Value | +| --- | --- | +| Release identifier | `eduwork-calendar` | +| Package name | `@eduwork/dsh-calendar` | +| Source | `dsh-plugins/calendar` | +| Panel identifier | `calendar` | +| Form | Built-in source extension: built from source during assembly, not published as a separate npm package | + +Naming: in the public edition this capability is a calendar and schedule, not one school's timetable; timetables and academic calendars are data sources for it. Upstream already provides `@deepseek-ai/dsh-schedule` (reminder engine) and `@deepseek-ai/dsh-client-ui-schedule` (read-only reminder list in the conversation header), so this plugin avoids the `schedule` name to prevent conceptual overlap. + +Optionality: public-edition plugins are installed with the release by default. This plugin declares itself **removable without intruding on the core**, following the existing UI extensions (`chatecnuWork` in `package.json`: `kind` is `ui-extension`, `removable` is true, `uses` lists the slots it occupies). Removing it does not affect chat, Studio or other capabilities. Whether a user-facing toggle exists is decided by existing settings capability and is outside this proposal. + +## Scope + +Included: + +| Capability | Description | +| --- | --- | +| Week view | First view: events grouped by week, with previous and next week, current week and today markers | +| Local event storage | Events are kept on this machine and survive across sessions | +| ICS import and export | Parse and emit a minimal `VEVENT` set, matching common academic-calendar and timetable data | +| Open data structure | The event contract defined by the public edition, used by the institution edition to inject data | +| Sidebar entry | Reuses the existing `sidebar.panellist` and `main` slots (verified) | + +Excluded: + +| Item | Destination | +| --- | --- | +| Reminders triggered at a point in time | Separate proposal, see [Reminder boundary](#reminder-boundary) | +| Scraping the academic system, academic calendar, holiday shifts | Institution edition, see [Institution boundary](#institution-boundary) | +| Month view, agenda view, drag-and-drop editing | Later as needed | +| CalDAV, ICS subscription sync, meeting invitations | Out of scope | +| Lecture notes and other course attachments | Later milestone | + +## Relationship to existing implementations + +### Official foundation + +| Package | Role | Relation to this document | +| --- | --- | --- | +| `@deepseek-ai/dsh-schedule` | Host-side reminder engine: one-off, fixed-interval and calendar-rule reminders that survive restarts | Foundation for reminders (separate proposal); this proposal does not duplicate scheduling | +| `@deepseek-ai/dsh-client-ui-schedule` | Read-only reminder list in the conversation header | Different responsibility from a calendar panel; it is not replaced | + +Both cover only the existence and due time of reminders. Neither provides a calendar view, event storage or a data-injection entry point, so this proposal does not overlap with them. + +### Third-party implementations + +The survey is recorded in issue #48 (2026-09-27). Its conclusion: there is no ready-made timetable plugin, and no single implementation covers local storage, reminders, data injection and a week view at the same time. Relevant candidate types: + +| Candidate type | Worth reusing | Concern about adopting it directly | +| --- | --- | --- | +| Multi-view calendars (week/month/agenda, drag and drop) | View layout and interaction design | Stops at an earlier DSH API line; single maintainer; brings its own data model and styling | +| Calendar toolkits centred on CalDAV and RRULE | Approach to ICS compatibility and RRULE expansion | Newer versions ship no UI; internal model differs from the open contract here | +| iCal subscription and early-reminder plugins | Product shape of subscription import | Depends on an external subscription source; reminder channel differs from this repository's notification contract | +| Holiday and lunar-calendar plugins | Source of holiday data | Academic calendars and holiday shifts must follow school data and belong to the institution edition | + +Any candidate must be re-checked for licence, version line and maintenance status before adoption. + +### Reuse paths and admission conditions + +This repository already has four established reuse mechanisms. All four could carry calendar capability, with different admission conditions: + +| Path | Precedent in this repository | Admission condition | Cost | +| --- | --- | --- | --- | +| Reference the design, implement it here | Several | None | No licence or version burden; work sits in the view and the contract | +| Vendor the source | `dsh-host/vendor/jsonc-parser` (keeps licence and README) | Keep licence and provenance, include in the source audit | Must follow upstream fixes over time | +| Fork as a standalone npm package | `@eduwork/dsh-literature`, forked from a third party and maintained here | Move into `packages/`, rename, adapt to the DSH baseline, **publish to npm**, add a component lock; maintainer involvement required | Heavier dependency and release flow | +| Runtime patch | Session-search backport | Record patch identity, package version and before/after SHA-256 | Only suitable for small fixes | + +### Approach taken here: reuse the design, implement it here + +This proposal argues for **reusing the design while implementing the code in this repository**: + +- The survey itself points this way: no existing implementation covers local storage, reminders, data injection and a week view at the same time, and the most complete candidate is strongest at month view, agenda view and drag and drop, none of which is in scope here. +- Adopting one directly would bring its licence, version line, dependency lock and the duty of following upstream along with it, while the things this proposal actually depends on — the open contract, the host-side injection entry point and notification integration — are not provided by any third party. +- Implementing it here can reuse the existing theme variables and UI primitives, so the styling matches the existing panels from the start instead of being adopted and then reworked. + +What is reused: information structure and interaction only — how a week is laid out, how the current time and today are marked, what an event block shows, week navigation and the empty state. Visuals and code are written in this repository. + +Constraints: + +- Keep zero runtime dependencies, consistent with existing `dsh-plugins/` (none of them declares runtime `dependencies`). +- ICS and RRULE implement only the subset listed under [Recurrence boundary](#recurrence-boundary), following the survey's approach and adding no third-party parser. +- If maintainers prefer to reuse an existing implementation through a fork or vendoring, please name the candidate during review; we will open a separate proposal under the matching admission conditions above. That would change dependency locks and release arrangements. + +## Event data structure + +The core of the public contract. Fields follow ICS semantics so that no single data source is baked in. + +| Field | Type | Required | ICS | Notes | +| --- | --- | --- | --- | --- | +| `uid` | string | yes | `UID` | Stable event identity; preserved on import, generated locally otherwise | +| `title` | string | yes | `SUMMARY` | Title, for example "standup" or "health check"; in an injected timetable it is the course name | +| `start` | string | yes | `DTSTART` | Local time `YYYY-MM-DDTHH:mm`, or a date `YYYY-MM-DD` | +| `end` | string | no | `DTEND` | Without it the event is a date entry or has zero length | +| `location` | string | no | `LOCATION` | Room or other place | +| `description` | string | no | `DESCRIPTION` | Notes | +| `recurrence` | object | no | `RRULE` | See [Recurrence boundary](#recurrence-boundary) | +| `exceptions` | string[] | no | `EXDATE` | Cancelled instances | +| `source` | string | no | — | Origin: manual, imported or institution-injected | +| `extensions` | object | no | `X-*` | Verbatim store for unrecognised fields | + +Example (synthetic data): + +```json +{ + "schemaVersion": 2, + "events": [ + { + "uid": "demo-event-1", + "title": "Sample talk", + "start": "2026-03-02T08:00", + "end": "2026-03-02T09:35", + "location": "Sample building 101", + "recurrence": { "freq": "weekly", "count": 16, "byDay": ["mo"] }, + "source": "manual" + } + ] +} +``` + +Conventions: + +- What this proposal asks for is an **open structure**: fields may be added, unrecognised fields are kept verbatim, the structure carries `schemaVersion`, and adding optional fields does not change the major version. +- Field naming and time semantics (local time in the first version, without time-zone conversion) are implementation details to be settled during review and do not affect the conclusions above. +- The public contract defines the shape only, not the storage location or file name. + +## Why the public edition has no course entity + +The first version carried a course in the public contract (a `courses` table, referenced by events through `courseId`). The public edition now holds one kind of record, the event, because a course is not a general calendar concept: + +- A calendar's unit of data is "something is scheduled at this time"; a lesson is one source and one grouping of that schedule. Course catalogues, teachers and default rooms are registrar data, and the public edition contains no school-specific logic. +- A "course" entity only has content while somebody maintains its classes, term and curriculum. The public edition has no such source, so that table did nothing beyond surviving an export and an import. +- The institution edition injects a timetable as a **data source**: one lesson is one event, owned and tinted by its `source`, repeated weekly through `recurrence`. The same event structure therefore carries mail, activities and personal plans, with no extra entity per source. + +Future lecture notes, assignments and grades attach by course; when that day comes, the injecting side brings the course identity (in `extensions` or within its `source` scope) without changing the public event contract. + +## Recurrence boundary + +`recurrence` supports only the subset that schedules and academic calendars actually need: + +- Supported: `weekly`, `interval`, `count`, `until`, `byDay` +- Not supported: `monthly`, `yearly`, `BYMONTHDAY`, `BYSETPOS` and complex `RDATE` combinations +- Unsupported rules keep the original `RRULE` string on import and degrade to a single event instead of being dropped silently + +"Which teaching week, odd or even weeks, holiday shifts" depend on the academic calendar and are institution data; the public edition accepts only generic input such as a term start date. + +## ICS boundary + +Import and export use a minimal in-house implementation and add no third-party calendar parser. + +- Import: `VCALENDAR`, `VEVENT`, `DTSTART`, `DTEND` (dates and local times), `SUMMARY`, `LOCATION`, `DESCRIPTION`, `UID`, `RRULE`, `EXDATE`, `RECURRENCE-ID`; line folding, escaping and UTF-8 follow RFC 5545 +- Export: a minimal `VEVENT` set readable by common calendar applications; a recurring event with per-date changes exports its master event together with a `RECURRENCE-ID` event for each change +- The retired `X-EDUWORK-COURSE*` and `X-EDUWORK-NOTE` properties of an earlier shape are recognised on import and dropped: a course is no longer a public-edition concept, and their values now live in the event's own `SUMMARY`/`LOCATION`/`DESCRIPTION`, so they are never written back +- A single date's change or cancellation is written to that event's `overrides` or `exceptions` through `RECURRENCE-ID`, without touching the series; a non-recurring event is rewritten in place instead, since a one-off record has no instance to override +- No full `VTIMEZONE` implementation, no `VALARM` (reminders belong to the notification pipeline), no CalDAV or subscriptions +- Import is an explicit user action; nothing is fetched in the background + +## Presentation boundary + +The first deliverable conclusion of this proposal (feasibility already verified): + +- The panel list slot of the public-edition sidebar is `sidebar.panellist`, a list slot owned by `@deepseek-ai/dsh-client-ui-sidebar`. An entry requires `id` and optionally `order` and `label`; the sidebar renders the row, button, tooltip and highlight. +- Clicking an entry calls `selectPanel(id)`, and the main area switches to the `main` slot entry whose `key` equals that `id`. +- **Conclusion: the left-hand calendar entry the product needs requires no new "left panel" capability**; the two slots above are enough. +- Sidebar label wording and whether a release configuration may override it are listed under [Open questions](#open-questions). + +![Week view: entries on one hour axis, overlapped entries side by side, and records without a time drawn in the day headers (10/6–10/8 and 10/9)](../../images/calendar-week.png) + +## Agent read and write boundary + +The panel is not the only writer: `lib/tools.js` opens the same `calendar` service to the model in a session through five tools — `calendar_list_events` reads one range, while `calendar_create_event`, `calendar_update_event`, `calendar_delete_event` and `calendar_occurrence` write, the last one acting on a single date of a series (move, reset, cancel, restore). + +Three boundaries: + +- **Every window is finite**: without `from` a read starts 180 days before today, without `to` it ends a year later, and one call may not span more than 3660 days. A weekly series with no end date expands up to whatever bound it is given, so no bound at all would mean four thousand years of dates. +- **A write carries only the fields it changes**: a field left out keeps its stored value and an empty string clears one, while moving a start keeps the entry's duration. An interface that demands a whole record is hostile to a model — one field forgotten there is one field silently lost. +- **The default answer for a write is a confirmation**: `tools/pre-execute` decides from the tool name — reads pass, writes ask, a session already running without approvals passes, and a write with no session behind it is denied (`lib/tool-permission.js`). The tools and the panel write the same records through the same validation; no second data path is added. + +Moving one date and updating the whole record are two tools, which is exactly `RECURRENCE-ID` against the series: the model chooses which one it means instead of a `scope` argument deciding it implicitly. + +![After the model calls calendar_create_event, the write waits for confirmation, where the user either declines or allows it once](../../images/calendar-agent.png) + +## Data shape and storage + +Events are held by the host half; the client half reads a snapshot and renders it. Reasons: + +- Institution injection needs a host-side entry point; client-only storage cannot be injected into. +- Later agent tools and reminders need the same data. +- Reminders (separate proposal) subscribe to the same data on the host side instead of parsing it again. + +The concrete local storage backend (host storage service or a plugin-owned file) is decided during implementation and is not part of the public contract. + +Privacy: + +- Events stay on this machine; nothing is uploaded and nothing is written to logs. +- Reminders show only title and place, never the description body, following the existing desktop notification default. +- Institution data access keeps the existing constraints: tokens never enter the plugin, are never written to disk and never logged. + +## Reminder boundary + +Reminders triggered at a point in time are outside this proposal because they cross the plugin and desktop-shell boundary: + +- [Desktop notifications](../../DESKTOP_NOTIFICATIONS_EN.md) currently have four categories — attention, completed, final failure and Studio results — and no "point in time" channel; extending them means a new category and a change to the local event bridge. +- Whether a due reminder can be delivered in a cold session (tray-resident application with no active session) depends on the runtime baseline; the current release line and the candidate line differ here, so the core team must confirm the target baseline. +- Reminders are therefore a separate proposal: settle the event contract and the usable baseline first, then implement. + +## Institution boundary + +- The public edition contains no school-specific logic: no academic-system address, scraper, academic calendar or holiday rules. +- A data source is currently only the `source` string (ownership, grouping and tint follow it): writing from an injecting side, replacing one source's records and tinting by source all work, but there is **no source registration** — nothing records a source's name, colour or sync policy. That is the half of the roadmap's "data-source registration" still missing; its shape is under [Open questions](#open-questions). +- Following the edition boundaries ([`docs/EDITIONS.md`](../../EDITIONS.md)), the institution edition supplies an adapter under `edition/plugins/`: it converts school data to the public structure above and injects it through the host-side entry point provided by the public edition. +- Raising the institution lock depends on a public **release**: assembly in institution mode validates the public commit and the source receipt, so institution integration happens after this work lands and is released, and is outside this proposal. +- An offline path also works: the institution side saves an academic-system page, parses it into ICS or the JSON above, and uses the generic import, with no dependency on a school API. + +## Compatibility boundary + +- Adds one standalone plugin; existing public exports, configuration keys and data paths are unchanged. +- Adds no runtime dependency, changes no dependency lock, and adds no npm package that needs separate publishing (built-in source extensions are rebuilt during assembly). +- Two public interfaces ask for review: the event data structure, and the remote method surface the client half reads and writes through (currently 10 methods). Both are defined in `lib/typert-schemas.js` and `lib/typert-descriptors.js`, in one description shared by the host and the client. +- The release manifest gains one plugin entry; the order and configuration of existing plugins do not change. +- No released data format to migrate: this is the plugin's first introduction. The record contract dropped `courseId` during development, and such an older record is read compatibly and rewritten into the current shape once when the store opens. +- The desktop shell, updater and institution repository are untouched; reminders are a separate proposal. +- Desktop behaviour needs independent acceptance; this proposal is verified on a Web assembly first. + +## Development split and acceptance + +1. Plugin skeleton and registration: the sidebar entry and the main-area panel work (delivered). +2. Event model and local storage: synthetic round trip, survival across restarts, `node --test` unit tests (delivered). +3. Week view: week navigation, current week and today markers; a UI verification script and screenshots (delivered). +4. ICS import and export: field mapping on synthetic samples, line folding and escaping, syntax check of the output; a round trip that keeps unrecognised fields (delivered). +5. Institution injection entry point: define the host-side entry point in the public edition, verify it with a synthetic adapter and no institution data (not started). +6. Agent read and write interface: five tools covering a range read and four kinds of write, every write behind one confirmation; unit tests and registration in the assembly (delivered). + +Verification performed: the plugin's unit tests pass (81 cases) on both pinned runtime lines — `release-v0.1.5-rc.2` for the generic Web product and `candidate-v0.2.0-rc.2` for the desktop candidate; the interface was walked through end to end in a generic Web assembly on this machine; the desktop candidate line's client is rebuilt and checked by the candidate assembly pipeline, but the full candidate product was not run here. + +Every pull request reports the verification actually performed and the parts left unverified. Module checks follow the existing CI; real sign-in, Office, audio and video, and desktop behaviour are accepted locally according to the change. macOS desktop notifications need acceptance on real hardware; a browser test is not a substitute. + +## Open questions + +- The final shape and naming of the event contract. +- The form of the institution injection entry point: a host-side service, release configuration, or both. +- **The shape of source registration**: where a source's name, colour, order and sync policy are recorded (plugin configuration, release configuration, or the institution adapter's own declaration), and how an unregistered source degrades. Today only the `source` string exists: tinting by source and replacing one source's records are implemented, registration itself is not. +- Ownership of the reminder contract: a new notification category or an existing one, and the target runtime baseline. +- Sidebar label wording, and whether the release manifest can override plugin configuration. +- Whether to reuse an existing implementation through a fork or vendoring; if so, which candidate. +- Whether a month view or other views are needed, and their scheduling. diff --git a/dsh-plugins/calendar/README.md b/dsh-plugins/calendar/README.md new file mode 100644 index 00000000..e0aa7868 --- /dev/null +++ b/dsh-plugins/calendar/README.md @@ -0,0 +1,109 @@ +# EduWork 日历插件 + +**简体中文** | [English](README_EN.md) + +公版的日历与日程面板:左侧栏提供入口,主区是按小时轴排布的周视图与日程页,Host 半提供日程记录的本机存储与 ICS 导入导出,并为机构版预留数据注入位置。 + +状态:开发中。Host 半的日程模型、本机存储、`calendar` 服务与客户端数据通道均已完成;周视图按整日小时轴画出这一周(今天列高亮、同一时段重叠的日程并排分列),打开时自动滚到这周日程所在的时段,点任意一条打开详情弹窗;顶部「新建日程」、空日历的引导与周视图里拖动或双击空白时段都能新建,日程页左栏改标题、日期、时间、地点与备注,右栏按月份列出最近一年的出现时间,可对单次改期、取消、恢复或删除整条;导入支持拖拽文件与粘贴文本,导出可复制或下载;模型也能改这份日历——五个工具覆盖读区间、新建、修改、删除与按单次改期或取消,写入前由用户确认。范围、日程数据结构与机构版边界见[日历与日程模块提案](../../docs/proposals/calendar/README.md)。 + +公版是日历,不是课表:公版里只有日程这一种记录,学校课表、校历与教务规则由机构插件作为数据来源注入,见[提案](../../docs/proposals/calendar/README.md)。 + +## 功能 + +- 左侧栏入口:登记到 `sidebar.panellist`,点击后主区切换到本面板。 +- 周视图:一周七列排在同一条整日小时轴上,左侧是整点刻度,今天列高亮;当前时刻画一条线。同一时段重叠的日程并排分列,而不是互相压住。可前后翻周、回到本周,并显示本周条数。 +- 拖动即可新建:在空白时段按下并沿时间轴拖出一段(15 分钟对齐),松手就按这个时间段打开新建弹窗;双击空白时段则按一小时新建。 +- 全天记录画在日期表头里:从界面新建的日程总带开始与结束时间;ICS 里不带时间的全天记录(`DTSTART;VALUE=DATE`,节假日与校历订阅几乎全是这种)照旧导入并显示,作为日期表头里的一枚「全天」小条列出,跨天的记录在它覆盖的每一天都出现。它不占时间轴上的任何一格,滚动时一直看得见,点开同样能改——全天记录本来就不属于某个时段,这样也才不会被滚过去。 +- 打开位置:周视图画的是完整的一天,打开时自动滚到这周日程所在的时段,面板再高也不会在下面留一片空白;一周没有日程时停在工作时段。 +- 日程详情:点任意一条打开弹窗,显示时间、地点、来源与重复规则,并可直接改期或改地点、撤销改期、取消这一次或删除整条日程;改期只改开始时间时,结束时间跟着一起移动,条目保持原长度;全天记录没有钟点,弹窗指向它的日程页去改日期与地点。 +- 新建与编辑:顶部「新建日程」、空日历中央的引导,以及周视图里双击空白时段都会打开新建弹窗,双击时时段已经填好;弹窗里写标题、日期、开始与结束时间、地点、备注,并可以打开"每周重复"。保存后回到周视图,新日程就在刚才的位置。日程页左栏直接改同这些字段,右栏点某一次可以单独处理那一次。 +- 重复规则:每周重复的日程按"发生"展开,`interval`、`count`、`until`、`byDay` 生效;`exceptions` 取消某一次,`overrides` 调整某一次。 +- 日程页:入口是顶部「日程」菜单(列出全部日程)或周视图里的标题。左栏改标题、日期、开始与结束时间、地点与备注,并列出这条记录的已取消日期与恢复按钮;右栏按月份分组列出最近一年的出现时间,点一条打开同一个详情弹窗。 +- 导入导出:导入是弹窗,既可以把 `.ics` 文件拖进面板或拖到弹窗里,也可以直接粘贴文本,导入结果(写入、移除、跳过与降级的明细)就在同一个弹窗里;导出弹窗给出可复制或下载的日历文本。 +- 日程记录:`calendar` 服务提供读取、写入、区间查询、按发生查询、快照导入与 ICS 导入导出,记录保存在本机。 + +## 界面 + +- 时间轴而不是卡片列表:日历的第一件事是时间。一天是一列,一小时 48 像素,日程是按开始与结束时间定位的块,所以"上一节 10:00 结束、下一节 10:00 开始"在画面上是上下相邻的两块,而不是两条并列的文字。 +- 重叠分列:一天里时间有交叠的日程归为一组,组内平均分配这一天的宽度;本组结束后(下一条的开始不早于本组最晚的结束)恢复整宽。日期也算身份的一部分:两个都从 08:00 开始但不在同一天的日程不算冲突。这部分几何是纯函数(`src/layout.js`),不依赖 React,单独有 15 例测试。 +- 整日轴:轴画满 24 小时,打开时滚到这周第一条日程所在的整点,所以面板高时看到的是更多小时,而不是固定工作时段下面的一片空白。翻周时轴不动,只换内容。 +- 颜色跟着来源:同一数据来源的日程用同一种色调,手动新建的是一种,导入的 ICS 是另一种;来源不是装饰,它是"这条记录是谁写进来的"。 +- 空日历留白:没有日程的位置不画任何东西,不写"无安排";整个日历为空时,面板中央给一张卡片,说明可以新建日程或拖入 `.ics` 文件。 +- 复用产品的界面原件:按钮、弹窗、菜单、输入框、开关、标签与图标都来自宿主提供的 `@deepseek-ai/dsh-client-ui-primitives`,深浅色主题与键盘操作跟外壳其余部分一致。两条发行线带的原件是两代:早期把绘制尺寸写进图标名(`IconPlusOutline16`),后来改成笔画变体加 `size` 属性(`IconPlusOutlineRegular`)。组件本身两代相同,图标则按运行时实际导出的一组名字解析(见 `src/client.ts` 里的 `icon()`),所以面板在两代外壳里都能画出来,而不是静态引一个某一代没有的名字。 + +## 数据模型与存储 + +- 日程字段:`uid`、`title`、`start` 必填,另有 `end`、`location`、`description`、`recurrence`、`exceptions`、`overrides`、`source`、`extensions`。`description` 就是日历里的备注(ICS 的 `DESCRIPTION`)。 +- 时间使用本地时间字符串(`YYYY-MM-DD` 或 `YYYY-MM-DDTHH:mm`),首版不做时区换算;字段沿用 ICS 词汇(`UID`、`SUMMARY`、`DTSTART`、`DTEND`),未识别字段原样保存在 `extensions`。 +- 重复规则只支持每周:`freq`、`interval`、`count`、`until`、`byDay`。范围之外的规则(每月、每年、`BYMONTHDAY`、`BYSETPOS`)由导入方把原始 RRULE 文本放进 `extensions` 并降级为单次日程,不静默丢弃。 +- 记录存放在本机存储域的一张表:`events` 按 `uid`。写入先落盘再可见,读取取自内存状态,变更以 `domain/changed` 通知;不联网、不上传、不写日志。存储域的后端由装配配置决定,插件本身不持有路径。 +- `snapshot()` 返回带 `schemaVersion` 的完整数据(`{ schemaVersion, events }`);导入按 `uid` 合并,同一份快照重复导入结果不变,且先校验全部记录再写第一条,被拒绝的导入不会留下半份数据。 +- 写入用 `putEvent({ ...record })`:调用方自己给出 `uid`(界面新建时生成 `manual-`),插入或整体替换,没有 `source` 时补成 `manual`。记录形状只此一种,写进去什么就取到什么样。 + +### 重复、改期与取消 + +存储的是**系列**,视图看到的是**发生**(occurrence):`occurrences({ from, to })` 每次调用现算,不保存任何按周副本,因此不存在"某一周的副本过期"的问题。每条发生带 `occurrenceId`(`#<系列本应产生的日期>`)与 `occurrenceDate`,界面按它渲染,后续提醒与课堂纪要也用它定位。 + +| 情形 | 表示 | 说明 | +| --- | --- | --- | +| 取消这一次 | `exceptions: ['2026-03-16']` | 对应 ICS 的 `EXDATE` | +| 改期、换地点 | `overrides: [{ date: '2026-03-23', start: '2026-03-27T14:00', location: '示例楼 202' }]` | 对应 ICS 的 `RECURRENCE-ID`:只写变化字段,其余继承系列;`date` 是系列本应产生的日期,所以之后改动系列时间也不会失配 | +| 额外加一次 | 独立记录 | 不属于任何系列,各用各的 `uid` | +| 长期变更(从第 9 周起换教室) | 切开系列 | 原系列以 `until` 收在第 8 周,新系列从第 9 周起(新 `uid`):`RRULE` 表达不了"从某周起改变" | + +服务提供 `applyOverride({ uid, date, patch })` 与 `removeOverride({ uid, date })` 记录或撤销一次改期,`cancelOccurrence({ uid, date })` 与 `restoreOccurrence({ uid, date })` 取消与恢复这一次。`date` 上重复的覆盖会被拒绝,空的覆盖(只给 `date`)也会被拒绝:契约里不存在"什么都没改"的改期。取消一次会给已经有改期的那天连带撤掉那次改期——那天已经没有安排可改了。 + +### 存储形状的演进 + +记录契约曾经带过 `courseId`。现在它不再是公开字段,但旧记录里可能还有:存储域按 `StoredEventSchema` 读取(公开契约加上这类已退休字段),`CalendarStore.open()` 在开库时把这些记录改写成当前形状,一次写完就再也不会出现。域版本仍是 1——后端校验的是整个单元的版本,升级版本会让改动之前的存储直接打不开,而"日历打不开"比"多读一个字段"糟得多。同一次开库也会让旧存储里那张不再被读取的表随第一次写入消失。 + +## 客户端数据通道 + +- Host 半把 `snapshot`、`occurrences`、`importIcs`、`exportIcs`、`putEvent`、`deleteEvent`、`applyOverride`、`removeOverride`、`cancelOccurrence` 与 `restoreOccurrence` 标记为远端方法(`lib/index.js` 中的 `Remote(...)`),并由包导出的 `./typert`(`lib/typert.host.js`)交给 typert loader 注册到网关;线缆契约的 zod schema 在 `lib/typert-schemas.js`,与记录 schema 共用同一份定义,避免两处描述不一致。 +- 两个面(`./typert` 与 `./remote`)的描述清单来自同一份 `lib/typert-descriptors.js`,所以方法名、参数与 schema 不会两边写歪;测试还校验每个描述的行号确实指向 `lib/index.js` 里实现它的方法。 +- 每个远端方法收一个对象参数、返回一个 JSON 值。客户端半挂载包导出的 `./remote`(`lib/typert.remote-client.js`):先 `ctx.remote.$mount(...)`,再在 `ctx.inject(['remote.calendar'], ...)` 中调用,例如 `remote.calendar.occurrences({ from, to })` 拿到 `{ schemaVersion, occurrences }`(一次往返带回这一周的全部日程),`remote.calendar.putEvent({ uid, title, start })` 写入一条日程。网关返回 `{ ok, value }` 或 `{ ok, error }`,客户端解包后渲染。 +- 界面只取当前这一周:翻周就是换区间重新取数,不缓存、不物化;日程页取最近一年左右的窗口,同样不落盘。每次写入后重新取数,所以界面上看到的就是记录当前的样子。`snapshot()` 保留给导出与机构注入这类需要全量数据的场景。 +- 面板只在远端可用时注册,数据中心不可用时不会出现空白面板。 + +## Agent 读写接口 + +`lib/tools.js` 把同一份 `calendar` 服务开放给会话里的模型,五个工具覆盖读与写: + +| 工具 | 作用 | +| --- | --- | +| `calendar_list_events` | 读一个日期区间的全部发生,逐条给出 `uid`、日期、时间、地点与是否每周重复 | +| `calendar_create_event` | 新建一条记录,可带 `repeat: "weekly"` 与 `until` | +| `calendar_update_event` | 按 `uid` 改一条记录,只改调用给出的字段;挪动开始时间时保留原时长 | +| `calendar_delete_event` | 按 `uid` 删除整条记录 | +| `calendar_occurrence` | 只处理系列里的某一次:`move`、`reset`、`cancel`、`restore` | + +每个区间都是有限的:不写 `from` 就从今天往前 180 天,不写 `to` 就给一年,一次最多 3660 天。这不是保守,而是必须——没有结束日期的每周系列会一直展开到给定的上界,不设上界就等于要四千年的日期。写入只带要改的字段:字段留空就保持原值,空字符串才是清除。 + +写工具在 `tools/pre-execute` 上统一过一道确认(`lib/tool-permission.js`):读取直接放行,写入弹确认,已经处于「无审批」的会话直接放行,没有会话上下文的写入直接拒绝。工具与面板写的是同一份记录、走同一套校验,不存在第二份实现。 + +## 使用的接口 + +| 接口 | 用途 | 要求 | +| --- | --- | --- | +| `sidebar.panellist` | 左侧栏的面板入口 | 列表槽;登记项需要 `id`,可选 `order` 与 `label` | +| `main` | 主区面板 | 键值槽;`key` 与侧栏 `id` 相同即互相切换 | +| `storageDomain` | 日程记录的持久化 | 域表形式的存储域;域在插件初始化时打开 | +| `remote`(Typert 网关) | 客户端半读写 Host 记录 | 宿主面经 `./typert` 注册,客户端面经 `./remote` 挂载;`snapshot`、`occurrences` 与 8 个写入方法跨半可见,其余方法只属于 Host 侧 | +| `@deepseek-ai/dsh-client-ui-primitives` | 客户端半的按钮、弹窗、菜单、输入框与图标 | 由宿主提供,不打进产物;声明在 `dsh.client.inject` 与 `peerDependencies`。两条发行线的元件同名,图标名分两代,按运行时导出名解析 | +| `tools`、`permissionPresets` | 注册 Agent 工具与写入确认 | `ctx.tools.register(defineTool(...))`;`tools/pre-execute` 返回 `ask` 或 `deny`,`permissionPresets.current()` 读出当前审批档 | + +插件声明为可整体移除、不侵入核心(`package.json` 的 `chatecnuWork`:`kind` 为 `ui-extension`、`removable` 为真),并保持零运行时依赖。 + +## 构建 + +- 客户端半:装配时由 `build-client.ps1` 把 `src/client.ts` 连同它引用的 `src/layout.js` 编译为 `lib/client.js`(`tsdown`,配置见 `tsdown.config.ts`)。脚本接收装配传入的 `-Upstream`(编译工具链)与 `-RuntimePackages`(运行时 `node_modules`),把其中的 `zod` 以链接方式铺进临时编译目录,编译结束后只删除该链接。`react` 与 `@deepseek-ai/dsh-client-ui-primitives` 是外部依赖,不打进产物。编译工具链由装配流程准备,不在仓库内。 +- Host 半:`lib/model.js`(记录契约)、`lib/store.js`(存储域读写)、`lib/ics.js`(ICS 解析与生成)、`lib/tools.js`(Agent 工具与它的写入确认)、`lib/typert-schemas.js`(线缆 schema)、`lib/typert-descriptors.js`(两个面共用的远端描述)、`lib/typert.host.js`(宿主面)、`lib/typert.remote-client.js`(客户端面)与 `lib/index.js`(`calendar` 服务)均为手写源码,装配时原样拷贝。 +- 登记位置:插件清单与 `localPlugins` 分别位于 `config/distributions/generic.json` 与 `config/assembly.eduwork.json`;Agent 工具是同一个包的 `./tools` 子路径,作为 `patches` 里的一条 `insert` 装进合成清单(与 `search-auto` 同一种形式),面板与工具因此是同一份包的两个入口。 + +## 测试 + +`node --test test/*.test.mjs`(或 `npm test`)。测试用装配锁定的 Runtime 提供 `zod`、存储包与 Typert 协议:把 `EDUWORK_TEST_RUNTIME` 指向运行时目录后运行,未设置时跳过;CI 准备好运行时后执行同一命令。两条固定运行时线(通用 Web 的 `release-v0.1.5-rc.2` 与桌面候选的 `candidate-v0.2.0-rc.2`)上各 81 例全部通过;参数 codec 需要带 `create()`,缺了会在后一条线上被 loader 校验拦下。`test/model.test.mjs` 覆盖记录契约与快照(8 例),`test/occurrence.test.mjs` 覆盖发生的展开、`exceptions`、`overrides` 与跨天记录保留的天数(16 例),`test/ics.test.mjs` 覆盖字段映射、行折叠与转义、`RRULE` 子集与降级、`EXDATE`/`RECURRENCE-ID`、导出语法与"往返不丢未识别字段"(18 例),`test/remote.test.mjs` 校验两个面描述同一组方法、参数与结果 schema、每个描述指向真实方法,并确认清单能通过 loader 校验(5 例),`test/store.test.mjs` 用真实 Cordis 作用域与 JSON 后端验证往返、重启、原子导入、区间查询、改期与取消写入、ICS 导入与再导出、按来源替换式合并、旧存储的迁移,确认只有被标记的方法出现在活实例的远端表面上,并校验声明的线缆 schema 能解析方法真实返回的载荷(9 例),`test/tools.test.mjs` 用真实现发生规则验证五个工具读写到的方法与参数、区间上界、只改给出的字段,并覆盖写入确认的四种判定(9 例),`test/layout.test.mjs` 覆盖周视图的取轴、重叠分列几何与改期时的时长保持(16 例,纯函数,不需要运行时,因此不随 `EDUWORK_TEST_RUNTIME` 跳过)。 + +## 文档 + +- [日历与日程模块提案](../../docs/proposals/calendar/README.md):目标、范围、日程数据结构、提醒边界与机构版边界。 diff --git a/dsh-plugins/calendar/README_EN.md b/dsh-plugins/calendar/README_EN.md new file mode 100644 index 00000000..c61f739e --- /dev/null +++ b/dsh-plugins/calendar/README_EN.md @@ -0,0 +1,111 @@ +# EduWork calendar plugin + +[简体中文](README.md) | **English** + +The public-edition calendar and schedule panel: an entry in the left sidebar, a week view laid out on an hour axis plus an event page in the main area, local storage for event records in the Host half, ICS import and export, and a data-injection point reserved for the institution edition. + +Status: in development. The Host half (event model, local storage, `calendar` service) and the client data channel are in place; the week view draws the week on a whole-day hour axis (today highlighted, events that overlap in time side by side) and opens scrolled to where this week's events are, and clicking one opens its details dialog; the header's "新建日程", the empty-calendar card and a drag or a double click on an empty slot all create an event, and the event page edits its title, date, times, place and note in the left column while the last year of occurrences is listed by month in the right, where one date can be moved, cancelled, restored or the whole record deleted; an import accepts a dropped file or pasted text, an export can be copied or downloaded, and the model can change this calendar too — five tools cover reading a range, creating, updating, deleting and moving or cancelling a single date, each write behind a user confirmation. Scope, the event data structure and institution boundaries are described in the [calendar and schedule module proposal](../../docs/proposals/calendar/README_EN.md). + +The public edition is a calendar, not a timetable: the only record here is an event. A school timetable, an academic calendar and the registrar's rules arrive as data sources injected by an institution plugin, as described in the [proposal](../../docs/proposals/calendar/README_EN.md). + +## Capabilities + +- Sidebar entry: registered into `sidebar.panellist`; selecting it switches the main area to this panel. +- Week view: seven days as columns on one whole-day hour axis, with hour marks down the left and today's column highlighted; a line marks the current time. Events that overlap in time take separate columns instead of covering each other. Previous/next/current week navigation, with this week's count. +- Drag to create: press on an empty slot and drag out a span (snapped to fifteen minutes) and the create dialog opens on that range; a double click on an empty slot creates a one-hour entry. +- Whole-day records live in the day header: an entry created here always carries a start and an end time, while a whole-day record that arrives from a calendar file (`DTSTART;VALUE=DATE`, which is what holiday and academic-calendar feeds are made of) still imports and still shows — as a small "全天" chip in the day's header, and a record spanning several days appears on every one of them. It takes no slot on the axis, stays visible while the axis is scrolled, and opens the same dialog; a whole-day record belongs to no time of day, and this way it cannot be scrolled past. +- Where it opens: the axis draws the whole day and the view scrolls to where this week's events are, so a tall panel is filled with hours instead of leaving empty space below a fixed working day; a week with nothing in it stays on working hours. +- Event details: clicking any event opens a dialog with its time, place, source and repeat rule, and the actions that fit it — move it or its place, withdraw a move, cancel one date, or delete the whole record; a start that moves on its own takes the end with it, so the entry keeps its length, while a whole-day entry has no clock and the dialog points at its page for the date and the place. +- Creating and editing: the header's "新建日程", the empty-calendar card and a double click on an empty slot in the week all open the create dialog, the double click with that slot already filled in; the dialog takes a title, date, start and end time, place and note, and can turn on "每周重复". Saving returns to the week, with the new event where it was asked for. The event page edits the same fields directly in its left column, and one occurrence can be handled on its own from the right. +- Recurrence: weekly rules are expanded into occurrences, honouring `interval`, `count`, `until` and `byDay`; `exceptions` cancel one date and `overrides` change one date. +- Event page: opened from the "日程" menu in the header (which lists every event) or from a title in the week view. The left column edits the title, date, start and end time, place and note, and lists the record's cancelled dates with a restore button; the right column lists the last year of occurrences grouped by month, and clicking one opens the same details dialog. +- Import and export: an import is a dialog that accepts an `.ics` file dropped on the panel or on the dialog itself, or pasted text, and reports what it wrote, removed, skipped or degraded in that same dialog; an export dialog offers the calendar text to copy or download. +- Event records: the `calendar` service reads, writes, queries by range, queries occurrences, imports snapshots and imports/exports ICS; records stay on this machine. + +## Interface + +- A time axis, not a list of cards: the first thing a calendar shows is time. A day is a column, an hour is 48 pixels, and an event is a block positioned by its start and end — so "one event ends at 10:00 and the next starts at 10:00" reads as two blocks that touch, not as two lines of text. +- Overlaps share the width: events that overlap inside one day form a group and split that day's width evenly; once the group ends (the next event starts no earlier than the group's latest end) the full width comes back. The date is part of a block's identity too: two events that both start at 08:00 but fall on different days are not in conflict. That geometry is a pure function (`src/layout.js`) with no React in it and 15 tests of its own. +- A whole-day axis: the axis spans 24 hours and the view opens scrolled to the hour of this week's first event, so a tall panel shows more hours rather than empty space below a fixed working day. Stepping through weeks moves the content, not the axis. +- Colour follows the source: events from one data source share a tint, one for the ones you create and another for an imported `.ics`; the source is not decoration, it is who wrote the record. +- Empty time stays empty: nothing is drawn where no event is, and no "no events" label is written into the grid; when the whole calendar is empty the panel shows one card explaining that an event can be created or an `.ics` file dropped on it. +- The product's own primitives: buttons, dialogs, menus, inputs, switches, tags and icons come from the host's `@deepseek-ai/dsh-client-ui-primitives`, so light and dark themes and keyboard behaviour match the rest of the shell. The two shipping lines carry two generations of that package: the earlier one puts the drawn size in the icon name (`IconPlusOutline16`), the later one drops it and offers stroke variants with the size as a prop (`IconPlusOutlineRegular`). The components are the same in both, while each icon is resolved from whichever names the running shell actually exports (see `icon()` in `src/client.ts`), so the panel draws inside either shell instead of importing a name one of them lacks. + +## Data model and storage + +- Event fields: `uid`, `title` and `start` are required; `end`, `location`, `description`, `recurrence`, `exceptions`, `overrides`, `source` and `extensions` are optional. `description` is the calendar's own note (the ICS `DESCRIPTION`). +- Times are local strings (`YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`) and the first version does no timezone conversion; field names follow the ICS vocabulary (`UID`, `SUMMARY`, `DTSTART`, `DTEND`) and unrecognized fields are kept verbatim in `extensions`. +- Recurrence supports weekly rules only: `freq`, `interval`, `count`, `until`, `byDay`. Rules outside that range (monthly, yearly, `BYMONTHDAY`, `BYSETPOS`) are imported by keeping the original RRULE text in `extensions` and degrading the event to a single occurrence instead of dropping it silently. +- Records live in one table of a local storage domain: `events` keyed by `uid`. A write is durable before it becomes visible, reads come from in-memory state, and changes are announced as `domain/changed`; nothing is networked, uploaded or logged. The assembly decides the medium behind the domain — the plugin holds no paths. +- `snapshot()` returns the whole dataset with its `schemaVersion` (`{ schemaVersion, events }`); imports merge by `uid`, so importing the same snapshot twice leaves the same result, and every record is validated before the first write, so a rejected import leaves no half-written copy behind. +- Writes go through `putEvent({ ...record })`: the caller brings its own `uid` (the interface generates `manual-` for a new event), the call inserts or replaces the whole record, and a missing `source` becomes `manual`. There is one record shape, and what was written is what is read back. + +### Recurrence, moves and cancellations + +What is stored is a **series**; what a view shows is an **occurrence**: `occurrences({ from, to })` computes them per call and keeps no per-week copy, so no week can go stale. Each occurrence carries `occurrenceId` (`#`) and `occurrenceDate`, which the interface renders by and which later reminders and classroom notes will refer to. + +| Case | Representation | Note | +| --- | --- | --- | +| Cancelled (not this time) | `exceptions: ['2026-03-16']` | The ICS `EXDATE` case | +| Moved or relocated | `overrides: [{ date: '2026-03-23', start: '2026-03-27T14:00', location: '示例楼 202' }]` | The ICS `RECURRENCE-ID` case: only changed fields are written, the rest inherit from the series, and `date` is the date the series produced, so a later change to the series time cannot break the match | +| An extra date | A record of its own | It belongs to no series and carries its own `uid` | +| Long-term change (new room from week 9) | Split the series | The old series ends at week 8 through `until`, a new series starts in week 9 (`uid` changes): `RRULE` cannot express "changed from week 9 on" | + +The service exposes `applyOverride({ uid, date, patch })` and `removeOverride({ uid, date })` to record or withdraw one move, and `cancelOccurrence({ uid, date })` / `restoreOccurrence({ uid, date })` to cancel or restore one date. Two overrides on the same `date` are rejected, and so is an empty one that carries only `date`: the contract has no move that changes nothing. Cancelling a date also withdraws a move that had been scheduled for it — there is nothing left to move. + +A one-off event takes a different path: changing it edits the record itself, because an override on it would be a `RECURRENCE-ID` on a `VEVENT` without `RRULE`, which RFC 5545 does not allow. + +### How the stored shape evolves + +The record contract used to carry `courseId`. It is no longer a public field, but an older record may still hold it: the domain is read with `StoredEventSchema` (the public contract plus such retired fields), and `CalendarStore.open()` rewrites those records into the current shape once, after which they cannot come back. The domain version stays 1 — the backend validates the version of the whole unit, and a bump would make every store written before the change fail to open, which is far worse than reading one extra field. That same open is also what drops a retired table from an older store at the first write. + +## Client data channel + +- The Host half marks `snapshot`, `occurrences`, `importIcs`, `exportIcs`, `putEvent`, `deleteEvent`, `applyOverride`, `removeOverride`, `cancelOccurrence` and `restoreOccurrence` as remote methods (`Remote(...)` in `lib/index.js`) and hands them to the gateway through the package's `./typert` export (`lib/typert.host.js`); the wire contract's zod schemas live in `lib/typert-schemas.js` and reuse the record schemas, so the two descriptions cannot drift apart. +- Both faces (`./typert` and `./remote`) take their descriptor list from one `lib/typert-descriptors.js`, so method names, arguments and schemas cannot be written down twice and disagree; a test also checks that every descriptor points at the line of the method that implements it. +- Each remote method takes one object argument and returns one JSON value. The client half mounts the package's `./remote` export (`lib/typert.remote-client.js`): it calls `ctx.remote.$mount(...)` first and then, inside `ctx.inject(['remote.calendar'], ...)`, for example `remote.calendar.occurrences({ from, to })` for `{ schemaVersion, occurrences }` (one round trip carries the whole week) or `remote.calendar.putEvent({ uid, title, start })` to write one event. The gateway answers `{ ok, value }` or `{ ok, error }`, which the client unwraps before rendering. +- The interface asks for the week it shows: moving to another week is another range query, with no cache and no per-week copy; the event page asks for a window of roughly a year and stores nothing either. Every write is followed by a fresh read, so what is on screen is what the records say. `snapshot()` stays for callers that need the whole dataset, such as export and institution injection. +- The panel registers only while the remote surface is available, so an unavailable data host never leaves a blank panel behind. + +## Agent read and write interface + +`lib/tools.js` opens the same `calendar` service to the model in a session, with five tools covering reads and writes: + +| Tool | What it does | +| --- | --- | +| `calendar_list_events` | Reads every occurrence in a range of dates, giving each one its `uid`, date, time, place and whether it repeats weekly | +| `calendar_create_event` | Creates one record, optionally with `repeat: "weekly"` and an `until` date | +| `calendar_update_event` | Updates one record by `uid`, changing only the fields the call carries; moving the start keeps the entry's duration | +| `calendar_delete_event` | Deletes one whole record by `uid` | +| `calendar_occurrence` | Acts on one date of a series: `move`, `reset`, `cancel` or `restore` | + +Every window is bounded: without `from` a read starts 180 days before today, without `to` it ends a year later, and no single call may span more than 3660 days. That is not caution for its own sake — a weekly series with no end date expands up to whatever bound it is given, so an unbounded window would ask for four thousand years of dates. A write carries only what it changes: a field left out keeps its stored value, and an empty string clears one. + +Writes pass one confirmation on `tools/pre-execute` (`lib/tool-permission.js`): reads go straight through, writes ask, a session already running without approvals goes through, and a write with no session behind it is denied. The tools and the panel write the same records through the same validation; there is no second implementation. + +## Interfaces used + +| Interface | Purpose | Requirement | +| --- | --- | --- | +| `sidebar.panellist` | Panel entry in the left sidebar | List slot; an entry needs `id` and optionally `order` and `label` | +| `main` | Main-area panel | Keyed slot; the `key` equals the sidebar `id` to switch between them | +| `storageDomain` | Persistence for event records | Domain-table storage; the domain opens during plugin initialization | +| `remote` (Typert gateway) | Client half reading and writing Host records | Host face registered through `./typert`, client face mounted through `./remote`; `snapshot`, `occurrences` and eight write methods cross the boundary, every other method belongs to the Host half alone | +| `@deepseek-ai/dsh-client-ui-primitives` | Buttons, dialogs, menus, inputs and icons of the client half | Provided by the host and never bundled; declared in `dsh.client.inject` and `peerDependencies`. Both lines name the components alike but name their icons in two generations, resolved from what the runtime exports | +| `tools`, `permissionPresets` | Registering the Agent tools and confirming writes | `ctx.tools.register(defineTool(...))`; `tools/pre-execute` answers `ask` or `deny`, and `permissionPresets.current()` reads the current approval preset | + +The plugin declares itself removable without intruding on the core (`chatecnuWork` in `package.json`: `kind` is `ui-extension`, `removable` is true) and keeps zero runtime dependencies. + +## Build + +- Client half: during assembly `build-client.ps1` compiles `src/client.ts` together with the `src/layout.js` it imports into `lib/client.js` (`tsdown`, configured in `tsdown.config.ts`). The script takes `-Upstream` (the compiler toolchain) and `-RuntimePackages` (the runtime's `node_modules`) from the assembly and links that `zod` into its temporary build directory, removing only the link afterwards. `react` and `@deepseek-ai/dsh-client-ui-primitives` are external and never bundled. The compiler toolchain is prepared by the assembly flow and is not part of this repository. +- Host half: `lib/model.js` (the record contract), `lib/store.js` (domain access), `lib/ics.js` (ICS parsing and writing), `lib/tools.js` (the Agent tools and their write confirmation), `lib/typert-schemas.js` (wire schemas), `lib/typert-descriptors.js` (the remote descriptors both faces share), `lib/typert.host.js` (host face), `lib/typert.remote-client.js` (client face) and `lib/index.js` (the `calendar` service) are hand-written source, copied as-is during assembly. +- Registration: the plugin list and `localPlugins` live in `config/distributions/generic.json` and `config/assembly.eduwork.json`; the Agent tools are the same package's `./tools` subpath, installed into the composition by an `insert` row in `patches` (the same form `search-auto` uses), so the panel and the tools are two entries of one package. + +## Tests + +`node --test test/*.test.mjs` (or `npm test`). The tests take `zod`, the storage packages and the Typert protocol from the Runtime pinned by the assembly: point `EDUWORK_TEST_RUNTIME` at a runtime directory to run them, otherwise they skip; CI runs the same command once the runtime is prepared. All 81 cases pass on both pinned runtime lines — `release-v0.1.5-rc.2` for the generic Web product and `candidate-v0.2.0-rc.2` for the desktop candidate; a parameter codec needs `create()`, and without it the loader check rejects the manifest on the latter line. `test/model.test.mjs` covers the record contract and the snapshot (8 cases), `test/occurrence.test.mjs` covers occurrence expansion with `exceptions`, `overrides` and the days a record spans (16 cases), `test/ics.test.mjs` covers the field mapping, line folding and escaping, the `RRULE` subset and its degradation, `EXDATE`/`RECURRENCE-ID`, the syntax of what we export, and a round trip that loses nothing it did not understand (18 cases), `test/remote.test.mjs` checks that both faces describe the same methods, parameters and result schemas, that every descriptor points at the method that implements it, and that the manifest passes loader validation (5 cases), `test/store.test.mjs` uses a real Cordis scope over the JSON backend to verify round-trips, restarts, atomic imports, range queries, moved and cancelled dates, ICS import and re-export, the source-scoped replacement and the migration of an older store, to check that only marked methods appear on the live service's remote surface, and to check that the declared wire schemas parse the payloads the methods really return (9 cases), `test/tools.test.mjs` uses the real occurrence rules to check the service methods and arguments each of the five tools reaches, the bounds on every window, that a write carries only what it changes, and all four outcomes of the write confirmation (9 cases), and `test/layout.test.mjs` covers the axis window, the overlap geometry of the week view and the length a moved start keeps (16 cases, pure functions, no runtime needed, so it never skips on a missing `EDUWORK_TEST_RUNTIME`). + +## Documentation + +- [Calendar and schedule module proposal](../../docs/proposals/calendar/README_EN.md): goal, scope, event data structure, reminder boundary and institution boundary. diff --git a/dsh-plugins/calendar/build-client.ps1 b/dsh-plugins/calendar/build-client.ps1 new file mode 100644 index 00000000..e543c52b --- /dev/null +++ b/dsh-plugins/calendar/build-client.ps1 @@ -0,0 +1,40 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Upstream, + [Parameter(Mandatory)][string]$Output, + [Parameter(Mandatory)][string]$RuntimePackages +) +$ErrorActionPreference = 'Stop' +$upstreamRoot = (Resolve-Path -LiteralPath $Upstream).Path +$target = [IO.Path]::GetFullPath($Output) +$stage = Join-Path $upstreamRoot ('packages/extensions/eduwork-calendar-build-' + [guid]::NewGuid().ToString('N')) +$tsdown = Join-Path $upstreamRoot ('node_modules/.bin/' + $(if ($IsWindows) { 'tsdown.cmd' } else { 'tsdown' })) +$linkType = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' } +if (-not $stage.StartsWith($upstreamRoot.TrimEnd('\','/') + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Build stage must stay in the selected toolchain' } +New-Item -ItemType Directory -Path $stage | Out-Null +try { + # The remote descriptors carry zod schemas, so the compiler needs the zod the + # selected runtime ships. Link it instead of copying, and only the link is + # removed below — the runtime stays untouched. + $dependencies = (Resolve-Path -LiteralPath $RuntimePackages).Path + if (-not (Test-Path -LiteralPath (Join-Path $dependencies 'zod/package.json'))) { throw 'The selected runtime must provide zod' } + New-Item -ItemType Directory -Path (Join-Path $stage 'node_modules') -Force | Out-Null + New-Item -ItemType $linkType -Path (Join-Path $stage 'node_modules/zod') -Target (Join-Path $dependencies 'zod') | Out-Null + foreach ($name in @('src','lib','package.json','tsdown.config.ts','tsconfig.json')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $stage -Recurse } + Push-Location $stage + try { & $tsdown --config tsdown.config.ts; if ($LASTEXITCODE -ne 0) { throw 'Calendar client build failed' } } + finally { Pop-Location } + New-Item -ItemType Directory -Path $target -Force | Out-Null + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $stage 'lib') -File -Filter '*.js') { + $content = [IO.File]::ReadAllText($file.FullName) -replace '(?m)^//# sourceMappingURL=.*$', '' + # Rolldown region comments can contain resolved dependency paths outside + # the toolchain. Keep build-machine paths out of the shipped source. + $content = $content -replace '(?m)^[ \t]*//#(?:end)?region\b[^\r\n]*\r?\n', '' + $content = ($content -replace '(?m)[ \t]+(?=\r?$)', '').TrimEnd("`r", "`n") + "`n" + [IO.File]::WriteAllText((Join-Path $target $file.Name),$content,[Text.UTF8Encoding]::new($false)) + } +} finally { + if (Test-Path -LiteralPath (Join-Path $stage 'node_modules/zod')) { Remove-Item -LiteralPath (Join-Path $stage 'node_modules/zod') -Force } + if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } +} diff --git a/dsh-plugins/calendar/lib/client.js b/dsh-plugins/calendar/lib/client.js new file mode 100644 index 00000000..c773e55c --- /dev/null +++ b/dsh-plugins/calendar/lib/client.js @@ -0,0 +1,6466 @@ +window.__ModuleLoader__.load({ + id: "@eduwork/dsh-calendar", + factory: (require) => { + var module = { exports: {} }; + var exports = module.exports; + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); + var __create = Object.create; + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __getProtoOf = Object.getPrototypeOf; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { + key = keys[i]; + if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { + get: ((k) => from[k]).bind(null, key), + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable + }); + } + return to; + }; + var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { + value: mod, + enumerable: true + }) : target, mod)); + let react = require("react"); + react = __toESM(react, 1); + let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); + _deepseek_ai_dsh_client_ui_primitives = __toESM(_deepseek_ai_dsh_client_ui_primitives, 1); + var _a$1; + function $constructor(name, initializer, params) { + function init(inst, def) { + if (!inst._zod) Object.defineProperty(inst, "_zod", { + value: { + def, + constr: _, + traits: /* @__PURE__ */ new Set() + }, + enumerable: false + }); + if (inst._zod.traits.has(name)) return; + inst._zod.traits.add(name); + initializer(inst, def); + const proto = _.prototype; + const keys = Object.keys(proto); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (!(k in inst)) inst[k] = proto[k].bind(inst); + } + } + const Parent = params?.Parent ?? Object; + class Definition extends Parent {} + Object.defineProperty(Definition, "name", { value: name }); + function _(def) { + var _a; + const inst = params?.Parent ? new Definition() : this; + init(inst, def); + (_a = inst._zod).deferred ?? (_a.deferred = []); + for (const fn of inst._zod.deferred) fn(); + return inst; + } + Object.defineProperty(_, "init", { value: init }); + Object.defineProperty(_, Symbol.hasInstance, { value: (inst) => { + if (params?.Parent && inst instanceof params.Parent) return true; + return inst?._zod?.traits?.has(name); + } }); + Object.defineProperty(_, "name", { value: name }); + return _; + } + var $ZodAsyncError = class extends Error { + constructor() { + super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`); + } + }; + var $ZodEncodeError = class extends Error { + constructor(name) { + super(`Encountered unidirectional transform during encode: ${name}`); + this.name = "ZodEncodeError"; + } + }; + (_a$1 = globalThis).__zod_globalConfig ?? (_a$1.__zod_globalConfig = {}); + const globalConfig = globalThis.__zod_globalConfig; + function config(newConfig) { + if (newConfig) Object.assign(globalConfig, newConfig); + return globalConfig; + } + function getEnumValues(entries) { + const numericValues = Object.values(entries).filter((v) => typeof v === "number"); + return Object.entries(entries).filter(([k, _]) => numericValues.indexOf(+k) === -1).map(([_, v]) => v); + } + function jsonStringifyReplacer(_, value) { + if (typeof value === "bigint") return value.toString(); + return value; + } + function cached(getter) { + return { get value() { + { + const value = getter(); + Object.defineProperty(this, "value", { value }); + return value; + } + throw new Error("cached value already set"); + } }; + } + function nullish(input) { + return input === null || input === void 0; + } + function cleanRegex(source) { + const start = source.startsWith("^") ? 1 : 0; + const end = source.endsWith("$") ? source.length - 1 : source.length; + return source.slice(start, end); + } + function floatSafeRemainder(val, step) { + const ratio = val / step; + const roundedRatio = Math.round(ratio); + const tolerance = Number.EPSILON * Math.max(Math.abs(ratio), 1); + if (Math.abs(ratio - roundedRatio) < tolerance) return 0; + return ratio - roundedRatio; + } + const EVALUATING = /* @__PURE__*/ Symbol("evaluating"); + function defineLazy(object, key, getter) { + let value = void 0; + Object.defineProperty(object, key, { + get() { + if (value === EVALUATING) return; + if (value === void 0) { + value = EVALUATING; + value = getter(); + } + return value; + }, + set(v) { + Object.defineProperty(object, key, { value: v }); + }, + configurable: true + }); + } + function assignProp(target, prop, value) { + Object.defineProperty(target, prop, { + value, + writable: true, + enumerable: true, + configurable: true + }); + } + function mergeDefs(...defs) { + const mergedDescriptors = {}; + for (const def of defs) Object.assign(mergedDescriptors, Object.getOwnPropertyDescriptors(def)); + return Object.defineProperties({}, mergedDescriptors); + } + function esc(str) { + return JSON.stringify(str); + } + function slugify(input) { + return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); + } + const captureStackTrace = "captureStackTrace" in Error ? Error.captureStackTrace : (..._args) => {}; + function isObject(data) { + return typeof data === "object" && data !== null && !Array.isArray(data); + } + const allowsEval = /* @__PURE__*/ cached(() => { + if (globalConfig.jitless) return false; + if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) return false; + try { + new Function(""); + return true; + } catch (_) { + return false; + } + }); + function isPlainObject(o) { + if (isObject(o) === false) return false; + const ctor = o.constructor; + if (ctor === void 0) return true; + if (typeof ctor !== "function") return true; + const prot = ctor.prototype; + if (isObject(prot) === false) return false; + if (Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf") === false) return false; + return true; + } + function shallowClone(o) { + if (isPlainObject(o)) return { ...o }; + if (Array.isArray(o)) return [...o]; + if (o instanceof Map) return new Map(o); + if (o instanceof Set) return new Set(o); + return o; + } + const propertyKeyTypes = /* @__PURE__*/ new Set([ + "string", + "number", + "symbol" + ]); + function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + function clone(inst, def, params) { + const cl = new inst._zod.constr(def ?? inst._zod.def); + if (!def || params?.parent) cl._zod.parent = inst; + return cl; + } + function normalizeParams(_params) { + const params = _params; + if (!params) return {}; + if (typeof params === "string") return { error: () => params }; + if (params?.message !== void 0) { + if (params?.error !== void 0) throw new Error("Cannot specify both `message` and `error` params"); + params.error = params.message; + } + delete params.message; + if (typeof params.error === "string") return { + ...params, + error: () => params.error + }; + return params; + } + function optionalKeys(shape) { + return Object.keys(shape).filter((k) => { + return shape[k]._zod.optin === "optional" && shape[k]._zod.optout === "optional"; + }); + } + const NUMBER_FORMAT_RANGES = { + safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], + int32: [-2147483648, 2147483647], + uint32: [0, 4294967295], + float32: [-34028234663852886e22, 34028234663852886e22], + float64: [-Number.MAX_VALUE, Number.MAX_VALUE] + }; + function pick(schema, mask) { + const currDef = schema._zod.def; + const checks = currDef.checks; + if (checks && checks.length > 0) throw new Error(".pick() cannot be used on object schemas containing refinements"); + return clone(schema, mergeDefs(schema._zod.def, { + get shape() { + const newShape = {}; + for (const key in mask) { + if (!(key in currDef.shape)) throw new Error(`Unrecognized key: "${key}"`); + if (!mask[key]) continue; + newShape[key] = currDef.shape[key]; + } + assignProp(this, "shape", newShape); + return newShape; + }, + checks: [] + })); + } + function omit(schema, mask) { + const currDef = schema._zod.def; + const checks = currDef.checks; + if (checks && checks.length > 0) throw new Error(".omit() cannot be used on object schemas containing refinements"); + return clone(schema, mergeDefs(schema._zod.def, { + get shape() { + const newShape = { ...schema._zod.def.shape }; + for (const key in mask) { + if (!(key in currDef.shape)) throw new Error(`Unrecognized key: "${key}"`); + if (!mask[key]) continue; + delete newShape[key]; + } + assignProp(this, "shape", newShape); + return newShape; + }, + checks: [] + })); + } + function extend(schema, shape) { + if (!isPlainObject(shape)) throw new Error("Invalid input to extend: expected a plain object"); + const checks = schema._zod.def.checks; + if (checks && checks.length > 0) { + const existingShape = schema._zod.def.shape; + for (const key in shape) if (Object.getOwnPropertyDescriptor(existingShape, key) !== void 0) throw new Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead."); + } + return clone(schema, mergeDefs(schema._zod.def, { get shape() { + const _shape = { + ...schema._zod.def.shape, + ...shape + }; + assignProp(this, "shape", _shape); + return _shape; + } })); + } + function safeExtend(schema, shape) { + if (!isPlainObject(shape)) throw new Error("Invalid input to safeExtend: expected a plain object"); + return clone(schema, mergeDefs(schema._zod.def, { get shape() { + const _shape = { + ...schema._zod.def.shape, + ...shape + }; + assignProp(this, "shape", _shape); + return _shape; + } })); + } + function merge(a, b) { + if (a._zod.def.checks?.length) throw new Error(".merge() cannot be used on object schemas containing refinements. Use .safeExtend() instead."); + return clone(a, mergeDefs(a._zod.def, { + get shape() { + const _shape = { + ...a._zod.def.shape, + ...b._zod.def.shape + }; + assignProp(this, "shape", _shape); + return _shape; + }, + get catchall() { + return b._zod.def.catchall; + }, + checks: b._zod.def.checks ?? [] + })); + } + function partial(Class, schema, mask) { + const checks = schema._zod.def.checks; + if (checks && checks.length > 0) throw new Error(".partial() cannot be used on object schemas containing refinements"); + return clone(schema, mergeDefs(schema._zod.def, { + get shape() { + const oldShape = schema._zod.def.shape; + const shape = { ...oldShape }; + if (mask) for (const key in mask) { + if (!(key in oldShape)) throw new Error(`Unrecognized key: "${key}"`); + if (!mask[key]) continue; + shape[key] = Class ? new Class({ + type: "optional", + innerType: oldShape[key] + }) : oldShape[key]; + } + else for (const key in oldShape) shape[key] = Class ? new Class({ + type: "optional", + innerType: oldShape[key] + }) : oldShape[key]; + assignProp(this, "shape", shape); + return shape; + }, + checks: [] + })); + } + function required(Class, schema, mask) { + return clone(schema, mergeDefs(schema._zod.def, { get shape() { + const oldShape = schema._zod.def.shape; + const shape = { ...oldShape }; + if (mask) for (const key in mask) { + if (!(key in shape)) throw new Error(`Unrecognized key: "${key}"`); + if (!mask[key]) continue; + shape[key] = new Class({ + type: "nonoptional", + innerType: oldShape[key] + }); + } + else for (const key in oldShape) shape[key] = new Class({ + type: "nonoptional", + innerType: oldShape[key] + }); + assignProp(this, "shape", shape); + return shape; + } })); + } + function aborted(x, startIndex = 0) { + if (x.aborted === true) return true; + for (let i = startIndex; i < x.issues.length; i++) if (x.issues[i]?.continue !== true) return true; + return false; + } + function explicitlyAborted(x, startIndex = 0) { + if (x.aborted === true) return true; + for (let i = startIndex; i < x.issues.length; i++) if (x.issues[i]?.continue === false) return true; + return false; + } + function prefixIssues(path, issues) { + return issues.map((iss) => { + var _a; + (_a = iss).path ?? (_a.path = []); + iss.path.unshift(path); + return iss; + }); + } + function unwrapMessage(message) { + return typeof message === "string" ? message : message?.message; + } + function finalizeIssue(iss, ctx, config) { + const message = iss.message ? iss.message : unwrapMessage(iss.inst?._zod.def?.error?.(iss)) ?? unwrapMessage(ctx?.error?.(iss)) ?? unwrapMessage(config.customError?.(iss)) ?? unwrapMessage(config.localeError?.(iss)) ?? "Invalid input"; + const { inst: _inst, continue: _continue, input: _input, ...rest } = iss; + rest.path ?? (rest.path = []); + rest.message = message; + if (ctx?.reportInput) rest.input = _input; + return rest; + } + function getLengthableOrigin(input) { + if (Array.isArray(input)) return "array"; + if (typeof input === "string") return "string"; + return "unknown"; + } + function issue(...args) { + const [iss, input, inst] = args; + if (typeof iss === "string") return { + message: iss, + code: "custom", + input, + inst + }; + return { ...iss }; + } + const initializer$1 = (inst, def) => { + inst.name = "$ZodError"; + Object.defineProperty(inst, "_zod", { + value: inst._zod, + enumerable: false + }); + Object.defineProperty(inst, "issues", { + value: def, + enumerable: false + }); + inst.message = JSON.stringify(def, jsonStringifyReplacer, 2); + Object.defineProperty(inst, "toString", { + value: () => inst.message, + enumerable: false + }); + }; + const $ZodError = $constructor("$ZodError", initializer$1); + const $ZodRealError = $constructor("$ZodError", initializer$1, { Parent: Error }); + function flattenError(error, mapper = (issue) => issue.message) { + const fieldErrors = {}; + const formErrors = []; + for (const sub of error.issues) if (sub.path.length > 0) { + fieldErrors[sub.path[0]] = fieldErrors[sub.path[0]] || []; + fieldErrors[sub.path[0]].push(mapper(sub)); + } else formErrors.push(mapper(sub)); + return { + formErrors, + fieldErrors + }; + } + function formatError(error, mapper = (issue) => issue.message) { + const fieldErrors = { _errors: [] }; + const processError = (error, path = []) => { + for (const issue of error.issues) if (issue.code === "invalid_union" && issue.errors.length) issue.errors.map((issues) => processError({ issues }, [...path, ...issue.path])); + else if (issue.code === "invalid_key") processError({ issues: issue.issues }, [...path, ...issue.path]); + else if (issue.code === "invalid_element") processError({ issues: issue.issues }, [...path, ...issue.path]); + else { + const fullpath = [...path, ...issue.path]; + if (fullpath.length === 0) fieldErrors._errors.push(mapper(issue)); + else { + let curr = fieldErrors; + let i = 0; + while (i < fullpath.length) { + const el = fullpath[i]; + if (!(i === fullpath.length - 1)) curr[el] = curr[el] || { _errors: [] }; + else { + curr[el] = curr[el] || { _errors: [] }; + curr[el]._errors.push(mapper(issue)); + } + curr = curr[el]; + i++; + } + } + } + }; + processError(error); + return fieldErrors; + } + const _parse = (_Err) => (schema, value, _ctx, _params) => { + const ctx = _ctx ? { + ..._ctx, + async: false + } : { async: false }; + const result = schema._zod.run({ + value, + issues: [] + }, ctx); + if (result instanceof Promise) throw new $ZodAsyncError(); + if (result.issues.length) { + const e = new ((_params?.Err) ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); + captureStackTrace(e, _params?.callee); + throw e; + } + return result.value; + }; + const _parseAsync = (_Err) => async (schema, value, _ctx, params) => { + const ctx = _ctx ? { + ..._ctx, + async: true + } : { async: true }; + let result = schema._zod.run({ + value, + issues: [] + }, ctx); + if (result instanceof Promise) result = await result; + if (result.issues.length) { + const e = new ((params?.Err) ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); + captureStackTrace(e, params?.callee); + throw e; + } + return result.value; + }; + const _safeParse = (_Err) => (schema, value, _ctx) => { + const ctx = _ctx ? { + ..._ctx, + async: false + } : { async: false }; + const result = schema._zod.run({ + value, + issues: [] + }, ctx); + if (result instanceof Promise) throw new $ZodAsyncError(); + return result.issues.length ? { + success: false, + error: new (_Err ?? $ZodError)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) + } : { + success: true, + data: result.value + }; + }; + const safeParse$1 = /* @__PURE__*/ _safeParse($ZodRealError); + const _safeParseAsync = (_Err) => async (schema, value, _ctx) => { + const ctx = _ctx ? { + ..._ctx, + async: true + } : { async: true }; + let result = schema._zod.run({ + value, + issues: [] + }, ctx); + if (result instanceof Promise) result = await result; + return result.issues.length ? { + success: false, + error: new _Err(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) + } : { + success: true, + data: result.value + }; + }; + const safeParseAsync$1 = /* @__PURE__*/ _safeParseAsync($ZodRealError); + const _encode = (_Err) => (schema, value, _ctx) => { + const ctx = _ctx ? { + ..._ctx, + direction: "backward" + } : { direction: "backward" }; + return _parse(_Err)(schema, value, ctx); + }; + const _decode = (_Err) => (schema, value, _ctx) => { + return _parse(_Err)(schema, value, _ctx); + }; + const _encodeAsync = (_Err) => async (schema, value, _ctx) => { + const ctx = _ctx ? { + ..._ctx, + direction: "backward" + } : { direction: "backward" }; + return _parseAsync(_Err)(schema, value, ctx); + }; + const _decodeAsync = (_Err) => async (schema, value, _ctx) => { + return _parseAsync(_Err)(schema, value, _ctx); + }; + const _safeEncode = (_Err) => (schema, value, _ctx) => { + const ctx = _ctx ? { + ..._ctx, + direction: "backward" + } : { direction: "backward" }; + return _safeParse(_Err)(schema, value, ctx); + }; + const _safeDecode = (_Err) => (schema, value, _ctx) => { + return _safeParse(_Err)(schema, value, _ctx); + }; + const _safeEncodeAsync = (_Err) => async (schema, value, _ctx) => { + const ctx = _ctx ? { + ..._ctx, + direction: "backward" + } : { direction: "backward" }; + return _safeParseAsync(_Err)(schema, value, ctx); + }; + const _safeDecodeAsync = (_Err) => async (schema, value, _ctx) => { + return _safeParseAsync(_Err)(schema, value, _ctx); + }; + /** + * @deprecated CUID v1 is deprecated by its authors due to information leakage + * (timestamps embedded in the id). Use {@link cuid2} instead. + * See https://github.com/paralleldrive/cuid. + */ + const cuid = /^[cC][0-9a-z]{6,}$/; + const cuid2 = /^[0-9a-z]+$/; + const ulid = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/; + const xid = /^[0-9a-vA-V]{20}$/; + const ksuid = /^[A-Za-z0-9]{27}$/; + const nanoid = /^[a-zA-Z0-9_-]{21}$/; + /** ISO 8601-1 duration regex. Does not support the 8601-2 extensions like negative durations or fractional/negative components. */ + const duration$1 = /^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/; + /** A regex for any UUID-like identifier: 8-4-4-4-12 hex pattern */ + const guid = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; + /** Returns a regex for validating an RFC 9562/4122 UUID. + * + * @param version Optionally specify a version 1-8. If no version is specified, all versions are supported. */ + const uuid = (version) => { + if (!version) return /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/; + return new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${version}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`); + }; + /** Practical email validation */ + const email = /^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/; + const _emoji$1 = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`; + function emoji() { + return new RegExp(_emoji$1, "u"); + } + const ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; + const ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/; + const cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/; + const cidrv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; + const base64 = /^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/; + const base64url = /^[A-Za-z0-9_-]*$/; + const httpProtocol = /^https?$/; + const e164 = /^\+[1-9]\d{6,14}$/; + const dateSource = `(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))`; + const date$1 = /*@__PURE__*/ new RegExp(`^${dateSource}$`); + function timeSource(args) { + const hhmm = `(?:[01]\\d|2[0-3]):[0-5]\\d`; + return typeof args.precision === "number" ? args.precision === -1 ? `${hhmm}` : args.precision === 0 ? `${hhmm}:[0-5]\\d` : `${hhmm}:[0-5]\\d\\.\\d{${args.precision}}` : `${hhmm}(?::[0-5]\\d(?:\\.\\d+)?)?`; + } + function time$1(args) { + return new RegExp(`^${timeSource(args)}$`); + } + function datetime$1(args) { + const time = timeSource({ precision: args.precision }); + const opts = ["Z"]; + if (args.local) opts.push(""); + if (args.offset) opts.push(`([+-](?:[01]\\d|2[0-3]):[0-5]\\d)`); + const timeRegex = `${time}(?:${opts.join("|")})`; + return new RegExp(`^${dateSource}T(?:${timeRegex})$`); + } + const string$1 = (params) => { + const regex = params ? `[\\s\\S]{${params?.minimum ?? 0},${params?.maximum ?? ""}}` : `[\\s\\S]*`; + return new RegExp(`^${regex}$`); + }; + const integer = /^-?\d+$/; + const number$1 = /^-?\d+(?:\.\d+)?$/; + const boolean$1 = /^(?:true|false)$/i; + const lowercase = /^[^A-Z]*$/; + const uppercase = /^[^a-z]*$/; + const $ZodCheck = /*@__PURE__*/ $constructor("$ZodCheck", (inst, def) => { + var _a; + inst._zod ?? (inst._zod = {}); + inst._zod.def = def; + (_a = inst._zod).onattach ?? (_a.onattach = []); + }); + const numericOriginMap = { + number: "number", + bigint: "bigint", + object: "date" + }; + const $ZodCheckLessThan = /*@__PURE__*/ $constructor("$ZodCheckLessThan", (inst, def) => { + $ZodCheck.init(inst, def); + const origin = numericOriginMap[typeof def.value]; + inst._zod.onattach.push((inst) => { + const bag = inst._zod.bag; + const curr = (def.inclusive ? bag.maximum : bag.exclusiveMaximum) ?? Number.POSITIVE_INFINITY; + if (def.value < curr) if (def.inclusive) bag.maximum = def.value; + else bag.exclusiveMaximum = def.value; + }); + inst._zod.check = (payload) => { + if (def.inclusive ? payload.value <= def.value : payload.value < def.value) return; + payload.issues.push({ + origin, + code: "too_big", + maximum: typeof def.value === "object" ? def.value.getTime() : def.value, + input: payload.value, + inclusive: def.inclusive, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckGreaterThan = /*@__PURE__*/ $constructor("$ZodCheckGreaterThan", (inst, def) => { + $ZodCheck.init(inst, def); + const origin = numericOriginMap[typeof def.value]; + inst._zod.onattach.push((inst) => { + const bag = inst._zod.bag; + const curr = (def.inclusive ? bag.minimum : bag.exclusiveMinimum) ?? Number.NEGATIVE_INFINITY; + if (def.value > curr) if (def.inclusive) bag.minimum = def.value; + else bag.exclusiveMinimum = def.value; + }); + inst._zod.check = (payload) => { + if (def.inclusive ? payload.value >= def.value : payload.value > def.value) return; + payload.issues.push({ + origin, + code: "too_small", + minimum: typeof def.value === "object" ? def.value.getTime() : def.value, + input: payload.value, + inclusive: def.inclusive, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckMultipleOf = /*@__PURE__*/ $constructor("$ZodCheckMultipleOf", (inst, def) => { + $ZodCheck.init(inst, def); + inst._zod.onattach.push((inst) => { + var _a; + (_a = inst._zod.bag).multipleOf ?? (_a.multipleOf = def.value); + }); + inst._zod.check = (payload) => { + if (typeof payload.value !== typeof def.value) throw new Error("Cannot mix number and bigint in multiple_of check."); + if (typeof payload.value === "bigint" ? payload.value % def.value === BigInt(0) : floatSafeRemainder(payload.value, def.value) === 0) return; + payload.issues.push({ + origin: typeof payload.value, + code: "not_multiple_of", + divisor: def.value, + input: payload.value, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckNumberFormat = /*@__PURE__*/ $constructor("$ZodCheckNumberFormat", (inst, def) => { + $ZodCheck.init(inst, def); + def.format = def.format || "float64"; + const isInt = def.format?.includes("int"); + const origin = isInt ? "int" : "number"; + const [minimum, maximum] = NUMBER_FORMAT_RANGES[def.format]; + inst._zod.onattach.push((inst) => { + const bag = inst._zod.bag; + bag.format = def.format; + bag.minimum = minimum; + bag.maximum = maximum; + if (isInt) bag.pattern = integer; + }); + inst._zod.check = (payload) => { + const input = payload.value; + if (isInt) { + if (!Number.isInteger(input)) { + payload.issues.push({ + expected: origin, + format: def.format, + code: "invalid_type", + continue: false, + input, + inst + }); + return; + } + if (!Number.isSafeInteger(input)) { + if (input > 0) payload.issues.push({ + input, + code: "too_big", + maximum: Number.MAX_SAFE_INTEGER, + note: "Integers must be within the safe integer range.", + inst, + origin, + inclusive: true, + continue: !def.abort + }); + else payload.issues.push({ + input, + code: "too_small", + minimum: Number.MIN_SAFE_INTEGER, + note: "Integers must be within the safe integer range.", + inst, + origin, + inclusive: true, + continue: !def.abort + }); + return; + } + } + if (input < minimum) payload.issues.push({ + origin: "number", + input, + code: "too_small", + minimum, + inclusive: true, + inst, + continue: !def.abort + }); + if (input > maximum) payload.issues.push({ + origin: "number", + input, + code: "too_big", + maximum, + inclusive: true, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckMaxLength = /*@__PURE__*/ $constructor("$ZodCheckMaxLength", (inst, def) => { + var _a; + $ZodCheck.init(inst, def); + (_a = inst._zod.def).when ?? (_a.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.length !== void 0; + }); + inst._zod.onattach.push((inst) => { + const curr = inst._zod.bag.maximum ?? Number.POSITIVE_INFINITY; + if (def.maximum < curr) inst._zod.bag.maximum = def.maximum; + }); + inst._zod.check = (payload) => { + const input = payload.value; + if (input.length <= def.maximum) return; + const origin = getLengthableOrigin(input); + payload.issues.push({ + origin, + code: "too_big", + maximum: def.maximum, + inclusive: true, + input, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckMinLength = /*@__PURE__*/ $constructor("$ZodCheckMinLength", (inst, def) => { + var _a; + $ZodCheck.init(inst, def); + (_a = inst._zod.def).when ?? (_a.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.length !== void 0; + }); + inst._zod.onattach.push((inst) => { + const curr = inst._zod.bag.minimum ?? Number.NEGATIVE_INFINITY; + if (def.minimum > curr) inst._zod.bag.minimum = def.minimum; + }); + inst._zod.check = (payload) => { + const input = payload.value; + if (input.length >= def.minimum) return; + const origin = getLengthableOrigin(input); + payload.issues.push({ + origin, + code: "too_small", + minimum: def.minimum, + inclusive: true, + input, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckLengthEquals = /*@__PURE__*/ $constructor("$ZodCheckLengthEquals", (inst, def) => { + var _a; + $ZodCheck.init(inst, def); + (_a = inst._zod.def).when ?? (_a.when = (payload) => { + const val = payload.value; + return !nullish(val) && val.length !== void 0; + }); + inst._zod.onattach.push((inst) => { + const bag = inst._zod.bag; + bag.minimum = def.length; + bag.maximum = def.length; + bag.length = def.length; + }); + inst._zod.check = (payload) => { + const input = payload.value; + const length = input.length; + if (length === def.length) return; + const origin = getLengthableOrigin(input); + const tooBig = length > def.length; + payload.issues.push({ + origin, + ...tooBig ? { + code: "too_big", + maximum: def.length + } : { + code: "too_small", + minimum: def.length + }, + inclusive: true, + exact: true, + input: payload.value, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckStringFormat = /*@__PURE__*/ $constructor("$ZodCheckStringFormat", (inst, def) => { + var _a, _b; + $ZodCheck.init(inst, def); + inst._zod.onattach.push((inst) => { + const bag = inst._zod.bag; + bag.format = def.format; + if (def.pattern) { + bag.patterns ?? (bag.patterns = /* @__PURE__ */ new Set()); + bag.patterns.add(def.pattern); + } + }); + if (def.pattern) (_a = inst._zod).check ?? (_a.check = (payload) => { + def.pattern.lastIndex = 0; + if (def.pattern.test(payload.value)) return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: def.format, + input: payload.value, + ...def.pattern ? { pattern: def.pattern.toString() } : {}, + inst, + continue: !def.abort + }); + }); + else (_b = inst._zod).check ?? (_b.check = () => {}); + }); + const $ZodCheckRegex = /*@__PURE__*/ $constructor("$ZodCheckRegex", (inst, def) => { + $ZodCheckStringFormat.init(inst, def); + inst._zod.check = (payload) => { + def.pattern.lastIndex = 0; + if (def.pattern.test(payload.value)) return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: "regex", + input: payload.value, + pattern: def.pattern.toString(), + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckLowerCase = /*@__PURE__*/ $constructor("$ZodCheckLowerCase", (inst, def) => { + def.pattern ?? (def.pattern = lowercase); + $ZodCheckStringFormat.init(inst, def); + }); + const $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst, def) => { + def.pattern ?? (def.pattern = uppercase); + $ZodCheckStringFormat.init(inst, def); + }); + const $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => { + $ZodCheck.init(inst, def); + const escapedRegex = escapeRegex(def.includes); + const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex); + def.pattern = pattern; + inst._zod.onattach.push((inst) => { + const bag = inst._zod.bag; + bag.patterns ?? (bag.patterns = /* @__PURE__ */ new Set()); + bag.patterns.add(pattern); + }); + inst._zod.check = (payload) => { + if (payload.value.includes(def.includes, def.position)) return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: "includes", + includes: def.includes, + input: payload.value, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => { + $ZodCheck.init(inst, def); + const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`); + def.pattern ?? (def.pattern = pattern); + inst._zod.onattach.push((inst) => { + const bag = inst._zod.bag; + bag.patterns ?? (bag.patterns = /* @__PURE__ */ new Set()); + bag.patterns.add(pattern); + }); + inst._zod.check = (payload) => { + if (payload.value.startsWith(def.prefix)) return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: "starts_with", + prefix: def.prefix, + input: payload.value, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => { + $ZodCheck.init(inst, def); + const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`); + def.pattern ?? (def.pattern = pattern); + inst._zod.onattach.push((inst) => { + const bag = inst._zod.bag; + bag.patterns ?? (bag.patterns = /* @__PURE__ */ new Set()); + bag.patterns.add(pattern); + }); + inst._zod.check = (payload) => { + if (payload.value.endsWith(def.suffix)) return; + payload.issues.push({ + origin: "string", + code: "invalid_format", + format: "ends_with", + suffix: def.suffix, + input: payload.value, + inst, + continue: !def.abort + }); + }; + }); + const $ZodCheckOverwrite = /*@__PURE__*/ $constructor("$ZodCheckOverwrite", (inst, def) => { + $ZodCheck.init(inst, def); + inst._zod.check = (payload) => { + payload.value = def.tx(payload.value); + }; + }); + var Doc = class { + constructor(args = []) { + this.content = []; + this.indent = 0; + if (this) this.args = args; + } + indented(fn) { + this.indent += 1; + fn(this); + this.indent -= 1; + } + write(arg) { + if (typeof arg === "function") { + arg(this, { execution: "sync" }); + arg(this, { execution: "async" }); + return; + } + const lines = arg.split("\n").filter((x) => x); + const minIndent = Math.min(...lines.map((x) => x.length - x.trimStart().length)); + const dedented = lines.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x); + for (const line of dedented) this.content.push(line); + } + compile() { + const F = Function; + const args = this?.args; + const lines = [...(this?.content ?? [``]).map((x) => ` ${x}`)]; + return new F(...args, lines.join("\n")); + } + }; + const version = { + major: 4, + minor: 4, + patch: 3 + }; + const $ZodType = /*@__PURE__*/ $constructor("$ZodType", (inst, def) => { + var _a; + inst ?? (inst = {}); + inst._zod.def = def; + inst._zod.bag = inst._zod.bag || {}; + inst._zod.version = version; + const checks = [...inst._zod.def.checks ?? []]; + if (inst._zod.traits.has("$ZodCheck")) checks.unshift(inst); + for (const ch of checks) for (const fn of ch._zod.onattach) fn(inst); + if (checks.length === 0) { + (_a = inst._zod).deferred ?? (_a.deferred = []); + inst._zod.deferred?.push(() => { + inst._zod.run = inst._zod.parse; + }); + } else { + const runChecks = (payload, checks, ctx) => { + let isAborted = aborted(payload); + let asyncResult; + for (const ch of checks) { + if (ch._zod.def.when) { + if (explicitlyAborted(payload)) continue; + if (!ch._zod.def.when(payload)) continue; + } else if (isAborted) continue; + const currLen = payload.issues.length; + const _ = ch._zod.check(payload); + if (_ instanceof Promise && ctx?.async === false) throw new $ZodAsyncError(); + if (asyncResult || _ instanceof Promise) asyncResult = (asyncResult ?? Promise.resolve()).then(async () => { + await _; + if (payload.issues.length === currLen) return; + if (!isAborted) isAborted = aborted(payload, currLen); + }); + else { + if (payload.issues.length === currLen) continue; + if (!isAborted) isAborted = aborted(payload, currLen); + } + } + if (asyncResult) return asyncResult.then(() => { + return payload; + }); + return payload; + }; + const handleCanaryResult = (canary, payload, ctx) => { + if (aborted(canary)) { + canary.aborted = true; + return canary; + } + const checkResult = runChecks(payload, checks, ctx); + if (checkResult instanceof Promise) { + if (ctx.async === false) throw new $ZodAsyncError(); + return checkResult.then((checkResult) => inst._zod.parse(checkResult, ctx)); + } + return inst._zod.parse(checkResult, ctx); + }; + inst._zod.run = (payload, ctx) => { + if (ctx.skipChecks) return inst._zod.parse(payload, ctx); + if (ctx.direction === "backward") { + const canary = inst._zod.parse({ + value: payload.value, + issues: [] + }, { + ...ctx, + skipChecks: true + }); + if (canary instanceof Promise) return canary.then((canary) => { + return handleCanaryResult(canary, payload, ctx); + }); + return handleCanaryResult(canary, payload, ctx); + } + const result = inst._zod.parse(payload, ctx); + if (result instanceof Promise) { + if (ctx.async === false) throw new $ZodAsyncError(); + return result.then((result) => runChecks(result, checks, ctx)); + } + return runChecks(result, checks, ctx); + }; + } + defineLazy(inst, "~standard", () => ({ + validate: (value) => { + try { + const r = safeParse$1(inst, value); + return r.success ? { value: r.data } : { issues: r.error?.issues }; + } catch (_) { + return safeParseAsync$1(inst, value).then((r) => r.success ? { value: r.data } : { issues: r.error?.issues }); + } + }, + vendor: "zod", + version: 1 + })); + }); + const $ZodString = /*@__PURE__*/ $constructor("$ZodString", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = [...inst?._zod.bag?.patterns ?? []].pop() ?? string$1(inst._zod.bag); + inst._zod.parse = (payload, _) => { + if (def.coerce) try { + payload.value = String(payload.value); + } catch (_) {} + if (typeof payload.value === "string") return payload; + payload.issues.push({ + expected: "string", + code: "invalid_type", + input: payload.value, + inst + }); + return payload; + }; + }); + const $ZodStringFormat = /*@__PURE__*/ $constructor("$ZodStringFormat", (inst, def) => { + $ZodCheckStringFormat.init(inst, def); + $ZodString.init(inst, def); + }); + const $ZodGUID = /*@__PURE__*/ $constructor("$ZodGUID", (inst, def) => { + def.pattern ?? (def.pattern = guid); + $ZodStringFormat.init(inst, def); + }); + const $ZodUUID = /*@__PURE__*/ $constructor("$ZodUUID", (inst, def) => { + if (def.version) { + const v = { + v1: 1, + v2: 2, + v3: 3, + v4: 4, + v5: 5, + v6: 6, + v7: 7, + v8: 8 + }[def.version]; + if (v === void 0) throw new Error(`Invalid UUID version: "${def.version}"`); + def.pattern ?? (def.pattern = uuid(v)); + } else def.pattern ?? (def.pattern = uuid()); + $ZodStringFormat.init(inst, def); + }); + const $ZodEmail = /*@__PURE__*/ $constructor("$ZodEmail", (inst, def) => { + def.pattern ?? (def.pattern = email); + $ZodStringFormat.init(inst, def); + }); + const $ZodURL = /*@__PURE__*/ $constructor("$ZodURL", (inst, def) => { + $ZodStringFormat.init(inst, def); + inst._zod.check = (payload) => { + try { + const trimmed = payload.value.trim(); + if (!def.normalize && def.protocol?.source === httpProtocol.source) { + if (!/^https?:\/\//i.test(trimmed)) { + payload.issues.push({ + code: "invalid_format", + format: "url", + note: "Invalid URL format", + input: payload.value, + inst, + continue: !def.abort + }); + return; + } + } + const url = new URL(trimmed); + if (def.hostname) { + def.hostname.lastIndex = 0; + if (!def.hostname.test(url.hostname)) payload.issues.push({ + code: "invalid_format", + format: "url", + note: "Invalid hostname", + pattern: def.hostname.source, + input: payload.value, + inst, + continue: !def.abort + }); + } + if (def.protocol) { + def.protocol.lastIndex = 0; + if (!def.protocol.test(url.protocol.endsWith(":") ? url.protocol.slice(0, -1) : url.protocol)) payload.issues.push({ + code: "invalid_format", + format: "url", + note: "Invalid protocol", + pattern: def.protocol.source, + input: payload.value, + inst, + continue: !def.abort + }); + } + if (def.normalize) payload.value = url.href; + else payload.value = trimmed; + return; + } catch (_) { + payload.issues.push({ + code: "invalid_format", + format: "url", + input: payload.value, + inst, + continue: !def.abort + }); + } + }; + }); + const $ZodEmoji = /*@__PURE__*/ $constructor("$ZodEmoji", (inst, def) => { + def.pattern ?? (def.pattern = emoji()); + $ZodStringFormat.init(inst, def); + }); + const $ZodNanoID = /*@__PURE__*/ $constructor("$ZodNanoID", (inst, def) => { + def.pattern ?? (def.pattern = nanoid); + $ZodStringFormat.init(inst, def); + }); + /** + * @deprecated CUID v1 is deprecated by its authors due to information leakage + * (timestamps embedded in the id). Use {@link $ZodCUID2} instead. + * See https://github.com/paralleldrive/cuid. + */ + const $ZodCUID = /*@__PURE__*/ $constructor("$ZodCUID", (inst, def) => { + def.pattern ?? (def.pattern = cuid); + $ZodStringFormat.init(inst, def); + }); + const $ZodCUID2 = /*@__PURE__*/ $constructor("$ZodCUID2", (inst, def) => { + def.pattern ?? (def.pattern = cuid2); + $ZodStringFormat.init(inst, def); + }); + const $ZodULID = /*@__PURE__*/ $constructor("$ZodULID", (inst, def) => { + def.pattern ?? (def.pattern = ulid); + $ZodStringFormat.init(inst, def); + }); + const $ZodXID = /*@__PURE__*/ $constructor("$ZodXID", (inst, def) => { + def.pattern ?? (def.pattern = xid); + $ZodStringFormat.init(inst, def); + }); + const $ZodKSUID = /*@__PURE__*/ $constructor("$ZodKSUID", (inst, def) => { + def.pattern ?? (def.pattern = ksuid); + $ZodStringFormat.init(inst, def); + }); + const $ZodISODateTime = /*@__PURE__*/ $constructor("$ZodISODateTime", (inst, def) => { + def.pattern ?? (def.pattern = datetime$1(def)); + $ZodStringFormat.init(inst, def); + }); + const $ZodISODate = /*@__PURE__*/ $constructor("$ZodISODate", (inst, def) => { + def.pattern ?? (def.pattern = date$1); + $ZodStringFormat.init(inst, def); + }); + const $ZodISOTime = /*@__PURE__*/ $constructor("$ZodISOTime", (inst, def) => { + def.pattern ?? (def.pattern = time$1(def)); + $ZodStringFormat.init(inst, def); + }); + const $ZodISODuration = /*@__PURE__*/ $constructor("$ZodISODuration", (inst, def) => { + def.pattern ?? (def.pattern = duration$1); + $ZodStringFormat.init(inst, def); + }); + const $ZodIPv4 = /*@__PURE__*/ $constructor("$ZodIPv4", (inst, def) => { + def.pattern ?? (def.pattern = ipv4); + $ZodStringFormat.init(inst, def); + inst._zod.bag.format = `ipv4`; + }); + const $ZodIPv6 = /*@__PURE__*/ $constructor("$ZodIPv6", (inst, def) => { + def.pattern ?? (def.pattern = ipv6); + $ZodStringFormat.init(inst, def); + inst._zod.bag.format = `ipv6`; + inst._zod.check = (payload) => { + try { + new URL(`http://[${payload.value}]`); + } catch { + payload.issues.push({ + code: "invalid_format", + format: "ipv6", + input: payload.value, + inst, + continue: !def.abort + }); + } + }; + }); + const $ZodCIDRv4 = /*@__PURE__*/ $constructor("$ZodCIDRv4", (inst, def) => { + def.pattern ?? (def.pattern = cidrv4); + $ZodStringFormat.init(inst, def); + }); + const $ZodCIDRv6 = /*@__PURE__*/ $constructor("$ZodCIDRv6", (inst, def) => { + def.pattern ?? (def.pattern = cidrv6); + $ZodStringFormat.init(inst, def); + inst._zod.check = (payload) => { + const parts = payload.value.split("/"); + try { + if (parts.length !== 2) throw new Error(); + const [address, prefix] = parts; + if (!prefix) throw new Error(); + const prefixNum = Number(prefix); + if (`${prefixNum}` !== prefix) throw new Error(); + if (prefixNum < 0 || prefixNum > 128) throw new Error(); + new URL(`http://[${address}]`); + } catch { + payload.issues.push({ + code: "invalid_format", + format: "cidrv6", + input: payload.value, + inst, + continue: !def.abort + }); + } + }; + }); + function isValidBase64(data) { + if (data === "") return true; + if (/\s/.test(data)) return false; + if (data.length % 4 !== 0) return false; + try { + atob(data); + return true; + } catch { + return false; + } + } + const $ZodBase64 = /*@__PURE__*/ $constructor("$ZodBase64", (inst, def) => { + def.pattern ?? (def.pattern = base64); + $ZodStringFormat.init(inst, def); + inst._zod.bag.contentEncoding = "base64"; + inst._zod.check = (payload) => { + if (isValidBase64(payload.value)) return; + payload.issues.push({ + code: "invalid_format", + format: "base64", + input: payload.value, + inst, + continue: !def.abort + }); + }; + }); + function isValidBase64URL(data) { + if (!base64url.test(data)) return false; + const base64 = data.replace(/[-_]/g, (c) => c === "-" ? "+" : "/"); + return isValidBase64(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); + } + const $ZodBase64URL = /*@__PURE__*/ $constructor("$ZodBase64URL", (inst, def) => { + def.pattern ?? (def.pattern = base64url); + $ZodStringFormat.init(inst, def); + inst._zod.bag.contentEncoding = "base64url"; + inst._zod.check = (payload) => { + if (isValidBase64URL(payload.value)) return; + payload.issues.push({ + code: "invalid_format", + format: "base64url", + input: payload.value, + inst, + continue: !def.abort + }); + }; + }); + const $ZodE164 = /*@__PURE__*/ $constructor("$ZodE164", (inst, def) => { + def.pattern ?? (def.pattern = e164); + $ZodStringFormat.init(inst, def); + }); + function isValidJWT(token, algorithm = null) { + try { + const tokensParts = token.split("."); + if (tokensParts.length !== 3) return false; + const [header] = tokensParts; + if (!header) return false; + const parsedHeader = JSON.parse(atob(header)); + if ("typ" in parsedHeader && parsedHeader?.typ !== "JWT") return false; + if (!parsedHeader.alg) return false; + if (algorithm && (!("alg" in parsedHeader) || parsedHeader.alg !== algorithm)) return false; + return true; + } catch { + return false; + } + } + const $ZodJWT = /*@__PURE__*/ $constructor("$ZodJWT", (inst, def) => { + $ZodStringFormat.init(inst, def); + inst._zod.check = (payload) => { + if (isValidJWT(payload.value, def.alg)) return; + payload.issues.push({ + code: "invalid_format", + format: "jwt", + input: payload.value, + inst, + continue: !def.abort + }); + }; + }); + const $ZodNumber = /*@__PURE__*/ $constructor("$ZodNumber", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = inst._zod.bag.pattern ?? number$1; + inst._zod.parse = (payload, _ctx) => { + if (def.coerce) try { + payload.value = Number(payload.value); + } catch (_) {} + const input = payload.value; + if (typeof input === "number" && !Number.isNaN(input) && Number.isFinite(input)) return payload; + const received = typeof input === "number" ? Number.isNaN(input) ? "NaN" : !Number.isFinite(input) ? "Infinity" : void 0 : void 0; + payload.issues.push({ + expected: "number", + code: "invalid_type", + input, + inst, + ...received ? { received } : {} + }); + return payload; + }; + }); + const $ZodNumberFormat = /*@__PURE__*/ $constructor("$ZodNumberFormat", (inst, def) => { + $ZodCheckNumberFormat.init(inst, def); + $ZodNumber.init(inst, def); + }); + const $ZodBoolean = /*@__PURE__*/ $constructor("$ZodBoolean", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.pattern = boolean$1; + inst._zod.parse = (payload, _ctx) => { + if (def.coerce) try { + payload.value = Boolean(payload.value); + } catch (_) {} + const input = payload.value; + if (typeof input === "boolean") return payload; + payload.issues.push({ + expected: "boolean", + code: "invalid_type", + input, + inst + }); + return payload; + }; + }); + const $ZodUnknown = /*@__PURE__*/ $constructor("$ZodUnknown", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload) => payload; + }); + const $ZodNever = /*@__PURE__*/ $constructor("$ZodNever", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, _ctx) => { + payload.issues.push({ + expected: "never", + code: "invalid_type", + input: payload.value, + inst + }); + return payload; + }; + }); + function handleArrayResult(result, final, index) { + if (result.issues.length) final.issues.push(...prefixIssues(index, result.issues)); + final.value[index] = result.value; + } + const $ZodArray = /*@__PURE__*/ $constructor("$ZodArray", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + if (!Array.isArray(input)) { + payload.issues.push({ + expected: "array", + code: "invalid_type", + input, + inst + }); + return payload; + } + payload.value = Array(input.length); + const proms = []; + for (let i = 0; i < input.length; i++) { + const item = input[i]; + const result = def.element._zod.run({ + value: item, + issues: [] + }, ctx); + if (result instanceof Promise) proms.push(result.then((result) => handleArrayResult(result, payload, i))); + else handleArrayResult(result, payload, i); + } + if (proms.length) return Promise.all(proms).then(() => payload); + return payload; + }; + }); + function handlePropertyResult(result, final, key, input, isOptionalIn, isOptionalOut) { + const isPresent = key in input; + if (result.issues.length) { + if (isOptionalIn && isOptionalOut && !isPresent) return; + final.issues.push(...prefixIssues(key, result.issues)); + } + if (!isPresent && !isOptionalIn) { + if (!result.issues.length) final.issues.push({ + code: "invalid_type", + expected: "nonoptional", + input: void 0, + path: [key] + }); + return; + } + if (result.value === void 0) { + if (isPresent) final.value[key] = void 0; + } else final.value[key] = result.value; + } + function normalizeDef(def) { + const keys = Object.keys(def.shape); + for (const k of keys) if (!def.shape?.[k]?._zod?.traits?.has("$ZodType")) throw new Error(`Invalid element at key "${k}": expected a Zod schema`); + const okeys = optionalKeys(def.shape); + return { + ...def, + keys, + keySet: new Set(keys), + numKeys: keys.length, + optionalKeys: new Set(okeys) + }; + } + function handleCatchall(proms, input, payload, ctx, def, inst) { + const unrecognized = []; + const keySet = def.keySet; + const _catchall = def.catchall._zod; + const t = _catchall.def.type; + const isOptionalIn = _catchall.optin === "optional"; + const isOptionalOut = _catchall.optout === "optional"; + for (const key in input) { + if (key === "__proto__") continue; + if (keySet.has(key)) continue; + if (t === "never") { + unrecognized.push(key); + continue; + } + const r = _catchall.run({ + value: input[key], + issues: [] + }, ctx); + if (r instanceof Promise) proms.push(r.then((r) => handlePropertyResult(r, payload, key, input, isOptionalIn, isOptionalOut))); + else handlePropertyResult(r, payload, key, input, isOptionalIn, isOptionalOut); + } + if (unrecognized.length) payload.issues.push({ + code: "unrecognized_keys", + keys: unrecognized, + input, + inst + }); + if (!proms.length) return payload; + return Promise.all(proms).then(() => { + return payload; + }); + } + const $ZodObject = /*@__PURE__*/ $constructor("$ZodObject", (inst, def) => { + $ZodType.init(inst, def); + if (!Object.getOwnPropertyDescriptor(def, "shape")?.get) { + const sh = def.shape; + Object.defineProperty(def, "shape", { get: () => { + const newSh = { ...sh }; + Object.defineProperty(def, "shape", { value: newSh }); + return newSh; + } }); + } + const _normalized = cached(() => normalizeDef(def)); + defineLazy(inst._zod, "propValues", () => { + const shape = def.shape; + const propValues = {}; + for (const key in shape) { + const field = shape[key]._zod; + if (field.values) { + propValues[key] ?? (propValues[key] = /* @__PURE__ */ new Set()); + for (const v of field.values) propValues[key].add(v); + } + } + return propValues; + }); + const isObject$1 = isObject; + const catchall = def.catchall; + let value; + inst._zod.parse = (payload, ctx) => { + value ?? (value = _normalized.value); + const input = payload.value; + if (!isObject$1(input)) { + payload.issues.push({ + expected: "object", + code: "invalid_type", + input, + inst + }); + return payload; + } + payload.value = {}; + const proms = []; + const shape = value.shape; + for (const key of value.keys) { + const el = shape[key]; + const isOptionalIn = el._zod.optin === "optional"; + const isOptionalOut = el._zod.optout === "optional"; + const r = el._zod.run({ + value: input[key], + issues: [] + }, ctx); + if (r instanceof Promise) proms.push(r.then((r) => handlePropertyResult(r, payload, key, input, isOptionalIn, isOptionalOut))); + else handlePropertyResult(r, payload, key, input, isOptionalIn, isOptionalOut); + } + if (!catchall) return proms.length ? Promise.all(proms).then(() => payload) : payload; + return handleCatchall(proms, input, payload, ctx, _normalized.value, inst); + }; + }); + const $ZodObjectJIT = /*@__PURE__*/ $constructor("$ZodObjectJIT", (inst, def) => { + $ZodObject.init(inst, def); + const superParse = inst._zod.parse; + const _normalized = cached(() => normalizeDef(def)); + const generateFastpass = (shape) => { + const doc = new Doc([ + "shape", + "payload", + "ctx" + ]); + const normalized = _normalized.value; + const parseStr = (key) => { + const k = esc(key); + return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`; + }; + doc.write(`const input = payload.value;`); + const ids = Object.create(null); + let counter = 0; + for (const key of normalized.keys) ids[key] = `key_${counter++}`; + doc.write(`const newResult = {};`); + for (const key of normalized.keys) { + const id = ids[key]; + const k = esc(key); + const schema = shape[key]; + const isOptionalIn = schema?._zod?.optin === "optional"; + const isOptionalOut = schema?._zod?.optout === "optional"; + doc.write(`const ${id} = ${parseStr(key)};`); + if (isOptionalIn && isOptionalOut) doc.write(` + if (${id}.issues.length) { + if (${k} in input) { + payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${k}, ...iss.path] : [${k}] + }))); + } + } + + if (${id}.value === undefined) { + if (${k} in input) { + newResult[${k}] = undefined; + } + } else { + newResult[${k}] = ${id}.value; + } + + `); + else if (!isOptionalIn) doc.write(` + const ${id}_present = ${k} in input; + if (${id}.issues.length) { + payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${k}, ...iss.path] : [${k}] + }))); + } + if (!${id}_present && !${id}.issues.length) { + payload.issues.push({ + code: "invalid_type", + expected: "nonoptional", + input: undefined, + path: [${k}] + }); + } + + if (${id}_present) { + if (${id}.value === undefined) { + newResult[${k}] = undefined; + } else { + newResult[${k}] = ${id}.value; + } + } + + `); + else doc.write(` + if (${id}.issues.length) { + payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${k}, ...iss.path] : [${k}] + }))); + } + + if (${id}.value === undefined) { + if (${k} in input) { + newResult[${k}] = undefined; + } + } else { + newResult[${k}] = ${id}.value; + } + + `); + } + doc.write(`payload.value = newResult;`); + doc.write(`return payload;`); + const fn = doc.compile(); + return (payload, ctx) => fn(shape, payload, ctx); + }; + let fastpass; + const isObject$2 = isObject; + const jit = !globalConfig.jitless; + const fastEnabled = jit && allowsEval.value; + const catchall = def.catchall; + let value; + inst._zod.parse = (payload, ctx) => { + value ?? (value = _normalized.value); + const input = payload.value; + if (!isObject$2(input)) { + payload.issues.push({ + expected: "object", + code: "invalid_type", + input, + inst + }); + return payload; + } + if (jit && fastEnabled && ctx?.async === false && ctx.jitless !== true) { + if (!fastpass) fastpass = generateFastpass(def.shape); + payload = fastpass(payload, ctx); + if (!catchall) return payload; + return handleCatchall([], input, payload, ctx, value, inst); + } + return superParse(payload, ctx); + }; + }); + function handleUnionResults(results, final, inst, ctx) { + for (const result of results) if (result.issues.length === 0) { + final.value = result.value; + return final; + } + const nonaborted = results.filter((r) => !aborted(r)); + if (nonaborted.length === 1) { + final.value = nonaborted[0].value; + return nonaborted[0]; + } + final.issues.push({ + code: "invalid_union", + input: final.value, + inst, + errors: results.map((result) => result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) + }); + return final; + } + const $ZodUnion = /*@__PURE__*/ $constructor("$ZodUnion", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "optin", () => def.options.some((o) => o._zod.optin === "optional") ? "optional" : void 0); + defineLazy(inst._zod, "optout", () => def.options.some((o) => o._zod.optout === "optional") ? "optional" : void 0); + defineLazy(inst._zod, "values", () => { + if (def.options.every((o) => o._zod.values)) return new Set(def.options.flatMap((option) => Array.from(option._zod.values))); + }); + defineLazy(inst._zod, "pattern", () => { + if (def.options.every((o) => o._zod.pattern)) { + const patterns = def.options.map((o) => o._zod.pattern); + return new RegExp(`^(${patterns.map((p) => cleanRegex(p.source)).join("|")})$`); + } + }); + const first = def.options.length === 1 ? def.options[0]._zod.run : null; + inst._zod.parse = (payload, ctx) => { + if (first) return first(payload, ctx); + let async = false; + const results = []; + for (const option of def.options) { + const result = option._zod.run({ + value: payload.value, + issues: [] + }, ctx); + if (result instanceof Promise) { + results.push(result); + async = true; + } else { + if (result.issues.length === 0) return result; + results.push(result); + } + } + if (!async) return handleUnionResults(results, payload, inst, ctx); + return Promise.all(results).then((results) => { + return handleUnionResults(results, payload, inst, ctx); + }); + }; + }); + const $ZodIntersection = /*@__PURE__*/ $constructor("$ZodIntersection", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + const left = def.left._zod.run({ + value: input, + issues: [] + }, ctx); + const right = def.right._zod.run({ + value: input, + issues: [] + }, ctx); + if (left instanceof Promise || right instanceof Promise) return Promise.all([left, right]).then(([left, right]) => { + return handleIntersectionResults(payload, left, right); + }); + return handleIntersectionResults(payload, left, right); + }; + }); + function mergeValues(a, b) { + if (a === b) return { + valid: true, + data: a + }; + if (a instanceof Date && b instanceof Date && +a === +b) return { + valid: true, + data: a + }; + if (isPlainObject(a) && isPlainObject(b)) { + const bKeys = Object.keys(b); + const sharedKeys = Object.keys(a).filter((key) => bKeys.indexOf(key) !== -1); + const newObj = { + ...a, + ...b + }; + for (const key of sharedKeys) { + const sharedValue = mergeValues(a[key], b[key]); + if (!sharedValue.valid) return { + valid: false, + mergeErrorPath: [key, ...sharedValue.mergeErrorPath] + }; + newObj[key] = sharedValue.data; + } + return { + valid: true, + data: newObj + }; + } + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return { + valid: false, + mergeErrorPath: [] + }; + const newArray = []; + for (let index = 0; index < a.length; index++) { + const itemA = a[index]; + const itemB = b[index]; + const sharedValue = mergeValues(itemA, itemB); + if (!sharedValue.valid) return { + valid: false, + mergeErrorPath: [index, ...sharedValue.mergeErrorPath] + }; + newArray.push(sharedValue.data); + } + return { + valid: true, + data: newArray + }; + } + return { + valid: false, + mergeErrorPath: [] + }; + } + function handleIntersectionResults(result, left, right) { + const unrecKeys = /* @__PURE__ */ new Map(); + let unrecIssue; + for (const iss of left.issues) if (iss.code === "unrecognized_keys") { + unrecIssue ?? (unrecIssue = iss); + for (const k of iss.keys) { + if (!unrecKeys.has(k)) unrecKeys.set(k, {}); + unrecKeys.get(k).l = true; + } + } else result.issues.push(iss); + for (const iss of right.issues) if (iss.code === "unrecognized_keys") for (const k of iss.keys) { + if (!unrecKeys.has(k)) unrecKeys.set(k, {}); + unrecKeys.get(k).r = true; + } + else result.issues.push(iss); + const bothKeys = [...unrecKeys].filter(([, f]) => f.l && f.r).map(([k]) => k); + if (bothKeys.length && unrecIssue) result.issues.push({ + ...unrecIssue, + keys: bothKeys + }); + if (aborted(result)) return result; + const merged = mergeValues(left.value, right.value); + if (!merged.valid) throw new Error(`Unmergable intersection. Error path: ${JSON.stringify(merged.mergeErrorPath)}`); + result.value = merged.data; + return result; + } + const $ZodRecord = /*@__PURE__*/ $constructor("$ZodRecord", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.parse = (payload, ctx) => { + const input = payload.value; + if (!isPlainObject(input)) { + payload.issues.push({ + expected: "record", + code: "invalid_type", + input, + inst + }); + return payload; + } + const proms = []; + const values = def.keyType._zod.values; + if (values) { + payload.value = {}; + const recordKeys = /* @__PURE__ */ new Set(); + for (const key of values) if (typeof key === "string" || typeof key === "number" || typeof key === "symbol") { + recordKeys.add(typeof key === "number" ? key.toString() : key); + const keyResult = def.keyType._zod.run({ + value: key, + issues: [] + }, ctx); + if (keyResult instanceof Promise) throw new Error("Async schemas not supported in object keys currently"); + if (keyResult.issues.length) { + payload.issues.push({ + code: "invalid_key", + origin: "record", + issues: keyResult.issues.map((iss) => finalizeIssue(iss, ctx, config())), + input: key, + path: [key], + inst + }); + continue; + } + const outKey = keyResult.value; + const result = def.valueType._zod.run({ + value: input[key], + issues: [] + }, ctx); + if (result instanceof Promise) proms.push(result.then((result) => { + if (result.issues.length) payload.issues.push(...prefixIssues(key, result.issues)); + payload.value[outKey] = result.value; + })); + else { + if (result.issues.length) payload.issues.push(...prefixIssues(key, result.issues)); + payload.value[outKey] = result.value; + } + } + let unrecognized; + for (const key in input) if (!recordKeys.has(key)) { + unrecognized = unrecognized ?? []; + unrecognized.push(key); + } + if (unrecognized && unrecognized.length > 0) payload.issues.push({ + code: "unrecognized_keys", + input, + inst, + keys: unrecognized + }); + } else { + payload.value = {}; + for (const key of Reflect.ownKeys(input)) { + if (key === "__proto__") continue; + if (!Object.prototype.propertyIsEnumerable.call(input, key)) continue; + let keyResult = def.keyType._zod.run({ + value: key, + issues: [] + }, ctx); + if (keyResult instanceof Promise) throw new Error("Async schemas not supported in object keys currently"); + if (typeof key === "string" && number$1.test(key) && keyResult.issues.length) { + const retryResult = def.keyType._zod.run({ + value: Number(key), + issues: [] + }, ctx); + if (retryResult instanceof Promise) throw new Error("Async schemas not supported in object keys currently"); + if (retryResult.issues.length === 0) keyResult = retryResult; + } + if (keyResult.issues.length) { + if (def.mode === "loose") payload.value[key] = input[key]; + else payload.issues.push({ + code: "invalid_key", + origin: "record", + issues: keyResult.issues.map((iss) => finalizeIssue(iss, ctx, config())), + input: key, + path: [key], + inst + }); + continue; + } + const result = def.valueType._zod.run({ + value: input[key], + issues: [] + }, ctx); + if (result instanceof Promise) proms.push(result.then((result) => { + if (result.issues.length) payload.issues.push(...prefixIssues(key, result.issues)); + payload.value[keyResult.value] = result.value; + })); + else { + if (result.issues.length) payload.issues.push(...prefixIssues(key, result.issues)); + payload.value[keyResult.value] = result.value; + } + } + } + if (proms.length) return Promise.all(proms).then(() => payload); + return payload; + }; + }); + const $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => { + $ZodType.init(inst, def); + const values = getEnumValues(def.entries); + const valuesSet = new Set(values); + inst._zod.values = valuesSet; + inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`); + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (valuesSet.has(input)) return payload; + payload.issues.push({ + code: "invalid_value", + values, + input, + inst + }); + return payload; + }; + }); + const $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => { + $ZodType.init(inst, def); + if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values"); + const values = new Set(def.values); + inst._zod.values = values; + inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`); + inst._zod.parse = (payload, _ctx) => { + const input = payload.value; + if (values.has(input)) return payload; + payload.issues.push({ + code: "invalid_value", + values: def.values, + input, + inst + }); + return payload; + }; + }); + const $ZodTransform = /*@__PURE__*/ $constructor("$ZodTransform", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.optin = "optional"; + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") throw new $ZodEncodeError(inst.constructor.name); + const _out = def.transform(payload.value, payload); + if (ctx.async) return (_out instanceof Promise ? _out : Promise.resolve(_out)).then((output) => { + payload.value = output; + payload.fallback = true; + return payload; + }); + if (_out instanceof Promise) throw new $ZodAsyncError(); + payload.value = _out; + payload.fallback = true; + return payload; + }; + }); + function handleOptionalResult(result, input) { + if (input === void 0 && (result.issues.length || result.fallback)) return { + issues: [], + value: void 0 + }; + return result; + } + const $ZodOptional = /*@__PURE__*/ $constructor("$ZodOptional", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.optin = "optional"; + inst._zod.optout = "optional"; + defineLazy(inst._zod, "values", () => { + return def.innerType._zod.values ? new Set([...def.innerType._zod.values, void 0]) : void 0; + }); + defineLazy(inst._zod, "pattern", () => { + const pattern = def.innerType._zod.pattern; + return pattern ? new RegExp(`^(${cleanRegex(pattern.source)})?$`) : void 0; + }); + inst._zod.parse = (payload, ctx) => { + if (def.innerType._zod.optin === "optional") { + const input = payload.value; + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) return result.then((r) => handleOptionalResult(r, input)); + return handleOptionalResult(result, input); + } + if (payload.value === void 0) return payload; + return def.innerType._zod.run(payload, ctx); + }; + }); + const $ZodExactOptional = /*@__PURE__*/ $constructor("$ZodExactOptional", (inst, def) => { + $ZodOptional.init(inst, def); + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + defineLazy(inst._zod, "pattern", () => def.innerType._zod.pattern); + inst._zod.parse = (payload, ctx) => { + return def.innerType._zod.run(payload, ctx); + }; + }); + const $ZodNullable = /*@__PURE__*/ $constructor("$ZodNullable", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "optin", () => def.innerType._zod.optin); + defineLazy(inst._zod, "optout", () => def.innerType._zod.optout); + defineLazy(inst._zod, "pattern", () => { + const pattern = def.innerType._zod.pattern; + return pattern ? new RegExp(`^(${cleanRegex(pattern.source)}|null)$`) : void 0; + }); + defineLazy(inst._zod, "values", () => { + return def.innerType._zod.values ? new Set([...def.innerType._zod.values, null]) : void 0; + }); + inst._zod.parse = (payload, ctx) => { + if (payload.value === null) return payload; + return def.innerType._zod.run(payload, ctx); + }; + }); + const $ZodDefault = /*@__PURE__*/ $constructor("$ZodDefault", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.optin = "optional"; + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") return def.innerType._zod.run(payload, ctx); + if (payload.value === void 0) { + payload.value = def.defaultValue; + /** + * $ZodDefault returns the default value immediately in forward direction. + * It doesn't pass the default value into the validator ("prefault"). There's no reason to pass the default value through validation. The validity of the default is enforced by TypeScript statically. Otherwise, it's the responsibility of the user to ensure the default is valid. In the case of pipes with divergent in/out types, you can specify the default on the `in` schema of your ZodPipe to set a "prefault" for the pipe. */ + return payload; + } + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) return result.then((result) => handleDefaultResult(result, def)); + return handleDefaultResult(result, def); + }; + }); + function handleDefaultResult(payload, def) { + if (payload.value === void 0) payload.value = def.defaultValue; + return payload; + } + const $ZodPrefault = /*@__PURE__*/ $constructor("$ZodPrefault", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.optin = "optional"; + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") return def.innerType._zod.run(payload, ctx); + if (payload.value === void 0) payload.value = def.defaultValue; + return def.innerType._zod.run(payload, ctx); + }; + }); + const $ZodNonOptional = /*@__PURE__*/ $constructor("$ZodNonOptional", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "values", () => { + const v = def.innerType._zod.values; + return v ? new Set([...v].filter((x) => x !== void 0)) : void 0; + }); + inst._zod.parse = (payload, ctx) => { + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) return result.then((result) => handleNonOptionalResult(result, inst)); + return handleNonOptionalResult(result, inst); + }; + }); + function handleNonOptionalResult(payload, inst) { + if (!payload.issues.length && payload.value === void 0) payload.issues.push({ + code: "invalid_type", + expected: "nonoptional", + input: payload.value, + inst + }); + return payload; + } + const $ZodCatch = /*@__PURE__*/ $constructor("$ZodCatch", (inst, def) => { + $ZodType.init(inst, def); + inst._zod.optin = "optional"; + defineLazy(inst._zod, "optout", () => def.innerType._zod.optout); + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") return def.innerType._zod.run(payload, ctx); + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) return result.then((result) => { + payload.value = result.value; + if (result.issues.length) { + payload.value = def.catchValue({ + ...payload, + error: { issues: result.issues.map((iss) => finalizeIssue(iss, ctx, config())) }, + input: payload.value + }); + payload.issues = []; + payload.fallback = true; + } + return payload; + }); + payload.value = result.value; + if (result.issues.length) { + payload.value = def.catchValue({ + ...payload, + error: { issues: result.issues.map((iss) => finalizeIssue(iss, ctx, config())) }, + input: payload.value + }); + payload.issues = []; + payload.fallback = true; + } + return payload; + }; + }); + const $ZodPipe = /*@__PURE__*/ $constructor("$ZodPipe", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "values", () => def.in._zod.values); + defineLazy(inst._zod, "optin", () => def.in._zod.optin); + defineLazy(inst._zod, "optout", () => def.out._zod.optout); + defineLazy(inst._zod, "propValues", () => def.in._zod.propValues); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") { + const right = def.out._zod.run(payload, ctx); + if (right instanceof Promise) return right.then((right) => handlePipeResult(right, def.in, ctx)); + return handlePipeResult(right, def.in, ctx); + } + const left = def.in._zod.run(payload, ctx); + if (left instanceof Promise) return left.then((left) => handlePipeResult(left, def.out, ctx)); + return handlePipeResult(left, def.out, ctx); + }; + }); + function handlePipeResult(left, next, ctx) { + if (left.issues.length) { + left.aborted = true; + return left; + } + return next._zod.run({ + value: left.value, + issues: left.issues, + fallback: left.fallback + }, ctx); + } + const $ZodReadonly = /*@__PURE__*/ $constructor("$ZodReadonly", (inst, def) => { + $ZodType.init(inst, def); + defineLazy(inst._zod, "propValues", () => def.innerType._zod.propValues); + defineLazy(inst._zod, "values", () => def.innerType._zod.values); + defineLazy(inst._zod, "optin", () => def.innerType?._zod?.optin); + defineLazy(inst._zod, "optout", () => def.innerType?._zod?.optout); + inst._zod.parse = (payload, ctx) => { + if (ctx.direction === "backward") return def.innerType._zod.run(payload, ctx); + const result = def.innerType._zod.run(payload, ctx); + if (result instanceof Promise) return result.then(handleReadonlyResult); + return handleReadonlyResult(result); + }; + }); + function handleReadonlyResult(payload) { + payload.value = Object.freeze(payload.value); + return payload; + } + const $ZodCustom = /*@__PURE__*/ $constructor("$ZodCustom", (inst, def) => { + $ZodCheck.init(inst, def); + $ZodType.init(inst, def); + inst._zod.parse = (payload, _) => { + return payload; + }; + inst._zod.check = (payload) => { + const input = payload.value; + const r = def.fn(input); + if (r instanceof Promise) return r.then((r) => handleRefineResult(r, payload, input, inst)); + handleRefineResult(r, payload, input, inst); + }; + }); + function handleRefineResult(result, payload, input, inst) { + if (!result) { + const _iss = { + code: "custom", + input, + inst, + path: [...inst._zod.def.path ?? []], + continue: !inst._zod.def.abort + }; + if (inst._zod.def.params) _iss.params = inst._zod.def.params; + payload.issues.push(issue(_iss)); + } + } + var _a; + var $ZodRegistry = class { + constructor() { + this._map = /* @__PURE__ */ new WeakMap(); + this._idmap = /* @__PURE__ */ new Map(); + } + add(schema, ..._meta) { + const meta = _meta[0]; + this._map.set(schema, meta); + if (meta && typeof meta === "object" && "id" in meta) this._idmap.set(meta.id, schema); + return this; + } + clear() { + this._map = /* @__PURE__ */ new WeakMap(); + this._idmap = /* @__PURE__ */ new Map(); + return this; + } + remove(schema) { + const meta = this._map.get(schema); + if (meta && typeof meta === "object" && "id" in meta) this._idmap.delete(meta.id); + this._map.delete(schema); + return this; + } + get(schema) { + const p = schema._zod.parent; + if (p) { + const pm = { ...this.get(p) ?? {} }; + delete pm.id; + const f = { + ...pm, + ...this._map.get(schema) + }; + return Object.keys(f).length ? f : void 0; + } + return this._map.get(schema); + } + has(schema) { + return this._map.has(schema); + } + }; + function registry() { + return new $ZodRegistry(); + } + (_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry()); + const globalRegistry = globalThis.__zod_globalRegistry; + // @__NO_SIDE_EFFECTS__ + function _string(Class, params) { + return new Class({ + type: "string", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _email(Class, params) { + return new Class({ + type: "string", + format: "email", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _guid(Class, params) { + return new Class({ + type: "string", + format: "guid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _uuid(Class, params) { + return new Class({ + type: "string", + format: "uuid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _uuidv4(Class, params) { + return new Class({ + type: "string", + format: "uuid", + check: "string_format", + abort: false, + version: "v4", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _uuidv6(Class, params) { + return new Class({ + type: "string", + format: "uuid", + check: "string_format", + abort: false, + version: "v6", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _uuidv7(Class, params) { + return new Class({ + type: "string", + format: "uuid", + check: "string_format", + abort: false, + version: "v7", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _url(Class, params) { + return new Class({ + type: "string", + format: "url", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _emoji(Class, params) { + return new Class({ + type: "string", + format: "emoji", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _nanoid(Class, params) { + return new Class({ + type: "string", + format: "nanoid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + /** + * @deprecated CUID v1 is deprecated by its authors due to information leakage + * (timestamps embedded in the id). Use {@link _cuid2} instead. + * See https://github.com/paralleldrive/cuid. + */ + // @__NO_SIDE_EFFECTS__ + function _cuid(Class, params) { + return new Class({ + type: "string", + format: "cuid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _cuid2(Class, params) { + return new Class({ + type: "string", + format: "cuid2", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _ulid(Class, params) { + return new Class({ + type: "string", + format: "ulid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _xid(Class, params) { + return new Class({ + type: "string", + format: "xid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _ksuid(Class, params) { + return new Class({ + type: "string", + format: "ksuid", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _ipv4(Class, params) { + return new Class({ + type: "string", + format: "ipv4", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _ipv6(Class, params) { + return new Class({ + type: "string", + format: "ipv6", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _cidrv4(Class, params) { + return new Class({ + type: "string", + format: "cidrv4", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _cidrv6(Class, params) { + return new Class({ + type: "string", + format: "cidrv6", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _base64(Class, params) { + return new Class({ + type: "string", + format: "base64", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _base64url(Class, params) { + return new Class({ + type: "string", + format: "base64url", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _e164(Class, params) { + return new Class({ + type: "string", + format: "e164", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _jwt(Class, params) { + return new Class({ + type: "string", + format: "jwt", + check: "string_format", + abort: false, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _isoDateTime(Class, params) { + return new Class({ + type: "string", + format: "datetime", + check: "string_format", + offset: false, + local: false, + precision: null, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _isoDate(Class, params) { + return new Class({ + type: "string", + format: "date", + check: "string_format", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _isoTime(Class, params) { + return new Class({ + type: "string", + format: "time", + check: "string_format", + precision: null, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _isoDuration(Class, params) { + return new Class({ + type: "string", + format: "duration", + check: "string_format", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _number(Class, params) { + return new Class({ + type: "number", + checks: [], + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _int(Class, params) { + return new Class({ + type: "number", + check: "number_format", + abort: false, + format: "safeint", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _boolean(Class, params) { + return new Class({ + type: "boolean", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _unknown(Class) { + return new Class({ type: "unknown" }); + } + // @__NO_SIDE_EFFECTS__ + function _never(Class, params) { + return new Class({ + type: "never", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _lt(value, params) { + return new $ZodCheckLessThan({ + check: "less_than", + ...normalizeParams(params), + value, + inclusive: false + }); + } + // @__NO_SIDE_EFFECTS__ + function _lte(value, params) { + return new $ZodCheckLessThan({ + check: "less_than", + ...normalizeParams(params), + value, + inclusive: true + }); + } + // @__NO_SIDE_EFFECTS__ + function _gt(value, params) { + return new $ZodCheckGreaterThan({ + check: "greater_than", + ...normalizeParams(params), + value, + inclusive: false + }); + } + // @__NO_SIDE_EFFECTS__ + function _gte(value, params) { + return new $ZodCheckGreaterThan({ + check: "greater_than", + ...normalizeParams(params), + value, + inclusive: true + }); + } + // @__NO_SIDE_EFFECTS__ + function _multipleOf(value, params) { + return new $ZodCheckMultipleOf({ + check: "multiple_of", + ...normalizeParams(params), + value + }); + } + // @__NO_SIDE_EFFECTS__ + function _maxLength(maximum, params) { + return new $ZodCheckMaxLength({ + check: "max_length", + ...normalizeParams(params), + maximum + }); + } + // @__NO_SIDE_EFFECTS__ + function _minLength(minimum, params) { + return new $ZodCheckMinLength({ + check: "min_length", + ...normalizeParams(params), + minimum + }); + } + // @__NO_SIDE_EFFECTS__ + function _length(length, params) { + return new $ZodCheckLengthEquals({ + check: "length_equals", + ...normalizeParams(params), + length + }); + } + // @__NO_SIDE_EFFECTS__ + function _regex(pattern, params) { + return new $ZodCheckRegex({ + check: "string_format", + format: "regex", + ...normalizeParams(params), + pattern + }); + } + // @__NO_SIDE_EFFECTS__ + function _lowercase(params) { + return new $ZodCheckLowerCase({ + check: "string_format", + format: "lowercase", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _uppercase(params) { + return new $ZodCheckUpperCase({ + check: "string_format", + format: "uppercase", + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _includes(includes, params) { + return new $ZodCheckIncludes({ + check: "string_format", + format: "includes", + ...normalizeParams(params), + includes + }); + } + // @__NO_SIDE_EFFECTS__ + function _startsWith(prefix, params) { + return new $ZodCheckStartsWith({ + check: "string_format", + format: "starts_with", + ...normalizeParams(params), + prefix + }); + } + // @__NO_SIDE_EFFECTS__ + function _endsWith(suffix, params) { + return new $ZodCheckEndsWith({ + check: "string_format", + format: "ends_with", + ...normalizeParams(params), + suffix + }); + } + // @__NO_SIDE_EFFECTS__ + function _overwrite(tx) { + return new $ZodCheckOverwrite({ + check: "overwrite", + tx + }); + } + // @__NO_SIDE_EFFECTS__ + function _normalize(form) { + return /* @__PURE__ */ _overwrite((input) => input.normalize(form)); + } + // @__NO_SIDE_EFFECTS__ + function _trim() { + return /* @__PURE__ */ _overwrite((input) => input.trim()); + } + // @__NO_SIDE_EFFECTS__ + function _toLowerCase() { + return /* @__PURE__ */ _overwrite((input) => input.toLowerCase()); + } + // @__NO_SIDE_EFFECTS__ + function _toUpperCase() { + return /* @__PURE__ */ _overwrite((input) => input.toUpperCase()); + } + // @__NO_SIDE_EFFECTS__ + function _slugify() { + return /* @__PURE__ */ _overwrite((input) => slugify(input)); + } + // @__NO_SIDE_EFFECTS__ + function _array(Class, element, params) { + return new Class({ + type: "array", + element, + ...normalizeParams(params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _refine(Class, fn, _params) { + return new Class({ + type: "custom", + check: "custom", + fn, + ...normalizeParams(_params) + }); + } + // @__NO_SIDE_EFFECTS__ + function _superRefine(fn, params) { + const ch = /* @__PURE__ */ _check((payload) => { + payload.addIssue = (issue$2) => { + if (typeof issue$2 === "string") payload.issues.push(issue(issue$2, payload.value, ch._zod.def)); + else { + const _issue = issue$2; + if (_issue.fatal) _issue.continue = false; + _issue.code ?? (_issue.code = "custom"); + _issue.input ?? (_issue.input = payload.value); + _issue.inst ?? (_issue.inst = ch); + _issue.continue ?? (_issue.continue = !ch._zod.def.abort); + payload.issues.push(issue(_issue)); + } + }; + return fn(payload.value, payload); + }, params); + return ch; + } + // @__NO_SIDE_EFFECTS__ + function _check(fn, params) { + const ch = new $ZodCheck({ + check: "custom", + ...normalizeParams(params) + }); + ch._zod.check = fn; + return ch; + } + function initializeContext(params) { + let target = params?.target ?? "draft-2020-12"; + if (target === "draft-4") target = "draft-04"; + if (target === "draft-7") target = "draft-07"; + return { + processors: params.processors ?? {}, + metadataRegistry: params?.metadata ?? globalRegistry, + target, + unrepresentable: params?.unrepresentable ?? "throw", + override: params?.override ?? (() => {}), + io: params?.io ?? "output", + counter: 0, + seen: /* @__PURE__ */ new Map(), + cycles: params?.cycles ?? "ref", + reused: params?.reused ?? "inline", + external: params?.external ?? void 0 + }; + } + function process(schema, ctx, _params = { + path: [], + schemaPath: [] + }) { + var _a; + const def = schema._zod.def; + const seen = ctx.seen.get(schema); + if (seen) { + seen.count++; + if (_params.schemaPath.includes(schema)) seen.cycle = _params.path; + return seen.schema; + } + const result = { + schema: {}, + count: 1, + cycle: void 0, + path: _params.path + }; + ctx.seen.set(schema, result); + const overrideSchema = schema._zod.toJSONSchema?.(); + if (overrideSchema) result.schema = overrideSchema; + else { + const params = { + ..._params, + schemaPath: [..._params.schemaPath, schema], + path: _params.path + }; + if (schema._zod.processJSONSchema) schema._zod.processJSONSchema(ctx, result.schema, params); + else { + const _json = result.schema; + const processor = ctx.processors[def.type]; + if (!processor) throw new Error(`[toJSONSchema]: Non-representable type encountered: ${def.type}`); + processor(schema, ctx, _json, params); + } + const parent = schema._zod.parent; + if (parent) { + if (!result.ref) result.ref = parent; + process(parent, ctx, params); + ctx.seen.get(parent).isParent = true; + } + } + const meta = ctx.metadataRegistry.get(schema); + if (meta) Object.assign(result.schema, meta); + if (ctx.io === "input" && isTransforming(schema)) { + delete result.schema.examples; + delete result.schema.default; + } + if (ctx.io === "input" && "_prefault" in result.schema) (_a = result.schema).default ?? (_a.default = result.schema._prefault); + delete result.schema._prefault; + return ctx.seen.get(schema).schema; + } + function extractDefs(ctx, schema) { + const root = ctx.seen.get(schema); + if (!root) throw new Error("Unprocessed schema. This is a bug in Zod."); + const idToSchema = /* @__PURE__ */ new Map(); + for (const entry of ctx.seen.entries()) { + const id = ctx.metadataRegistry.get(entry[0])?.id; + if (id) { + const existing = idToSchema.get(id); + if (existing && existing !== entry[0]) throw new Error(`Duplicate schema id "${id}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`); + idToSchema.set(id, entry[0]); + } + } + const makeURI = (entry) => { + const defsSegment = ctx.target === "draft-2020-12" ? "$defs" : "definitions"; + if (ctx.external) { + const externalId = ctx.external.registry.get(entry[0])?.id; + const uriGenerator = ctx.external.uri ?? ((id) => id); + if (externalId) return { ref: uriGenerator(externalId) }; + const id = entry[1].defId ?? entry[1].schema.id ?? `schema${ctx.counter++}`; + entry[1].defId = id; + return { + defId: id, + ref: `${uriGenerator("__shared")}#/${defsSegment}/${id}` + }; + } + if (entry[1] === root) return { ref: "#" }; + const defUriPrefix = `#/${defsSegment}/`; + const defId = entry[1].schema.id ?? `__schema${ctx.counter++}`; + return { + defId, + ref: defUriPrefix + defId + }; + }; + const extractToDef = (entry) => { + if (entry[1].schema.$ref) return; + const seen = entry[1]; + const { ref, defId } = makeURI(entry); + seen.def = { ...seen.schema }; + if (defId) seen.defId = defId; + const schema = seen.schema; + for (const key in schema) delete schema[key]; + schema.$ref = ref; + }; + if (ctx.cycles === "throw") for (const entry of ctx.seen.entries()) { + const seen = entry[1]; + if (seen.cycle) throw new Error(`Cycle detected: #/${seen.cycle?.join("/")}/ + +Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`); + } + for (const entry of ctx.seen.entries()) { + const seen = entry[1]; + if (schema === entry[0]) { + extractToDef(entry); + continue; + } + if (ctx.external) { + const ext = ctx.external.registry.get(entry[0])?.id; + if (schema !== entry[0] && ext) { + extractToDef(entry); + continue; + } + } + if (ctx.metadataRegistry.get(entry[0])?.id) { + extractToDef(entry); + continue; + } + if (seen.cycle) { + extractToDef(entry); + continue; + } + if (seen.count > 1) { + if (ctx.reused === "ref") { + extractToDef(entry); + continue; + } + } + } + } + function finalize(ctx, schema) { + const root = ctx.seen.get(schema); + if (!root) throw new Error("Unprocessed schema. This is a bug in Zod."); + const flattenRef = (zodSchema) => { + const seen = ctx.seen.get(zodSchema); + if (seen.ref === null) return; + const schema = seen.def ?? seen.schema; + const _cached = { ...schema }; + const ref = seen.ref; + seen.ref = null; + if (ref) { + flattenRef(ref); + const refSeen = ctx.seen.get(ref); + const refSchema = refSeen.schema; + if (refSchema.$ref && (ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0")) { + schema.allOf = schema.allOf ?? []; + schema.allOf.push(refSchema); + } else Object.assign(schema, refSchema); + Object.assign(schema, _cached); + if (zodSchema._zod.parent === ref) for (const key in schema) { + if (key === "$ref" || key === "allOf") continue; + if (!(key in _cached)) delete schema[key]; + } + if (refSchema.$ref && refSeen.def) for (const key in schema) { + if (key === "$ref" || key === "allOf") continue; + if (key in refSeen.def && JSON.stringify(schema[key]) === JSON.stringify(refSeen.def[key])) delete schema[key]; + } + } + const parent = zodSchema._zod.parent; + if (parent && parent !== ref) { + flattenRef(parent); + const parentSeen = ctx.seen.get(parent); + if (parentSeen?.schema.$ref) { + schema.$ref = parentSeen.schema.$ref; + if (parentSeen.def) for (const key in schema) { + if (key === "$ref" || key === "allOf") continue; + if (key in parentSeen.def && JSON.stringify(schema[key]) === JSON.stringify(parentSeen.def[key])) delete schema[key]; + } + } + } + ctx.override({ + zodSchema, + jsonSchema: schema, + path: seen.path ?? [] + }); + }; + for (const entry of [...ctx.seen.entries()].reverse()) flattenRef(entry[0]); + const result = {}; + if (ctx.target === "draft-2020-12") result.$schema = "https://json-schema.org/draft/2020-12/schema"; + else if (ctx.target === "draft-07") result.$schema = "http://json-schema.org/draft-07/schema#"; + else if (ctx.target === "draft-04") result.$schema = "http://json-schema.org/draft-04/schema#"; + else if (ctx.target === "openapi-3.0") {} + if (ctx.external?.uri) { + const id = ctx.external.registry.get(schema)?.id; + if (!id) throw new Error("Schema is missing an `id` property"); + result.$id = ctx.external.uri(id); + } + Object.assign(result, root.def ?? root.schema); + const rootMetaId = ctx.metadataRegistry.get(schema)?.id; + if (rootMetaId !== void 0 && result.id === rootMetaId) delete result.id; + const defs = ctx.external?.defs ?? {}; + for (const entry of ctx.seen.entries()) { + const seen = entry[1]; + if (seen.def && seen.defId) { + if (seen.def.id === seen.defId) delete seen.def.id; + defs[seen.defId] = seen.def; + } + } + if (ctx.external) {} else if (Object.keys(defs).length > 0) if (ctx.target === "draft-2020-12") result.$defs = defs; + else result.definitions = defs; + try { + const finalized = JSON.parse(JSON.stringify(result)); + Object.defineProperty(finalized, "~standard", { + value: { + ...schema["~standard"], + jsonSchema: { + input: createStandardJSONSchemaMethod(schema, "input", ctx.processors), + output: createStandardJSONSchemaMethod(schema, "output", ctx.processors) + } + }, + enumerable: false, + writable: false + }); + return finalized; + } catch (_err) { + throw new Error("Error converting schema to JSON."); + } + } + function isTransforming(_schema, _ctx) { + const ctx = _ctx ?? { seen: /* @__PURE__ */ new Set() }; + if (ctx.seen.has(_schema)) return false; + ctx.seen.add(_schema); + const def = _schema._zod.def; + if (def.type === "transform") return true; + if (def.type === "array") return isTransforming(def.element, ctx); + if (def.type === "set") return isTransforming(def.valueType, ctx); + if (def.type === "lazy") return isTransforming(def.getter(), ctx); + if (def.type === "promise" || def.type === "optional" || def.type === "nonoptional" || def.type === "nullable" || def.type === "readonly" || def.type === "default" || def.type === "prefault") return isTransforming(def.innerType, ctx); + if (def.type === "intersection") return isTransforming(def.left, ctx) || isTransforming(def.right, ctx); + if (def.type === "record" || def.type === "map") return isTransforming(def.keyType, ctx) || isTransforming(def.valueType, ctx); + if (def.type === "pipe") { + if (_schema._zod.traits.has("$ZodCodec")) return true; + return isTransforming(def.in, ctx) || isTransforming(def.out, ctx); + } + if (def.type === "object") { + for (const key in def.shape) if (isTransforming(def.shape[key], ctx)) return true; + return false; + } + if (def.type === "union") { + for (const option of def.options) if (isTransforming(option, ctx)) return true; + return false; + } + if (def.type === "tuple") { + for (const item of def.items) if (isTransforming(item, ctx)) return true; + if (def.rest && isTransforming(def.rest, ctx)) return true; + return false; + } + return false; + } + /** + * Creates a toJSONSchema method for a schema instance. + * This encapsulates the logic of initializing context, processing, extracting defs, and finalizing. + */ + const createToJSONSchemaMethod = (schema, processors = {}) => (params) => { + const ctx = initializeContext({ + ...params, + processors + }); + process(schema, ctx); + extractDefs(ctx, schema); + return finalize(ctx, schema); + }; + const createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params) => { + const { libraryOptions, target } = params ?? {}; + const ctx = initializeContext({ + ...libraryOptions ?? {}, + target, + io, + processors + }); + process(schema, ctx); + extractDefs(ctx, schema); + return finalize(ctx, schema); + }; + const formatMap = { + guid: "uuid", + url: "uri", + datetime: "date-time", + json_string: "json-string", + regex: "" + }; + const stringProcessor = (schema, ctx, _json, _params) => { + const json = _json; + json.type = "string"; + const { minimum, maximum, format, patterns, contentEncoding } = schema._zod.bag; + if (typeof minimum === "number") json.minLength = minimum; + if (typeof maximum === "number") json.maxLength = maximum; + if (format) { + json.format = formatMap[format] ?? format; + if (json.format === "") delete json.format; + if (format === "time") delete json.format; + } + if (contentEncoding) json.contentEncoding = contentEncoding; + if (patterns && patterns.size > 0) { + const regexes = [...patterns]; + if (regexes.length === 1) json.pattern = regexes[0].source; + else if (regexes.length > 1) json.allOf = [...regexes.map((regex) => ({ + ...ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0" ? { type: "string" } : {}, + pattern: regex.source + }))]; + } + }; + const numberProcessor = (schema, ctx, _json, _params) => { + const json = _json; + const { minimum, maximum, format, multipleOf, exclusiveMaximum, exclusiveMinimum } = schema._zod.bag; + if (typeof format === "string" && format.includes("int")) json.type = "integer"; + else json.type = "number"; + const exMin = typeof exclusiveMinimum === "number" && exclusiveMinimum >= (minimum ?? Number.NEGATIVE_INFINITY); + const exMax = typeof exclusiveMaximum === "number" && exclusiveMaximum <= (maximum ?? Number.POSITIVE_INFINITY); + const legacy = ctx.target === "draft-04" || ctx.target === "openapi-3.0"; + if (exMin) if (legacy) { + json.minimum = exclusiveMinimum; + json.exclusiveMinimum = true; + } else json.exclusiveMinimum = exclusiveMinimum; + else if (typeof minimum === "number") json.minimum = minimum; + if (exMax) if (legacy) { + json.maximum = exclusiveMaximum; + json.exclusiveMaximum = true; + } else json.exclusiveMaximum = exclusiveMaximum; + else if (typeof maximum === "number") json.maximum = maximum; + if (typeof multipleOf === "number") json.multipleOf = multipleOf; + }; + const booleanProcessor = (_schema, _ctx, json, _params) => { + json.type = "boolean"; + }; + const neverProcessor = (_schema, _ctx, json, _params) => { + json.not = {}; + }; + const enumProcessor = (schema, _ctx, json, _params) => { + const def = schema._zod.def; + const values = getEnumValues(def.entries); + if (values.every((v) => typeof v === "number")) json.type = "number"; + if (values.every((v) => typeof v === "string")) json.type = "string"; + json.enum = values; + }; + const literalProcessor = (schema, ctx, json, _params) => { + const def = schema._zod.def; + const vals = []; + for (const val of def.values) if (val === void 0) { + if (ctx.unrepresentable === "throw") throw new Error("Literal `undefined` cannot be represented in JSON Schema"); + } else if (typeof val === "bigint") if (ctx.unrepresentable === "throw") throw new Error("BigInt literals cannot be represented in JSON Schema"); + else vals.push(Number(val)); + else vals.push(val); + if (vals.length === 0) {} else if (vals.length === 1) { + const val = vals[0]; + json.type = val === null ? "null" : typeof val; + if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") json.enum = [val]; + else json.const = val; + } else { + if (vals.every((v) => typeof v === "number")) json.type = "number"; + if (vals.every((v) => typeof v === "string")) json.type = "string"; + if (vals.every((v) => typeof v === "boolean")) json.type = "boolean"; + if (vals.every((v) => v === null)) json.type = "null"; + json.enum = vals; + } + }; + const customProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") throw new Error("Custom types cannot be represented in JSON Schema"); + }; + const transformProcessor = (_schema, ctx, _json, _params) => { + if (ctx.unrepresentable === "throw") throw new Error("Transforms cannot be represented in JSON Schema"); + }; + const arrayProcessor = (schema, ctx, _json, params) => { + const json = _json; + const def = schema._zod.def; + const { minimum, maximum } = schema._zod.bag; + if (typeof minimum === "number") json.minItems = minimum; + if (typeof maximum === "number") json.maxItems = maximum; + json.type = "array"; + json.items = process(def.element, ctx, { + ...params, + path: [...params.path, "items"] + }); + }; + const objectProcessor = (schema, ctx, _json, params) => { + const json = _json; + const def = schema._zod.def; + json.type = "object"; + json.properties = {}; + const shape = def.shape; + for (const key in shape) json.properties[key] = process(shape[key], ctx, { + ...params, + path: [ + ...params.path, + "properties", + key + ] + }); + const allKeys = new Set(Object.keys(shape)); + const requiredKeys = new Set([...allKeys].filter((key) => { + const v = def.shape[key]._zod; + if (ctx.io === "input") return v.optin === void 0; + else return v.optout === void 0; + })); + if (requiredKeys.size > 0) json.required = Array.from(requiredKeys); + if (def.catchall?._zod.def.type === "never") json.additionalProperties = false; + else if (!def.catchall) { + if (ctx.io === "output") json.additionalProperties = false; + } else if (def.catchall) json.additionalProperties = process(def.catchall, ctx, { + ...params, + path: [...params.path, "additionalProperties"] + }); + }; + const unionProcessor = (schema, ctx, json, params) => { + const def = schema._zod.def; + const isExclusive = def.inclusive === false; + const options = def.options.map((x, i) => process(x, ctx, { + ...params, + path: [ + ...params.path, + isExclusive ? "oneOf" : "anyOf", + i + ] + })); + if (isExclusive) json.oneOf = options; + else json.anyOf = options; + }; + const intersectionProcessor = (schema, ctx, json, params) => { + const def = schema._zod.def; + const a = process(def.left, ctx, { + ...params, + path: [ + ...params.path, + "allOf", + 0 + ] + }); + const b = process(def.right, ctx, { + ...params, + path: [ + ...params.path, + "allOf", + 1 + ] + }); + const isSimpleIntersection = (val) => "allOf" in val && Object.keys(val).length === 1; + json.allOf = [...isSimpleIntersection(a) ? a.allOf : [a], ...isSimpleIntersection(b) ? b.allOf : [b]]; + }; + const recordProcessor = (schema, ctx, _json, params) => { + const json = _json; + const def = schema._zod.def; + json.type = "object"; + const keyType = def.keyType; + const patterns = keyType._zod.bag?.patterns; + if (def.mode === "loose" && patterns && patterns.size > 0) { + const valueSchema = process(def.valueType, ctx, { + ...params, + path: [ + ...params.path, + "patternProperties", + "*" + ] + }); + json.patternProperties = {}; + for (const pattern of patterns) json.patternProperties[pattern.source] = valueSchema; + } else { + if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process(def.keyType, ctx, { + ...params, + path: [...params.path, "propertyNames"] + }); + json.additionalProperties = process(def.valueType, ctx, { + ...params, + path: [...params.path, "additionalProperties"] + }); + } + const keyValues = keyType._zod.values; + if (keyValues) { + const validKeyValues = [...keyValues].filter((v) => typeof v === "string" || typeof v === "number"); + if (validKeyValues.length > 0) json.required = validKeyValues; + } + }; + const nullableProcessor = (schema, ctx, json, params) => { + const def = schema._zod.def; + const inner = process(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + if (ctx.target === "openapi-3.0") { + seen.ref = def.innerType; + json.nullable = true; + } else json.anyOf = [inner, { type: "null" }]; + }; + const nonoptionalProcessor = (schema, ctx, _json, params) => { + const def = schema._zod.def; + process(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + }; + const defaultProcessor = (schema, ctx, json, params) => { + const def = schema._zod.def; + process(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + json.default = JSON.parse(JSON.stringify(def.defaultValue)); + }; + const prefaultProcessor = (schema, ctx, json, params) => { + const def = schema._zod.def; + process(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + if (ctx.io === "input") json._prefault = JSON.parse(JSON.stringify(def.defaultValue)); + }; + const catchProcessor = (schema, ctx, json, params) => { + const def = schema._zod.def; + process(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + let catchValue; + try { + catchValue = def.catchValue(void 0); + } catch { + throw new Error("Dynamic catch values are not supported in JSON Schema"); + } + json.default = catchValue; + }; + const pipeProcessor = (schema, ctx, _json, params) => { + const def = schema._zod.def; + const inIsTransform = def.in._zod.traits.has("$ZodTransform"); + const innerType = ctx.io === "input" ? inIsTransform ? def.out : def.in : def.out; + process(innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = innerType; + }; + const readonlyProcessor = (schema, ctx, json, params) => { + const def = schema._zod.def; + process(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + json.readOnly = true; + }; + const optionalProcessor = (schema, ctx, _json, params) => { + const def = schema._zod.def; + process(def.innerType, ctx, params); + const seen = ctx.seen.get(schema); + seen.ref = def.innerType; + }; + const ZodISODateTime = /*@__PURE__*/ $constructor("ZodISODateTime", (inst, def) => { + $ZodISODateTime.init(inst, def); + ZodStringFormat.init(inst, def); + }); + function datetime(params) { + return /* @__PURE__ */ _isoDateTime(ZodISODateTime, params); + } + const ZodISODate = /*@__PURE__*/ $constructor("ZodISODate", (inst, def) => { + $ZodISODate.init(inst, def); + ZodStringFormat.init(inst, def); + }); + function date(params) { + return /* @__PURE__ */ _isoDate(ZodISODate, params); + } + const ZodISOTime = /*@__PURE__*/ $constructor("ZodISOTime", (inst, def) => { + $ZodISOTime.init(inst, def); + ZodStringFormat.init(inst, def); + }); + function time(params) { + return /* @__PURE__ */ _isoTime(ZodISOTime, params); + } + const ZodISODuration = /*@__PURE__*/ $constructor("ZodISODuration", (inst, def) => { + $ZodISODuration.init(inst, def); + ZodStringFormat.init(inst, def); + }); + function duration(params) { + return /* @__PURE__ */ _isoDuration(ZodISODuration, params); + } + const initializer = (inst, issues) => { + $ZodError.init(inst, issues); + inst.name = "ZodError"; + Object.defineProperties(inst, { + format: { value: (mapper) => formatError(inst, mapper) }, + flatten: { value: (mapper) => flattenError(inst, mapper) }, + addIssue: { value: (issue) => { + inst.issues.push(issue); + inst.message = JSON.stringify(inst.issues, jsonStringifyReplacer, 2); + } }, + addIssues: { value: (issues) => { + inst.issues.push(...issues); + inst.message = JSON.stringify(inst.issues, jsonStringifyReplacer, 2); + } }, + isEmpty: { get() { + return inst.issues.length === 0; + } } + }); + }; + const ZodRealError = /*@__PURE__*/ $constructor("ZodError", initializer, { Parent: Error }); + const parse = /* @__PURE__ */ _parse(ZodRealError); + const parseAsync = /* @__PURE__ */ _parseAsync(ZodRealError); + const safeParse = /* @__PURE__ */ _safeParse(ZodRealError); + const safeParseAsync = /* @__PURE__ */ _safeParseAsync(ZodRealError); + const encode = /* @__PURE__ */ _encode(ZodRealError); + const decode = /* @__PURE__ */ _decode(ZodRealError); + const encodeAsync = /* @__PURE__ */ _encodeAsync(ZodRealError); + const decodeAsync = /* @__PURE__ */ _decodeAsync(ZodRealError); + const safeEncode = /* @__PURE__ */ _safeEncode(ZodRealError); + const safeDecode = /* @__PURE__ */ _safeDecode(ZodRealError); + const safeEncodeAsync = /* @__PURE__ */ _safeEncodeAsync(ZodRealError); + const safeDecodeAsync = /* @__PURE__ */ _safeDecodeAsync(ZodRealError); + const _installedGroups = /* @__PURE__ */ new WeakMap(); + function _installLazyMethods(inst, group, methods) { + const proto = Object.getPrototypeOf(inst); + let installed = _installedGroups.get(proto); + if (!installed) { + installed = /* @__PURE__ */ new Set(); + _installedGroups.set(proto, installed); + } + if (installed.has(group)) return; + installed.add(group); + for (const key in methods) { + const fn = methods[key]; + Object.defineProperty(proto, key, { + configurable: true, + enumerable: false, + get() { + const bound = fn.bind(this); + Object.defineProperty(this, key, { + configurable: true, + writable: true, + enumerable: true, + value: bound + }); + return bound; + }, + set(v) { + Object.defineProperty(this, key, { + configurable: true, + writable: true, + enumerable: true, + value: v + }); + } + }); + } + } + const ZodType = /*@__PURE__*/ $constructor("ZodType", (inst, def) => { + $ZodType.init(inst, def); + Object.assign(inst["~standard"], { jsonSchema: { + input: createStandardJSONSchemaMethod(inst, "input"), + output: createStandardJSONSchemaMethod(inst, "output") + } }); + inst.toJSONSchema = createToJSONSchemaMethod(inst, {}); + inst.def = def; + inst.type = def.type; + Object.defineProperty(inst, "_def", { value: def }); + inst.parse = (data, params) => parse(inst, data, params, { callee: inst.parse }); + inst.safeParse = (data, params) => safeParse(inst, data, params); + inst.parseAsync = async (data, params) => parseAsync(inst, data, params, { callee: inst.parseAsync }); + inst.safeParseAsync = async (data, params) => safeParseAsync(inst, data, params); + inst.spa = inst.safeParseAsync; + inst.encode = (data, params) => encode(inst, data, params); + inst.decode = (data, params) => decode(inst, data, params); + inst.encodeAsync = async (data, params) => encodeAsync(inst, data, params); + inst.decodeAsync = async (data, params) => decodeAsync(inst, data, params); + inst.safeEncode = (data, params) => safeEncode(inst, data, params); + inst.safeDecode = (data, params) => safeDecode(inst, data, params); + inst.safeEncodeAsync = async (data, params) => safeEncodeAsync(inst, data, params); + inst.safeDecodeAsync = async (data, params) => safeDecodeAsync(inst, data, params); + _installLazyMethods(inst, "ZodType", { + check(...chks) { + const def = this.def; + return this.clone(mergeDefs(def, { checks: [...def.checks ?? [], ...chks.map((ch) => typeof ch === "function" ? { _zod: { + check: ch, + def: { check: "custom" }, + onattach: [] + } } : ch)] }), { parent: true }); + }, + with(...chks) { + return this.check(...chks); + }, + clone(def, params) { + return clone(this, def, params); + }, + brand() { + return this; + }, + register(reg, meta) { + reg.add(this, meta); + return this; + }, + refine(check, params) { + return this.check(refine(check, params)); + }, + superRefine(refinement, params) { + return this.check(superRefine(refinement, params)); + }, + overwrite(fn) { + return this.check(/* @__PURE__ */ _overwrite(fn)); + }, + optional() { + return optional(this); + }, + exactOptional() { + return exactOptional(this); + }, + nullable() { + return nullable(this); + }, + nullish() { + return optional(nullable(this)); + }, + nonoptional(params) { + return nonoptional(this, params); + }, + array() { + return array(this); + }, + or(arg) { + return union([this, arg]); + }, + and(arg) { + return intersection(this, arg); + }, + transform(tx) { + return pipe(this, transform(tx)); + }, + default(d) { + return _default(this, d); + }, + prefault(d) { + return prefault(this, d); + }, + catch(params) { + return _catch(this, params); + }, + pipe(target) { + return pipe(this, target); + }, + readonly() { + return readonly(this); + }, + describe(description) { + const cl = this.clone(); + globalRegistry.add(cl, { description }); + return cl; + }, + meta(...args) { + if (args.length === 0) return globalRegistry.get(this); + const cl = this.clone(); + globalRegistry.add(cl, args[0]); + return cl; + }, + isOptional() { + return this.safeParse(void 0).success; + }, + isNullable() { + return this.safeParse(null).success; + }, + apply(fn) { + return fn(this); + } + }); + Object.defineProperty(inst, "description", { + get() { + return globalRegistry.get(inst)?.description; + }, + configurable: true + }); + return inst; + }); + /** @internal */ + const _ZodString = /*@__PURE__*/ $constructor("_ZodString", (inst, def) => { + $ZodString.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => stringProcessor(inst, ctx, json, params); + const bag = inst._zod.bag; + inst.format = bag.format ?? null; + inst.minLength = bag.minimum ?? null; + inst.maxLength = bag.maximum ?? null; + _installLazyMethods(inst, "_ZodString", { + regex(...args) { + return this.check(/* @__PURE__ */ _regex(...args)); + }, + includes(...args) { + return this.check(/* @__PURE__ */ _includes(...args)); + }, + startsWith(...args) { + return this.check(/* @__PURE__ */ _startsWith(...args)); + }, + endsWith(...args) { + return this.check(/* @__PURE__ */ _endsWith(...args)); + }, + min(...args) { + return this.check(/* @__PURE__ */ _minLength(...args)); + }, + max(...args) { + return this.check(/* @__PURE__ */ _maxLength(...args)); + }, + length(...args) { + return this.check(/* @__PURE__ */ _length(...args)); + }, + nonempty(...args) { + return this.check(/* @__PURE__ */ _minLength(1, ...args)); + }, + lowercase(params) { + return this.check(/* @__PURE__ */ _lowercase(params)); + }, + uppercase(params) { + return this.check(/* @__PURE__ */ _uppercase(params)); + }, + trim() { + return this.check(/* @__PURE__ */ _trim()); + }, + normalize(...args) { + return this.check(/* @__PURE__ */ _normalize(...args)); + }, + toLowerCase() { + return this.check(/* @__PURE__ */ _toLowerCase()); + }, + toUpperCase() { + return this.check(/* @__PURE__ */ _toUpperCase()); + }, + slugify() { + return this.check(/* @__PURE__ */ _slugify()); + } + }); + }); + const ZodString = /*@__PURE__*/ $constructor("ZodString", (inst, def) => { + $ZodString.init(inst, def); + _ZodString.init(inst, def); + inst.email = (params) => inst.check(/* @__PURE__ */ _email(ZodEmail, params)); + inst.url = (params) => inst.check(/* @__PURE__ */ _url(ZodURL, params)); + inst.jwt = (params) => inst.check(/* @__PURE__ */ _jwt(ZodJWT, params)); + inst.emoji = (params) => inst.check(/* @__PURE__ */ _emoji(ZodEmoji, params)); + inst.guid = (params) => inst.check(/* @__PURE__ */ _guid(ZodGUID, params)); + inst.uuid = (params) => inst.check(/* @__PURE__ */ _uuid(ZodUUID, params)); + inst.uuidv4 = (params) => inst.check(/* @__PURE__ */ _uuidv4(ZodUUID, params)); + inst.uuidv6 = (params) => inst.check(/* @__PURE__ */ _uuidv6(ZodUUID, params)); + inst.uuidv7 = (params) => inst.check(/* @__PURE__ */ _uuidv7(ZodUUID, params)); + inst.nanoid = (params) => inst.check(/* @__PURE__ */ _nanoid(ZodNanoID, params)); + inst.guid = (params) => inst.check(/* @__PURE__ */ _guid(ZodGUID, params)); + inst.cuid = (params) => inst.check(/* @__PURE__ */ _cuid(ZodCUID, params)); + inst.cuid2 = (params) => inst.check(/* @__PURE__ */ _cuid2(ZodCUID2, params)); + inst.ulid = (params) => inst.check(/* @__PURE__ */ _ulid(ZodULID, params)); + inst.base64 = (params) => inst.check(/* @__PURE__ */ _base64(ZodBase64, params)); + inst.base64url = (params) => inst.check(/* @__PURE__ */ _base64url(ZodBase64URL, params)); + inst.xid = (params) => inst.check(/* @__PURE__ */ _xid(ZodXID, params)); + inst.ksuid = (params) => inst.check(/* @__PURE__ */ _ksuid(ZodKSUID, params)); + inst.ipv4 = (params) => inst.check(/* @__PURE__ */ _ipv4(ZodIPv4, params)); + inst.ipv6 = (params) => inst.check(/* @__PURE__ */ _ipv6(ZodIPv6, params)); + inst.cidrv4 = (params) => inst.check(/* @__PURE__ */ _cidrv4(ZodCIDRv4, params)); + inst.cidrv6 = (params) => inst.check(/* @__PURE__ */ _cidrv6(ZodCIDRv6, params)); + inst.e164 = (params) => inst.check(/* @__PURE__ */ _e164(ZodE164, params)); + inst.datetime = (params) => inst.check(datetime(params)); + inst.date = (params) => inst.check(date(params)); + inst.time = (params) => inst.check(time(params)); + inst.duration = (params) => inst.check(duration(params)); + }); + function string(params) { + return /* @__PURE__ */ _string(ZodString, params); + } + const ZodStringFormat = /*@__PURE__*/ $constructor("ZodStringFormat", (inst, def) => { + $ZodStringFormat.init(inst, def); + _ZodString.init(inst, def); + }); + const ZodEmail = /*@__PURE__*/ $constructor("ZodEmail", (inst, def) => { + $ZodEmail.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodGUID = /*@__PURE__*/ $constructor("ZodGUID", (inst, def) => { + $ZodGUID.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodUUID = /*@__PURE__*/ $constructor("ZodUUID", (inst, def) => { + $ZodUUID.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodURL = /*@__PURE__*/ $constructor("ZodURL", (inst, def) => { + $ZodURL.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodEmoji = /*@__PURE__*/ $constructor("ZodEmoji", (inst, def) => { + $ZodEmoji.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodNanoID = /*@__PURE__*/ $constructor("ZodNanoID", (inst, def) => { + $ZodNanoID.init(inst, def); + ZodStringFormat.init(inst, def); + }); + /** + * @deprecated CUID v1 is deprecated by its authors due to information leakage + * (timestamps embedded in the id). Use {@link ZodCUID2} instead. + * See https://github.com/paralleldrive/cuid. + */ + const ZodCUID = /*@__PURE__*/ $constructor("ZodCUID", (inst, def) => { + $ZodCUID.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodCUID2 = /*@__PURE__*/ $constructor("ZodCUID2", (inst, def) => { + $ZodCUID2.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodULID = /*@__PURE__*/ $constructor("ZodULID", (inst, def) => { + $ZodULID.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodXID = /*@__PURE__*/ $constructor("ZodXID", (inst, def) => { + $ZodXID.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodKSUID = /*@__PURE__*/ $constructor("ZodKSUID", (inst, def) => { + $ZodKSUID.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodIPv4 = /*@__PURE__*/ $constructor("ZodIPv4", (inst, def) => { + $ZodIPv4.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodIPv6 = /*@__PURE__*/ $constructor("ZodIPv6", (inst, def) => { + $ZodIPv6.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodCIDRv4 = /*@__PURE__*/ $constructor("ZodCIDRv4", (inst, def) => { + $ZodCIDRv4.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodCIDRv6 = /*@__PURE__*/ $constructor("ZodCIDRv6", (inst, def) => { + $ZodCIDRv6.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodBase64 = /*@__PURE__*/ $constructor("ZodBase64", (inst, def) => { + $ZodBase64.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodBase64URL = /*@__PURE__*/ $constructor("ZodBase64URL", (inst, def) => { + $ZodBase64URL.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodE164 = /*@__PURE__*/ $constructor("ZodE164", (inst, def) => { + $ZodE164.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodJWT = /*@__PURE__*/ $constructor("ZodJWT", (inst, def) => { + $ZodJWT.init(inst, def); + ZodStringFormat.init(inst, def); + }); + const ZodNumber = /*@__PURE__*/ $constructor("ZodNumber", (inst, def) => { + $ZodNumber.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => numberProcessor(inst, ctx, json, params); + _installLazyMethods(inst, "ZodNumber", { + gt(value, params) { + return this.check(/* @__PURE__ */ _gt(value, params)); + }, + gte(value, params) { + return this.check(/* @__PURE__ */ _gte(value, params)); + }, + min(value, params) { + return this.check(/* @__PURE__ */ _gte(value, params)); + }, + lt(value, params) { + return this.check(/* @__PURE__ */ _lt(value, params)); + }, + lte(value, params) { + return this.check(/* @__PURE__ */ _lte(value, params)); + }, + max(value, params) { + return this.check(/* @__PURE__ */ _lte(value, params)); + }, + int(params) { + return this.check(int(params)); + }, + safe(params) { + return this.check(int(params)); + }, + positive(params) { + return this.check(/* @__PURE__ */ _gt(0, params)); + }, + nonnegative(params) { + return this.check(/* @__PURE__ */ _gte(0, params)); + }, + negative(params) { + return this.check(/* @__PURE__ */ _lt(0, params)); + }, + nonpositive(params) { + return this.check(/* @__PURE__ */ _lte(0, params)); + }, + multipleOf(value, params) { + return this.check(/* @__PURE__ */ _multipleOf(value, params)); + }, + step(value, params) { + return this.check(/* @__PURE__ */ _multipleOf(value, params)); + }, + finite() { + return this; + } + }); + const bag = inst._zod.bag; + inst.minValue = Math.max(bag.minimum ?? Number.NEGATIVE_INFINITY, bag.exclusiveMinimum ?? Number.NEGATIVE_INFINITY) ?? null; + inst.maxValue = Math.min(bag.maximum ?? Number.POSITIVE_INFINITY, bag.exclusiveMaximum ?? Number.POSITIVE_INFINITY) ?? null; + inst.isInt = (bag.format ?? "").includes("int") || Number.isSafeInteger(bag.multipleOf ?? .5); + inst.isFinite = true; + inst.format = bag.format ?? null; + }); + function number(params) { + return /* @__PURE__ */ _number(ZodNumber, params); + } + const ZodNumberFormat = /*@__PURE__*/ $constructor("ZodNumberFormat", (inst, def) => { + $ZodNumberFormat.init(inst, def); + ZodNumber.init(inst, def); + }); + function int(params) { + return /* @__PURE__ */ _int(ZodNumberFormat, params); + } + const ZodBoolean = /*@__PURE__*/ $constructor("ZodBoolean", (inst, def) => { + $ZodBoolean.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => booleanProcessor(inst, ctx, json, params); + }); + function boolean(params) { + return /* @__PURE__ */ _boolean(ZodBoolean, params); + } + const ZodUnknown = /*@__PURE__*/ $constructor("ZodUnknown", (inst, def) => { + $ZodUnknown.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => void 0; + }); + function unknown() { + return /* @__PURE__ */ _unknown(ZodUnknown); + } + const ZodNever = /*@__PURE__*/ $constructor("ZodNever", (inst, def) => { + $ZodNever.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => neverProcessor(inst, ctx, json, params); + }); + function never(params) { + return /* @__PURE__ */ _never(ZodNever, params); + } + const ZodArray = /*@__PURE__*/ $constructor("ZodArray", (inst, def) => { + $ZodArray.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => arrayProcessor(inst, ctx, json, params); + inst.element = def.element; + _installLazyMethods(inst, "ZodArray", { + min(n, params) { + return this.check(/* @__PURE__ */ _minLength(n, params)); + }, + nonempty(params) { + return this.check(/* @__PURE__ */ _minLength(1, params)); + }, + max(n, params) { + return this.check(/* @__PURE__ */ _maxLength(n, params)); + }, + length(n, params) { + return this.check(/* @__PURE__ */ _length(n, params)); + }, + unwrap() { + return this.element; + } + }); + }); + function array(element, params) { + return /* @__PURE__ */ _array(ZodArray, element, params); + } + const ZodObject = /*@__PURE__*/ $constructor("ZodObject", (inst, def) => { + $ZodObjectJIT.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => objectProcessor(inst, ctx, json, params); + defineLazy(inst, "shape", () => { + return def.shape; + }); + _installLazyMethods(inst, "ZodObject", { + keyof() { + return _enum(Object.keys(this._zod.def.shape)); + }, + catchall(catchall) { + return this.clone({ + ...this._zod.def, + catchall + }); + }, + passthrough() { + return this.clone({ + ...this._zod.def, + catchall: unknown() + }); + }, + loose() { + return this.clone({ + ...this._zod.def, + catchall: unknown() + }); + }, + strict() { + return this.clone({ + ...this._zod.def, + catchall: never() + }); + }, + strip() { + return this.clone({ + ...this._zod.def, + catchall: void 0 + }); + }, + extend(incoming) { + return extend(this, incoming); + }, + safeExtend(incoming) { + return safeExtend(this, incoming); + }, + merge(other) { + return merge(this, other); + }, + pick(mask) { + return pick(this, mask); + }, + omit(mask) { + return omit(this, mask); + }, + partial(...args) { + return partial(ZodOptional, this, args[0]); + }, + required(...args) { + return required(ZodNonOptional, this, args[0]); + } + }); + }); + function object(shape, params) { + return new ZodObject({ + type: "object", + shape: shape ?? {}, + ...normalizeParams(params) + }); + } + const ZodUnion = /*@__PURE__*/ $constructor("ZodUnion", (inst, def) => { + $ZodUnion.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); + inst.options = def.options; + }); + function union(options, params) { + return new ZodUnion({ + type: "union", + options, + ...normalizeParams(params) + }); + } + const ZodIntersection = /*@__PURE__*/ $constructor("ZodIntersection", (inst, def) => { + $ZodIntersection.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => intersectionProcessor(inst, ctx, json, params); + }); + function intersection(left, right) { + return new ZodIntersection({ + type: "intersection", + left, + right + }); + } + const ZodRecord = /*@__PURE__*/ $constructor("ZodRecord", (inst, def) => { + $ZodRecord.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => recordProcessor(inst, ctx, json, params); + inst.keyType = def.keyType; + inst.valueType = def.valueType; + }); + function record(keyType, valueType, params) { + if (!valueType || !valueType._zod) return new ZodRecord({ + type: "record", + keyType: string(), + valueType: keyType, + ...normalizeParams(valueType) + }); + return new ZodRecord({ + type: "record", + keyType, + valueType, + ...normalizeParams(params) + }); + } + const ZodEnum = /*@__PURE__*/ $constructor("ZodEnum", (inst, def) => { + $ZodEnum.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => enumProcessor(inst, ctx, json, params); + inst.enum = def.entries; + inst.options = Object.values(def.entries); + const keys = new Set(Object.keys(def.entries)); + inst.extract = (values, params) => { + const newEntries = {}; + for (const value of values) if (keys.has(value)) newEntries[value] = def.entries[value]; + else throw new Error(`Key ${value} not found in enum`); + return new ZodEnum({ + ...def, + checks: [], + ...normalizeParams(params), + entries: newEntries + }); + }; + inst.exclude = (values, params) => { + const newEntries = { ...def.entries }; + for (const value of values) if (keys.has(value)) delete newEntries[value]; + else throw new Error(`Key ${value} not found in enum`); + return new ZodEnum({ + ...def, + checks: [], + ...normalizeParams(params), + entries: newEntries + }); + }; + }); + function _enum(values, params) { + return new ZodEnum({ + type: "enum", + entries: Array.isArray(values) ? Object.fromEntries(values.map((v) => [v, v])) : values, + ...normalizeParams(params) + }); + } + const ZodLiteral = /*@__PURE__*/ $constructor("ZodLiteral", (inst, def) => { + $ZodLiteral.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => literalProcessor(inst, ctx, json, params); + inst.values = new Set(def.values); + Object.defineProperty(inst, "value", { get() { + if (def.values.length > 1) throw new Error("This schema contains multiple valid literal values. Use `.values` instead."); + return def.values[0]; + } }); + }); + function literal(value, params) { + return new ZodLiteral({ + type: "literal", + values: Array.isArray(value) ? value : [value], + ...normalizeParams(params) + }); + } + const ZodTransform = /*@__PURE__*/ $constructor("ZodTransform", (inst, def) => { + $ZodTransform.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => transformProcessor(inst, ctx, json, params); + inst._zod.parse = (payload, _ctx) => { + if (_ctx.direction === "backward") throw new $ZodEncodeError(inst.constructor.name); + payload.addIssue = (issue$1) => { + if (typeof issue$1 === "string") payload.issues.push(issue(issue$1, payload.value, def)); + else { + const _issue = issue$1; + if (_issue.fatal) _issue.continue = false; + _issue.code ?? (_issue.code = "custom"); + _issue.input ?? (_issue.input = payload.value); + _issue.inst ?? (_issue.inst = inst); + payload.issues.push(issue(_issue)); + } + }; + const output = def.transform(payload.value, payload); + if (output instanceof Promise) return output.then((output) => { + payload.value = output; + payload.fallback = true; + return payload; + }); + payload.value = output; + payload.fallback = true; + return payload; + }; + }); + function transform(fn) { + return new ZodTransform({ + type: "transform", + transform: fn + }); + } + const ZodOptional = /*@__PURE__*/ $constructor("ZodOptional", (inst, def) => { + $ZodOptional.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => optionalProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + }); + function optional(innerType) { + return new ZodOptional({ + type: "optional", + innerType + }); + } + const ZodExactOptional = /*@__PURE__*/ $constructor("ZodExactOptional", (inst, def) => { + $ZodExactOptional.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => optionalProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + }); + function exactOptional(innerType) { + return new ZodExactOptional({ + type: "optional", + innerType + }); + } + const ZodNullable = /*@__PURE__*/ $constructor("ZodNullable", (inst, def) => { + $ZodNullable.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => nullableProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + }); + function nullable(innerType) { + return new ZodNullable({ + type: "nullable", + innerType + }); + } + const ZodDefault = /*@__PURE__*/ $constructor("ZodDefault", (inst, def) => { + $ZodDefault.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => defaultProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + inst.removeDefault = inst.unwrap; + }); + function _default(innerType, defaultValue) { + return new ZodDefault({ + type: "default", + innerType, + get defaultValue() { + return typeof defaultValue === "function" ? defaultValue() : shallowClone(defaultValue); + } + }); + } + const ZodPrefault = /*@__PURE__*/ $constructor("ZodPrefault", (inst, def) => { + $ZodPrefault.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => prefaultProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + }); + function prefault(innerType, defaultValue) { + return new ZodPrefault({ + type: "prefault", + innerType, + get defaultValue() { + return typeof defaultValue === "function" ? defaultValue() : shallowClone(defaultValue); + } + }); + } + const ZodNonOptional = /*@__PURE__*/ $constructor("ZodNonOptional", (inst, def) => { + $ZodNonOptional.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => nonoptionalProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + }); + function nonoptional(innerType, params) { + return new ZodNonOptional({ + type: "nonoptional", + innerType, + ...normalizeParams(params) + }); + } + const ZodCatch = /*@__PURE__*/ $constructor("ZodCatch", (inst, def) => { + $ZodCatch.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => catchProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + inst.removeCatch = inst.unwrap; + }); + function _catch(innerType, catchValue) { + return new ZodCatch({ + type: "catch", + innerType, + catchValue: typeof catchValue === "function" ? catchValue : () => catchValue + }); + } + const ZodPipe = /*@__PURE__*/ $constructor("ZodPipe", (inst, def) => { + $ZodPipe.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => pipeProcessor(inst, ctx, json, params); + inst.in = def.in; + inst.out = def.out; + }); + function pipe(in_, out) { + return new ZodPipe({ + type: "pipe", + in: in_, + out + }); + } + const ZodReadonly = /*@__PURE__*/ $constructor("ZodReadonly", (inst, def) => { + $ZodReadonly.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => readonlyProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + }); + function readonly(innerType) { + return new ZodReadonly({ + type: "readonly", + innerType + }); + } + const ZodCustom = /*@__PURE__*/ $constructor("ZodCustom", (inst, def) => { + $ZodCustom.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => customProcessor(inst, ctx, json, params); + }); + function refine(fn, _params = {}) { + return /* @__PURE__ */ _refine(ZodCustom, fn, _params); + } + function superRefine(fn, params) { + return /* @__PURE__ */ _superRefine(fn, params); + } + /** Local time string: `YYYY-MM-DD`, or `YYYY-MM-DDTHH:mm` where a clock applies. */ + const LocalTimeSchema = string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/, "expected YYYY-MM-DD or YYYY-MM-DDTHH:mm"); + /** + * Supported recurrence subset. Deliberately narrower than RRULE: rules outside + * it (monthly, yearly, BYMONTHDAY, BYSETPOS) are not represented here — the + * importer keeps their original RRULE text in `extensions` and degrades the + * event to a single occurrence instead of dropping it silently. + */ + const RecurrenceSchema = object({ + freq: literal("weekly"), + interval: number().int().positive().optional(), + count: number().int().positive().optional(), + until: LocalTimeSchema.optional(), + byDay: array(_enum([ + "mo", + "tu", + "we", + "th", + "fr", + "sa", + "su" + ])).optional() + }).strict(); + const OverridesSchema = array(object({ + date: string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD"), + title: string().min(1).optional(), + start: LocalTimeSchema.optional(), + end: LocalTimeSchema.optional(), + location: string().optional(), + description: string().optional() + }).strict().refine((value) => Object.keys(value).length > 1, "an override must change at least one field")).refine((list) => new Set(list.map((entry) => entry.date)).size === list.length, "each date may be overridden once"); + /** + * Fields every event-shaped value carries — the stored record and the + * occurrence handed to a view. Shared so the two cannot drift apart. + */ + const EventFields = { + uid: string().min(1), + title: string().min(1), + start: LocalTimeSchema, + end: LocalTimeSchema.optional(), + location: string().optional(), + description: string().optional(), + recurrence: RecurrenceSchema.optional(), + source: string().optional(), + extensions: record(string(), unknown()).optional() + }; + /** Durable shape of one event: the shared fields plus series bookkeeping. */ + const EventSchema = object({ + ...EventFields, + exceptions: array(LocalTimeSchema).optional(), + overrides: OverridesSchema.optional() + }).strict(); + EventSchema.extend(Object.fromEntries(["courseId"].map((field) => [field, unknown().optional()]))); + const snapshot = object({ + schemaVersion: number().int().positive(), + events: array(EventSchema) + }).strict(); + const occurrence = object({ + ...EventFields, + occurrenceId: string().min(1), + occurrenceDate: string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD"), + overridden: boolean().optional() + }).strict(); + const occurrences = object({ + schemaVersion: number().int().positive(), + occurrences: array(occurrence) + }).strict(); + /** Bounds of a range query; either side may be omitted. */ + const rangeSchema = object({ + from: LocalTimeSchema.optional(), + to: LocalTimeSchema.optional() + }).strict(); + /** A day of a series, as the occurrence itself names it. */ + const dateSchema = string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD"); + /** Calendar text to import, and how it should land. */ + const importInput = object({ + text: string().min(1), + source: string().min(1).optional(), + replace: boolean().optional() + }).strict(); + /** What an import wrote, what it removed, and what it could not use. */ + const importOutcome = object({ + events: number().int().nonnegative(), + removedEvents: number().int().nonnegative(), + skipped: array(object({ + uid: string().optional(), + reason: string() + }).strict()), + degraded: array(object({ + uid: string().optional(), + reason: string().optional(), + rrule: string().optional() + }).strict()) + }).strict(); + /** What to call the exported calendar. */ + const exportInput = object({ name: string().min(1).optional() }).strict(); + const exportOutcome = object({ text: string() }).strict(); + /** One date of one series. */ + const occurrenceInput = object({ + uid: string().min(1), + date: dateSchema + }).strict(); + /** The changeable fields of one occurrence; `date` is the series' own date. */ + const overrideInput = object({ + uid: string().min(1), + date: dateSchema, + patch: object({ + title: string().min(1).optional(), + start: LocalTimeSchema.optional(), + end: LocalTimeSchema.optional(), + location: string().optional(), + description: string().optional() + }).strict() + }).strict(); + const uidInput = object({ uid: string().min(1) }).strict(); + const removalOutcome = object({ removed: boolean() }).strict(); + /** Wrap a zod schema in the descriptor shape the gateway reads. */ + const result = (typeSymbol, schema) => Object.freeze({ + mode: "strict", + create() { + return this.schema; + }, + typeSymbol, + schema + }); + /** One JSON argument of one remote method, named the way the method reads it. */ + const jsonParameter = (method, name, schema) => Object.freeze({ + name, + wire: name, + source: "json", + codec: Object.freeze({ + mode: "strict", + create() { + return this.schema; + }, + typeSymbol: `@eduwork/dsh-calendar#calendar/${method}:${name}`, + schema + }) + }); + const rangeParameter = jsonParameter("occurrences", "range", rangeSchema); + const importParameter = jsonParameter("importIcs", "input", importInput); + const exportParameter = jsonParameter("exportIcs", "options", exportInput); + const overrideParameter = jsonParameter("applyOverride", "input", overrideInput); + const removeOverrideParameter = jsonParameter("removeOverride", "input", occurrenceInput); + const cancelParameter = jsonParameter("cancelOccurrence", "input", occurrenceInput); + const restoreParameter = jsonParameter("restoreOccurrence", "input", occurrenceInput); + const deleteEventParameter = jsonParameter("deleteEvent", "input", uidInput); + const eventParameter = jsonParameter("putEvent", "event", EventSchema); + const snapshotResult = result("@eduwork/dsh-calendar#CalendarSnapshot", snapshot); + const occurrencesResult = result("@eduwork/dsh-calendar#CalendarOccurrences", occurrences); + const importResult = result("@eduwork/dsh-calendar#CalendarImport", importOutcome); + const exportResult = result("@eduwork/dsh-calendar#CalendarExport", exportOutcome); + const eventResult = result("@eduwork/dsh-calendar#CalendarEvent", EventSchema); + const removalResult = result("@eduwork/dsh-calendar#CalendarRemoval", removalOutcome); + const pkg = "@eduwork/dsh-calendar"; + /** + * @param method - Method name, which is also the service's own method name. + * @param line - Line of the method in `lib/index.js`. + * @param parameters - One entry per argument, in call order. + * @param result - Result type of the method. + * @returns a frozen descriptor. + */ + const descriptor = (method, line, parameters, result) => Object.freeze({ + id: `${pkg}#calendar/${method}`, + service: "calendar", + namespace: "calendar", + method, + invocation: { kind: "direct" }, + parameters: Object.freeze(parameters), + result, + sourceLocation: { + file: "dsh-plugins/calendar/lib/index.js", + line, + column: 3 + } + }); + var typert_remote_client_default = { + package: "@eduwork/dsh-calendar", + descriptors: Object.freeze([ + descriptor("snapshot", 47, [], snapshotResult), + descriptor("occurrences", 66, [rangeParameter], occurrencesResult), + descriptor("importIcs", 82, [importParameter], importResult), + descriptor("exportIcs", 94, [exportParameter], exportResult), + descriptor("deleteEvent", 123, [deleteEventParameter], removalResult), + descriptor("applyOverride", 133, [overrideParameter], eventResult), + descriptor("removeOverride", 142, [removeOverrideParameter], eventResult), + descriptor("cancelOccurrence", 151, [cancelParameter], eventResult), + descriptor("restoreOccurrence", 160, [restoreParameter], eventResult), + descriptor("putEvent", 114, [eventParameter], eventResult) + ]) + }; + /** Minutes in a whole day; also the exclusive upper bound of the axis. */ + const MINUTES_IN_DAY = 1440; + const pad2$1 = (value) => String(value).padStart(2, "0"); + /** Local midnight of a `YYYY-MM-DD` date. */ + function parseDay(date) { + const [year, month, day] = date.split("-").map(Number); + return new Date(year, month - 1, day); + } + /** Whole days from one local date to another. */ + function dayGap(from, to) { + return Math.round((parseDay(to) - parseDay(from)) / 864e5); + } + /** The local `YYYY-MM-DD` date `days` away. */ + function shiftDay(date, days) { + const value = parseDay(date); + value.setDate(value.getDate() + days); + return `${value.getFullYear()}-${pad2$1(value.getMonth() + 1)}-${pad2$1(value.getDate())}`; + } + /** + * Minutes from local midnight of a local time string. + * @param value - `YYYY-MM-DDTHH:mm`, or anything else. + * @returns minutes in `[0, 1440)`, or `undefined` when the value carries no clock. + */ + function minutesOf(value) { + if (typeof value !== "string" || value.length < 16) return void 0; + const hours = Number(value.slice(11, 13)); + const minutes = Number(value.slice(14, 16)); + if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return void 0; + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return void 0; + return hours * 60 + minutes; + } + /** + * The end that keeps an entry's length when only its start moved. + * + * An edit form that opens with both fields filled hands back an end that never + * moved, so saving a new start on its own would keep the old clock time and can + * finish before it begins. Carrying the span instead keeps a 90-minute entry 90 + * minutes long, and follows a start that crosses into another day. + * @param start - New local start, `YYYY-MM-DDTHH:mm`. + * @param from - Start the entry had. + * @param to - End the entry had. + * @returns Local end that keeps `to - from`, or `undefined` when a value has no clock. + */ + function shiftedEnd(start, from, to) { + const startMinutes = minutesOf(start); + const fromMinutes = minutesOf(from); + const toMinutes = minutesOf(to); + if (startMinutes === void 0 || fromMinutes === void 0 || toMinutes === void 0) return void 0; + const at = startMinutes + (dayGap(from.slice(0, 10), to.slice(0, 10)) * MINUTES_IN_DAY + (toMinutes - fromMinutes)); + const date = shiftDay(start.slice(0, 10), Math.floor(at / MINUTES_IN_DAY)); + const clock = (at % MINUTES_IN_DAY + MINUTES_IN_DAY) % MINUTES_IN_DAY; + return `${date}T${pad2$1(Math.floor(clock / 60))}:${pad2$1(clock % 60)}`; + } + /** + * Vertical bounds of the axis, in minutes. + * + * The default working day is always visible and the data may widen it, padded by + * half an hour and rounded out to whole hours. Padding only applies to a side the + * data actually leaves, so a week whose first lesson is at 08:00 still starts at + * 08:00 instead of drifting to 07:00 to make room for padding it does not need. + * Keeping a floor means the axis does not jump between weeks while the reader is + * stepping through them. + * @param occurrences - Occurrences of the visible days; whole-day ones carry no clock to place. + * @param options - `from`/`to` are the guaranteed bounds, `pad` the slack, `step` the rounding. + * @returns `{ start, end }` in minutes, `start < end`. + */ + function dayWindow(occurrences, options = {}) { + const from = options.from ?? 480; + const to = options.to ?? 1200; + const pad = options.pad ?? 30; + const step = options.step ?? 60; + let first = Infinity; + let last = -Infinity; + for (const occurrence of occurrences ?? []) { + const start = minutesOf(occurrence?.start); + if (start === void 0) continue; + const end = minutesOf(occurrence?.end) ?? start + 45; + first = Math.min(first, start); + last = Math.max(last, end); + } + if (!Number.isFinite(first)) return { + start: from, + end: to + }; + return { + start: first < from ? Math.max(0, floorTo(first - pad, step)) : from, + end: last > to ? Math.min(MINUTES_IN_DAY, ceilTo(last + pad, step)) : to + }; + } + /** Round down to a `step` grid, counting from midnight. */ + function floorTo(minutes, step) { + return Math.floor(minutes / step) * step; + } + /** Round up to a `step` grid, counting from midnight. */ + function ceilTo(minutes, step) { + return Math.ceil(minutes / step) * step; + } + /** + * Place one day's timed occurrences. + * + * Blocks that overlap form a cluster and split the day's width between them; + * a block starting no earlier than the last end of the current cluster opens a + * new one and gets the full width, which is what keeps back-to-back lessons + * readable. Lanes inside a cluster are assigned greedily: a block takes the + * first lane free at its start. + * @param occurrences - Occurrences of one day; entries without a clock are skipped. + * @param options - `minHeight` keeps a very short block clickable, in minutes. + * @returns one entry per timed occurrence, in start order, as + * `{ occurrence, start, end, top, height, lane, lanes }` in minutes. + */ + function layoutDay(occurrences, options = {}) { + const minHeight = options.minHeight ?? 22; + const items = []; + for (const occurrence of occurrences ?? []) { + const start = minutesOf(occurrence?.start); + if (start === void 0) continue; + const rawEnd = minutesOf(occurrence?.end); + const end = rawEnd === void 0 ? start + 45 : rawEnd > start ? rawEnd : rawEnd < start ? MINUTES_IN_DAY : start + 1; + items.push({ + occurrence, + start, + end, + lane: 0 + }); + } + items.sort((left, right) => left.start - right.start || right.end - left.end || String(left.occurrence?.occurrenceId ?? "").localeCompare(String(right.occurrence?.occurrenceId ?? ""))); + const placed = []; + let cluster = []; + let clusterEnd = -Infinity; + const close = () => { + if (cluster.length === 0) return; + const laneEnds = []; + for (const item of cluster) { + let lane = laneEnds.findIndex((end) => end <= item.start); + if (lane === -1) { + lane = laneEnds.length; + laneEnds.push(item.end); + } else laneEnds[lane] = item.end; + item.lane = lane; + } + for (const item of cluster) placed.push({ + occurrence: item.occurrence, + start: item.start, + end: item.end, + top: item.start, + height: Math.max(minHeight, item.end - item.start), + lane: item.lane, + lanes: laneEnds.length + }); + cluster = []; + }; + for (const item of items) { + if (cluster.length > 0 && item.start >= clusterEnd) close(); + cluster.push(item); + clusterEnd = cluster.length === 1 ? item.end : Math.max(clusterEnd, item.end); + } + close(); + return placed; + } + /** + * Place a whole week, one day at a time. + * + * The date is part of a block's identity: two lessons that both start at 08:00 + * but fall on different weekdays are not in conflict and must not share lanes. + * Callers hand in whatever the range query returned, so the grouping happens + * here instead of being something every caller has to remember to do. + * @param occurrences - Occurrences of the visible days. + * @param options - forwarded to `layoutDay`. + * @returns a `Map` from `YYYY-MM-DD` to that day's placed entries. + */ + function layoutWeek(occurrences, options = {}) { + const days = /* @__PURE__ */ new Map(); + for (const occurrence of occurrences ?? []) { + const date = String(occurrence?.start ?? "").slice(0, 10); + if (date.length !== 10) continue; + const list = days.get(date); + if (list) list.push(occurrence); + else days.set(date, [occurrence]); + } + const placed = /* @__PURE__ */ new Map(); + for (const [date, list] of days) placed.set(date, layoutDay(list, options)); + return placed; + } + /** + * Resolve one icon from the loaded primitives package. + * @param names - the names it may be exported under, older generation first. + * @param size - the size to draw at when the name does not already fix it. + */ + const icon = (names, size) => { + const artwork = names.map((name) => _deepseek_ai_dsh_client_ui_primitives[name]).find(Boolean); + return artwork ? (props = {}) => react.default.createElement(artwork, { + size, + ...props + }) : () => null; + }; + const IconAlarmClock = icon(["IconAlarmClockOutline16", "IconAlarmClockOutlineRegular"], 16); + const IconArchive = icon(["IconArchiveOutline20", "IconArchiveOutlineRegular"], 20); + const IconCheck = icon(["IconCheckOutline16", "IconCheckOutlineRegular"], 16); + const IconChevronDown = icon(["IconChevronDownOutline14", "IconChevronDownOutlineRegular"], 14); + const IconChevronLeft = icon(["IconChevronLeftOutline14", "IconChevronLeftOutlineRegular"], 14); + const IconChevronRight = icon(["IconChevronRightOutline14", "IconChevronRightOutlineRegular"], 14); + const IconClock = icon(["IconClockOutline16", "IconClockOutlineRegular"], 16); + const IconCopy = icon(["IconCopyOutline16", "IconCopyOutlineRegular"], 16); + const IconDownload = icon(["IconDownloadOutline16", "IconDownloadOutlineRegular"], 16); + const IconFolderOpen = icon(["IconFolderOpenOutline16", "IconFolderOpenOutlineRegular"], 16); + const IconPlus = icon(["IconPlusOutline16", "IconPlusOutlineRegular"], 16); + const IconRefresh = icon(["IconRefreshOutline16", "IconRefreshOutlineRegular"], 16); + const IconRefreshSmall = icon(["IconRefreshOutline14", "IconRefreshOutlineRegular"], 14); + const IconTrash = icon(["IconTrashOutline16", "IconTrashOutlineRegular"], 16); + const IconWarning = icon(["IconWarningOutline16", "IconWarningOutlineRegular"], 16); + const inject = ["slots", "remote"]; + const PANEL_ID = "calendar"; + const WEEKDAYS = [ + "周一", + "周二", + "周三", + "周四", + "周五", + "周六", + "周日" + ]; + /** Recurrence weekday codes, for the summary line of a stored series. */ + const WEEKDAY_BY_CODE = { + mo: "周一", + tu: "周二", + we: "周三", + th: "周四", + fr: "周五", + sa: "周六", + su: "周日" + }; + /** Source an import writes unless the reader says otherwise; the scope that is replaced. */ + const DEFAULT_SOURCE = "ics"; + /** + * The `uid` of an entry created here. The caller owns it — the host stores the + * record it is handed — so it has to be one no import would ever produce. + */ + const newUid = () => globalThis.crypto?.randomUUID ? `manual-${globalThis.crypto.randomUUID()}` : `manual-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const PX_PER_MINUTE = 48 / 60; + /** Width of the hour gutter, in pixels. */ + const GUTTER = 54; + /** + * The axis is a whole day, as in the calendars this view follows. A fixed + * working day left the lower half of a tall window empty; with the whole day on + * the axis the grid always reaches the bottom, and the view scrolls to where + * this week's entries are instead of cropping them to office hours. + */ + const DAY_MINUTES = 1440; + const PAGE_BACKGROUND = "var(--dsw-alias-bg-base, #fff)"; + const PRIMARY = "var(--dsw-alias-label-primary, #222)"; + const SECONDARY = "var(--dsw-alias-label-secondary, #666)"; + const TERTIARY = "var(--dsw-alias-label-tertiary, #888)"; + const HAIRLINE = ".5px solid var(--dsw-alias-border-l1, #00000014)"; + const CARD_BORDER = "1px solid var(--dsw-alias-border-l2, #0000001f)"; + const LAYER = "var(--dsw-alias-bg-layer-2, #f6f6f6)"; + const HOVER = "var(--dsw-alias-interactive-bg-hover, #0000000a)"; + /** The hues a source is drawn in, as the product's static palette names; the fallbacks are for themes without them. */ + const TINTS = { + blue: "#4d6bfe", + amber: "#d99a2b", + green: "#2f9e68", + deepseek: "#4d6bfe", + red: "#d4553f" + }; + const TINT_FAMILIES = Object.keys(TINTS); + /** Hover feedback for a row or a block, which inline styles cannot express. */ + function useHover() { + const [hovered, setHovered] = (0, react.useState)(false); + return [hovered, { + onMouseEnter: () => setHovered(true), + onMouseLeave: () => setHovered(false) + }]; + } + const pad2 = (value) => String(value).padStart(2, "0"); + /** Local `YYYY-MM-DD` of a date. */ + const localDate = (value = /* @__PURE__ */ new Date()) => `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}`; + /** Local midnight of a `YYYY-MM-DD` string. */ + function parseDate(date) { + const [year, month, day] = date.split("-").map(Number); + return new Date(year, month - 1, day); + } + const addDays = (date, days) => { + const value = parseDate(date); + value.setDate(value.getDate() + days); + return localDate(value); + }; + /** Monday of the week a date belongs to. */ + const startOfWeek = (date = localDate()) => addDays(date, -((parseDate(date).getDay() + 6) % 7)); + const dateOf = (value) => value.slice(0, 10); + /** Clock part of a local time string, empty for a whole-day value. */ + const clockOf = (value) => value && value.length > 10 ? value.slice(11, 16) : ""; + const weekdayOf = (date) => WEEKDAYS[(parseDate(date).getDay() + 6) % 7]; + const dayNumber = (date) => parseDate(date).getDate(); + const shortDate = (date) => `${parseDate(date).getMonth() + 1}月${parseDate(date).getDate()}日`; + const monthOf = (date) => `${parseDate(date).getFullYear()}年${parseDate(date).getMonth() + 1}月`; + const hourLabel = (minutes) => `${pad2(Math.floor(minutes / 60))}:00`; + /** Minutes from midnight of an `HH:mm` clock string. */ + const clockMinutes = (value) => Number(value.slice(0, 2)) * 60 + Number(value.slice(3, 5)); + /** The `HH:mm` clock string of minutes from midnight. */ + const clockAt = (minutes) => `${pad2(Math.floor(minutes / 60))}:${pad2(minutes % 60)}`; + const isToday = (date) => date === localDate(); + /** + * The clock a new entry is offered: the next whole hour when it is for today, + * otherwise the start of a working day. A new entry therefore always carries a + * time, and nothing is written as a whole-day record by accident. + */ + function suggestedStart(date) { + if (date !== localDate()) return 540; + const now = /* @__PURE__ */ new Date(); + return Math.min(1320, Math.ceil((now.getHours() * 60 + now.getMinutes()) / 60) * 60); + } + /** The clock range a block shows, from its own times. */ + function clockRange(occurrence) { + const start = clockOf(occurrence?.start); + const end = clockOf(occurrence?.end); + if (start === "") return "全天"; + return end === "" ? start : `${start}–${end}`; + } + /** A stable hue per data source, so one import keeps its colour across views and restarts. */ + function tintOf(key) { + let hash = 0; + const text = key ?? "event"; + for (let index = 0; index < text.length; index += 1) hash = (hash * 31 + text.charCodeAt(index)) % 100003; + const family = TINT_FAMILIES[hash % TINT_FAMILIES.length]; + const accent = `var(--dsw-static-${family}-500, ${TINTS[family]})`; + return { + accent, + background: `color-mix(in srgb, ${accent} 15%, var(--dsw-alias-bg-layer-1, #fff))` + }; + } + /** Drop undefined and blank entries, so a patch or an edit carries only what it means. */ + function withoutEmpty(input) { + const output = {}; + for (const [key, value] of Object.entries(input)) { + if (value === void 0 || value === null) continue; + if (typeof value === "string" && value.trim() === "") continue; + output[key] = value; + } + return output; + } + const messageOf = (error) => error instanceof Error ? error.message : String(error); + /** Unwrap one remote result: the gateway answers with `{ ok, value }` or `{ ok, error }`. */ + async function unwrap(operation) { + const result = await operation; + if (result?.ok === true) return result.value; + throw new Error(result?.error?.message || result?.error?.code || "日历数据不可用"); + } + /** The note of a record: a field of its own, written out as `DESCRIPTION`. */ + const noteOf = (event) => typeof event?.description === "string" ? event.description : ""; + /** One line describing how often a stored series repeats. */ + function seriesSummary(event) { + if (!event.recurrence) return "单次"; + const days = (event.recurrence.byDay ?? []).map((code) => WEEKDAY_BY_CODE[code]).filter(Boolean).join("、"); + const interval = event.recurrence.interval; + const every = interval && interval > 1 ? `每 ${interval} 周` : "每周"; + const limit = event.recurrence.count ? ` · 共 ${event.recurrence.count} 次` : event.recurrence.until ? ` · 至 ${shortDate(dateOf(event.recurrence.until))}` : ""; + return `${every}${days ? ` ${days}` : ""}${limit}`; + } + /** A labelled form row: the label above its control, as in the shell's own settings. */ + function Field({ label, children, hint }) { + return react.default.createElement("label", { style: { + display: "block", + marginBottom: 12 + } }, react.default.createElement("span", { style: { + display: "block", + marginBottom: 6, + fontSize: 12, + color: SECONDARY + } }, label), children, hint ? react.default.createElement("span", { style: { + display: "block", + marginTop: 4, + fontSize: 11, + color: TERTIARY + } }, hint) : void 0); + } + const Muted = ({ children, style }) => react.default.createElement("span", { style: { + fontSize: 12, + color: SECONDARY, + ...style + } }, children); + /** Icon-only button with the product's tooltip, used for every row-level action. */ + function IconButton({ label, icon, onClick, danger, disabled }) { + return react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tooltip, { + label, + side: "bottom" + }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + icon, + onClick, + disabled, + "aria-label": label, + style: danger ? { color: "var(--dsw-alias-state-error-primary, #d4553f)" } : void 0 + })); + } + /** + * One entry on the axis. + * + * The block is a button so a keyboard reaches it, and its width is the lane the + * layout gave it: `100 / lanes` percent, inset by a hairline so neighbours stay + * apart. + */ + function Block({ entry, axisStart, tint, onSelect }) { + const [hovered, hover] = useHover(); + const width = 100 / entry.lanes; + const height = entry.height * PX_PER_MINUTE; + const inner = height - 8; + const label = `${clockRange(entry.occurrence)} ${entry.occurrence.title}${entry.occurrence.location ? ` · ${entry.occurrence.location}` : ""}`; + const line = { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis" + }; + return react.default.createElement("button", { + type: "button", + title: label, + "aria-label": label, + onClick: () => onSelect(entry.occurrence), + ...hover, + style: { + position: "absolute", + top: (entry.top - axisStart) * PX_PER_MINUTE, + height, + left: `calc(${entry.lane * width}% + 2px)`, + width: `calc(${width}% - 4px)`, + display: "block", + overflow: "hidden", + textAlign: "left", + cursor: "pointer", + padding: "3px 6px", + border: "none", + borderLeft: `3px solid ${tint.accent}`, + borderRadius: 6, + background: tint.background, + color: PRIMARY, + fontFamily: "inherit", + boxShadow: hovered ? "var(--dsw-elevation-panel, 0 6px 18px rgba(0, 0, 0, .16))" : "none", + transition: "box-shadow .12s ease", + zIndex: hovered ? 2 : 1 + } + }, react.default.createElement("div", { style: { + ...line, + fontSize: 11, + lineHeight: "15px", + color: SECONDARY, + fontVariantNumeric: "tabular-nums" + } }, clockRange(entry.occurrence)), react.default.createElement("div", { style: { + ...line, + marginTop: 1, + fontSize: 12, + lineHeight: "17px", + fontWeight: 600 + } }, entry.occurrence.title), inner >= 34 && entry.occurrence.location ? react.default.createElement("div", { style: { + ...line, + fontSize: 11, + lineHeight: "15px", + color: SECONDARY + } }, entry.occurrence.location) : void 0); + } + /** + * The week: a day header row and the hour axis below it. The axis is one scroll + * container so the day columns can never drift apart vertically. A record that + * arrives without a clock is drawn as a block at midnight, not in a row of its + * own, so the week has a single geometry. + * + * Dragging on an empty spot starts an entry spanning what was dragged, and a + * double click starts a one-hour one there: between them the view is writable + * rather than only readable. + */ + function WeekGrid({ anchor, occurrences, onSelect, onCreate }) { + const [now, setNow] = (0, react.useState)(() => /* @__PURE__ */ new Date()); + (0, react.useEffect)(() => { + const timer = setInterval(() => setNow(/* @__PURE__ */ new Date()), 6e4); + return () => clearInterval(timer); + }, []); + const days = (0, react.useMemo)(() => Array.from({ length: 7 }, (_, index) => { + const date = addDays(anchor, index); + return { + key: date, + label: WEEKDAYS[index], + day: dayNumber(date), + today: isToday(date) + }; + }), [anchor]); + const focus = (0, react.useMemo)(() => dayWindow(occurrences).start, [occurrences]); + const scroller = (0, react.useRef)(null); + const scrolledTo = (0, react.useRef)(""); + (0, react.useEffect)(() => { + const node = scroller.current; + const key = `${anchor}:${focus}`; + if (!node || scrolledTo.current === key) return; + scrolledTo.current = key; + node.scrollTop = Math.max(0, focus * PX_PER_MINUTE - 8); + }, [anchor, focus]); + const axis = (0, react.useMemo)(() => ({ + start: 0, + end: DAY_MINUTES + }), []); + const hours = (0, react.useMemo)(() => { + const list = []; + for (let minutes = axis.start; minutes < axis.end; minutes += 60) list.push(minutes); + return list; + }, [axis]); + const axisHeight = (axis.end - axis.start) * PX_PER_MINUTE; + const [dragging, setDragging] = (0, react.useState)(null); + const press = (0, react.useRef)(null); + /** The fifteen-minute slot under a pointer inside one column. */ + const slotAt = (event) => { + const box = event.currentTarget.getBoundingClientRect(); + const raw = axis.start + (event.clientY - box.top) / PX_PER_MINUTE; + return Math.max(0, Math.min(DAY_MINUTES - 15, Math.round(raw / 15) * 15)); + }; + const timed = (0, react.useMemo)(() => (occurrences ?? []).filter((occurrence) => clockOf(occurrence.start) !== ""), [occurrences]); + const byDay = (0, react.useMemo)(() => layoutWeek(timed), [timed]); + /** + * Whole-day occurrences, listed under every day of this week they cover. + * + * A value that arrived without a clock (`DTSTART;VALUE=DATE`, which is what a + * holiday or an academic calendar is made of) has no slot on the axis, so it is + * shown in the day header: visible whatever the axis is scrolled to, and not + * claiming a time it does not have. A record that covers several days is shown + * on each of them, the way a calendar draws a multi-day bar. + */ + const allDayByDay = (0, react.useMemo)(() => { + const first = anchor; + const last = addDays(anchor, 6); + const map = /* @__PURE__ */ new Map(); + for (const occurrence of occurrences ?? []) { + if (clockOf(occurrence.start) !== "") continue; + const start = dateOf(occurrence.start); + const spanned = occurrence.end && clockOf(occurrence.end) === "" && dateOf(occurrence.end) > start ? dateOf(occurrence.end) : start; + const from = start < first ? first : start; + const to = spanned > last ? last : spanned; + for (let date = from; date <= to; date = addDays(date, 1)) { + const list = map.get(date); + if (list) list.push(occurrence); + else map.set(date, [occurrence]); + } + } + return map; + }, [occurrences, anchor]); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const nowVisible = days.some((day) => day.today); + /** The whole-day entries of one day, as the chips its header shows. */ + const allDayChips = (date) => { + const list = allDayByDay.get(date) ?? []; + if (list.length === 0) return void 0; + return react.default.createElement("div", { style: { + display: "flex", + flexDirection: "column", + gap: 2, + marginTop: 4 + } }, ...list.slice(0, 2).map((occurrence) => { + const tint = tintOf(occurrence.source ?? occurrence.uid); + const label = `全天 · ${occurrence.end && dateOf(occurrence.end) > dateOf(occurrence.start) ? `${shortDate(dateOf(occurrence.start))}–${shortDate(dateOf(occurrence.end))}` : shortDate(dateOf(occurrence.start))} ${occurrence.title}`; + return react.default.createElement("button", { + key: occurrence.occurrenceId, + type: "button", + title: label, + "aria-label": label, + onClick: () => onSelect(occurrence), + style: { + display: "block", + width: "100%", + padding: "1px 4px", + border: "none", + borderRadius: 4, + cursor: "pointer", + textAlign: "left", + fontSize: 10, + lineHeight: "15px", + fontFamily: "inherit", + color: PRIMARY, + background: tint.background, + borderLeft: `3px solid ${tint.accent}`, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis" + } + }, occurrence.title); + }), list.length > 2 ? react.default.createElement("div", { style: { + fontSize: 10, + lineHeight: "14px", + color: SECONDARY, + textAlign: "left" + } }, `还有 ${list.length - 2} 条全天`) : void 0); + }; + const headerRow = react.default.createElement("div", { style: { + display: "flex", + borderBottom: CARD_BORDER, + background: PAGE_BACKGROUND, + paddingRight: 8 + } }, react.default.createElement("div", { style: { + width: GUTTER, + flex: "0 0 auto" + } }), ...days.map((day) => react.default.createElement("div", { + key: day.key, + style: { + flex: "1 1 0", + minWidth: 0, + padding: "8px 4px", + textAlign: "center", + borderLeft: HAIRLINE + } + }, react.default.createElement("div", { style: { + fontSize: 11, + color: day.today ? PRIMARY : SECONDARY + } }, day.label), react.default.createElement("div", { style: day.today ? { + width: 26, + height: 26, + margin: "2px auto 0", + borderRadius: 999, + lineHeight: "26px", + fontSize: 13, + fontWeight: 650, + background: "var(--dsw-alias-state-business-primary, #4d6bfe)", + color: "#fff" + } : { + marginTop: 2, + fontSize: 13, + fontWeight: 600, + lineHeight: "26px", + color: PRIMARY + } }, day.day), allDayChips(day.key)))); + const hourLabels = hours.map((minutes, index) => react.default.createElement("div", { + key: minutes, + style: { + position: "absolute", + top: index === 0 ? 2 : (minutes - axis.start) * PX_PER_MINUTE - 6, + right: 8, + fontSize: 11, + color: TERTIARY, + fontVariantNumeric: "tabular-nums" + } + }, hourLabel(minutes))); + const columns = days.map((day) => react.default.createElement("div", { + key: day.key, + onPointerDown: (event) => { + if (!onCreate || event.target !== event.currentTarget) return; + if (event.pointerType === "touch" || event.button !== 0) return; + const from = slotAt(event); + press.current = { + day: day.key, + from, + pointerId: event.pointerId, + moved: false + }; + event.currentTarget.setPointerCapture(event.pointerId); + setDragging({ + day: day.key, + from, + to: from + }); + event.preventDefault(); + }, + onPointerMove: (event) => { + const active = press.current; + if (!active || active.pointerId !== event.pointerId) return; + const to = slotAt(event); + if (!active.moved && Math.abs(to - active.from) < 15) return; + active.moved = true; + setDragging({ + day: active.day, + from: Math.min(active.from, to), + to: Math.max(active.from, to) + }); + }, + onPointerUp: (event) => { + const active = press.current; + if (!active || active.pointerId !== event.pointerId) return; + press.current = null; + setDragging(null); + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + if (!active.moved) return; + const to = slotAt(event); + if (Math.abs(to - active.from) < 15) return; + onCreate(day.key, Math.min(active.from, to), Math.max(active.from, to)); + }, + onPointerCancel: (event) => { + if (press.current?.pointerId !== event.pointerId) return; + press.current = null; + setDragging(null); + }, + onDoubleClick: (event) => { + if (!onCreate || event.target !== event.currentTarget) return; + const from = slotAt(event); + onCreate(day.key, from, Math.min(from + 60, 1439)); + }, + style: { + position: "relative", + flex: "1 1 0", + minWidth: 0, + borderLeft: HAIRLINE, + background: day.today ? "color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 4%, transparent)" : void 0 + } + }, ...hours.map((minutes) => react.default.createElement("div", { + key: minutes, + style: { + position: "absolute", + left: 0, + right: 0, + top: (minutes - axis.start) * PX_PER_MINUTE, + height: 1, + background: "var(--dsw-alias-border-l1, #00000010)", + pointerEvents: "none" + } + })), dragging?.day === day.key ? react.default.createElement("div", { + key: "drag-preview", + "aria-hidden": true, + style: { + position: "absolute", + left: 2, + right: 2, + top: dragging.from * PX_PER_MINUTE, + height: Math.max(22, dragging.to - dragging.from) * PX_PER_MINUTE, + borderRadius: 6, + pointerEvents: "none", + zIndex: 2, + border: "1px dashed var(--dsw-alias-state-business-primary, #4d6bfe)", + background: "color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 14%, transparent)" + } + }, dragging.to > dragging.from ? react.default.createElement("div", { style: { + padding: "2px 6px", + fontSize: 11, + color: PRIMARY, + fontVariantNumeric: "tabular-nums" + } }, `${clockAt(dragging.from)}–${clockAt(dragging.to)}`) : void 0) : void 0, ...(byDay.get(day.key) ?? []).map((entry) => react.default.createElement(Block, { + key: entry.occurrence.occurrenceId, + entry, + axisStart: axis.start, + tint: tintOf(entry.occurrence.source ?? entry.occurrence.uid), + onSelect + })))); + const nowLine = nowVisible ? react.default.createElement("div", { + "aria-hidden": true, + style: { + position: "absolute", + left: 0, + right: 0, + top: (nowMinutes - axis.start) * PX_PER_MINUTE, + height: 2, + background: "var(--dsw-alias-state-error-primary, #d4553f)", + zIndex: 3, + pointerEvents: "none" + } + }) : void 0; + const axisRow = react.default.createElement("div", { style: { + display: "flex", + height: axisHeight + 12, + minHeight: "100%", + paddingRight: 8 + } }, react.default.createElement("div", { style: { + position: "relative", + width: GUTTER, + flex: "0 0 auto" + } }, ...hourLabels), react.default.createElement("div", { style: { + position: "relative", + flex: "1 1 0", + display: "flex" + } }, ...columns, nowLine)); + return react.default.createElement("div", { style: { + display: "flex", + flexDirection: "column", + flex: "1 1 auto", + minHeight: 0 + } }, headerRow, react.default.createElement("div", { + ref: scroller, + style: { + flex: "1 1 auto", + minHeight: 0, + overflowY: "auto", + overflowX: "hidden" + } + }, axisRow)); + } + /** The details of one occurrence, and every change the panel can make to it. */ + function EventDialog({ occurrence, event, service, onClose, run, onEdit }) { + const [editing, setEditing] = (0, react.useState)(false); + const [confirming, setConfirming] = (0, react.useState)(false); + const [form, setForm] = (0, react.useState)({ + start: occurrence?.start ?? "", + end: occurrence?.end ?? "", + location: occurrence?.location ?? "" + }); + (0, react.useEffect)(() => { + setEditing(false); + setConfirming(false); + setForm({ + start: occurrence?.start ?? "", + end: occurrence?.end ?? "", + location: occurrence?.location ?? "" + }); + }, [occurrence]); + if (!occurrence) return null; + const date = occurrence.occurrenceDate ?? dateOf(occurrence.start); + const series = Boolean(event?.recurrence); + const wholeDay = clockOf(occurrence.start) === ""; + form.start !== (occurrence.start ?? "") || form.end !== (occurrence.end ?? "") || (form.location, occurrence.location); + const keptLength = !wholeDay && form.end === (occurrence.end ?? "") ? shiftedEnd(form.start, occurrence.start ?? form.start, occurrence.end ?? "") : void 0; + const patch = withoutEmpty({ + start: form.start !== occurrence.start ? form.start : void 0, + end: keptLength ?? (form.end !== (occurrence.end ?? "") ? form.end : void 0), + location: form.location !== (occurrence.location ?? "") ? form.location : void 0 + }); + /** + * A series keeps one record per rule and marks the exceptions, so a change to + * a single occurrence of one is an override. A one-off entry has no series to + * keep in step: an override there would be a `RECURRENCE-ID` on a `VEVENT` + * without `RRULE`, which RFC 5545 does not allow, so the record itself is what + * gets edited. + */ + const save = () => series ? service.applyOverride({ + uid: occurrence.uid, + date, + patch + }) : service.putEvent(withoutEmpty({ + ...event, + start: patch.start ?? event?.start, + end: patch.end ?? event?.end, + location: patch.location ?? event?.location + })); + const actions = []; + if (!editing) actions.push(react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + key: "when", + variant: "outline", + size: "sm", + onClick: () => wholeDay && event ? onEdit(event.uid) : setEditing(true) + }, wholeDay ? "改日期或地点" : series ? "改期或改地点" : "改时间或地点")); + if (event) actions.push(react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + key: "record", + variant: "ghost", + size: "sm", + onClick: () => onEdit(event.uid) + }, "编辑日程信息")); + if (occurrence.overridden) actions.push(react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + key: "undo", + variant: "ghost", + size: "sm", + disabled: false, + onClick: () => run(() => service.removeOverride({ + uid: occurrence.uid, + date + }), "已恢复原定安排").then((ok) => ok && onClose()) + }, "撤销改期")); + if (series) actions.push(react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + key: "cancel", + variant: "ghost", + size: "sm", + onClick: () => run(() => service.cancelOccurrence({ + uid: occurrence.uid, + date + }), `已取消:${shortDate(date)}`).then((ok) => ok && onClose()) + }, "取消这一次")); + else actions.push(react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + key: "delete", + variant: "ghost", + size: "sm", + style: { color: "var(--dsw-alias-state-error-primary, #d4553f)" }, + onClick: () => setConfirming(true) + }, "删除日程")); + return react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Modal, { + open: true, + onClose, + title: occurrence.title, + closeLabel: "关闭", + description: series ? seriesSummary(event) : void 0, + footer: react.default.createElement("div", { style: { + display: "flex", + gap: 8, + flexWrap: "wrap", + alignItems: "center" + } }, ...actions, react.default.createElement("span", { style: { flex: "1 1 auto" } }), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + onClick: onClose + }, "关闭")) + }, react.default.createElement("div", { style: { + display: "flex", + flexDirection: "column", + gap: 10 + } }, react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 8, + flexWrap: "wrap" + } }, react.default.createElement("span", { style: { + fontSize: 13, + color: PRIMARY, + fontVariantNumeric: "tabular-nums" + } }, `${monthOf(date)}${dayNumber(date)}日 ${weekdayOf(date)} ${clockRange(occurrence)}`), occurrence.overridden ? react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "info" }, "已改期") : void 0, series ? react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "outline" }, "重复日程中的一次") : react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "quiet" }, "单次日程")), occurrence.location ? react.default.createElement("div", { style: { + fontSize: 13, + color: PRIMARY + } }, `地点:${occurrence.location}`) : void 0, occurrence.description ? react.default.createElement("div", { style: { + fontSize: 12, + color: SECONDARY, + whiteSpace: "pre-wrap" + } }, occurrence.description) : void 0, event?.source ? react.default.createElement("div", null, react.default.createElement(Muted, null, `来源:${event.source}`)) : void 0, editing ? react.default.createElement("div", { style: { + marginTop: 4, + padding: 12, + borderRadius: 10, + background: LAYER, + display: "flex", + flexDirection: "column", + gap: 10 + } }, react.default.createElement(Field, { label: "开始" }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + type: "datetime-local", + value: form.start, + onChange: (event) => setForm({ + ...form, + start: event.target.value + }) + })), react.default.createElement(Field, { + label: "结束", + hint: "留空表示沿用原本的时长" + }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + type: "datetime-local", + value: form.end, + onChange: (event) => setForm({ + ...form, + end: event.target.value + }) + })), react.default.createElement(Field, { label: "地点" }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + value: form.location, + placeholder: "留空表示沿用原来的地点", + onChange: (event) => setForm({ + ...form, + location: event.target.value + }) + })), react.default.createElement("div", { style: { + display: "flex", + gap: 8, + alignItems: "center" + } }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + disabled: Object.keys(patch).length === 0 || !series && !event, + onClick: () => run(save, series ? "已保存这次调整" : "已保存修改").then((ok) => ok && onClose()) + }, "保存"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + onClick: () => setEditing(false) + }, "取消"), react.default.createElement(Muted, null, series ? "只改这一次,系列其他日期不受影响" : "只改这一条日程"))) : void 0, confirming ? react.default.createElement("div", { style: { + marginTop: 4, + padding: 12, + borderRadius: 10, + background: LAYER, + display: "flex", + alignItems: "center", + gap: 8, + flexWrap: "wrap" + } }, react.default.createElement("span", { style: { fontSize: 13 } }, "确定删除这条日程?"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + style: { background: "var(--dsw-alias-state-error-primary, #d4553f)" }, + onClick: () => run(() => service.deleteEvent({ uid: occurrence.uid }), "已删除这条日程").then((ok) => ok && onClose()) + }, "删除"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + onClick: () => setConfirming(false) + }, "取消")) : void 0)); + } + /** Read a dropped or chosen file as text, with the few checks a calendar import needs. */ + async function readCalendarFile(file) { + const name = file.name.toLowerCase(); + if (!(name.endsWith(".ics") || name.endsWith(".ical") || name.endsWith(".txt") || file.type.includes("calendar") || file.type.startsWith("text/"))) throw new Error("只支持 .ics 日历文件"); + const text = await file.text(); + if (text.trim() === "") throw new Error("文件是空的"); + return text; + } + /** The import dialog: drop a file or paste text, then read what the import did. */ + function ImportDialog({ initialText, initialName, service, onClose, run }) { + const [text, setText] = (0, react.useState)(initialText ?? ""); + const [source, setSource] = (0, react.useState)(DEFAULT_SOURCE); + const [replace, setReplace] = (0, react.useState)(true); + const [name, setName] = (0, react.useState)(initialName ?? ""); + const [dragging, setDragging] = (0, react.useState)(false); + const [problem, setProblem] = (0, react.useState)(null); + const [outcome, setOutcome] = (0, react.useState)(null); + const [busy, setBusy] = (0, react.useState)(false); + const picker = (0, react.useRef)(null); + const accept = (0, react.useCallback)(async (file) => { + try { + setText(await readCalendarFile(file)); + setName(file.name); + setProblem(null); + } catch (error) { + setProblem(messageOf(error)); + } + }, []); + const submit = async () => { + setBusy(true); + setProblem(null); + try { + setOutcome(await service.importIcs({ + text, + source: source.trim() === "" ? DEFAULT_SOURCE : source.trim(), + replace + })); + } catch (error) { + setProblem(messageOf(error)); + } finally { + setBusy(false); + } + }; + const count = text.trim() === "" ? 0 : text.split(/\r\n|\r|\n/).filter((line) => line.startsWith("BEGIN:VEVENT")).length; + return react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Modal, { + open: true, + onClose, + title: outcome ? "导入结果" : "导入日历", + closeLabel: "关闭", + description: outcome ? `来源 ${source.trim() === "" ? DEFAULT_SOURCE : source.trim()}` : "粘贴日历文本,或把 .ics 文件拖到这里;同一来源的旧记录会被替换,自己手动改过的记录不受影响。", + footer: react.default.createElement("div", { style: { + display: "flex", + gap: 8, + alignItems: "center" + } }, outcome ? react.default.createElement(react.default.Fragment, null, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + onClick: () => { + setOutcome(null); + setText(""); + setName(""); + } + }, "再导一份"), react.default.createElement("span", { style: { flex: "1 1 auto" } }), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + onClick: onClose + }, "完成")) : react.default.createElement(react.default.Fragment, null, react.default.createElement(Muted, null, count > 0 ? `识别到 ${count} 条日程` : "等待日历文本"), react.default.createElement("span", { style: { flex: "1 1 auto" } }), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + onClick: onClose + }, "取消"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + disabled: text.trim() === "" || busy, + onClick: submit + }, busy ? "正在导入…" : "导入"))) + }, outcome ? react.default.createElement("div", { style: { + display: "flex", + flexDirection: "column", + gap: 12 + } }, react.default.createElement("div", { style: { + display: "flex", + gap: 8, + flexWrap: "wrap" + } }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "neutral" }, `日程 ${outcome.events} 条`), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "neutral" }, `移除旧日程 ${outcome.removedEvents} 条`)), react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 6, + fontSize: 13, + color: PRIMARY + } }, react.default.createElement(IconCheck), outcome.events > 0 ? "已写入日历" : "没有写入新的日程"), outcome.skipped.length > 0 ? react.default.createElement("div", null, react.default.createElement(Muted, { style: { + display: "block", + marginBottom: 4 + } }, `未导入 ${outcome.skipped.length} 条`), react.default.createElement("ul", { style: { + margin: 0, + paddingLeft: 18, + fontSize: 12, + color: SECONDARY + } }, ...outcome.skipped.map((entry, index) => react.default.createElement("li", { key: index }, `${entry.uid ? `${entry.uid}:` : ""}${entry.reason}`)))) : void 0, outcome.degraded.length > 0 ? react.default.createElement("div", null, react.default.createElement(Muted, { style: { + display: "block", + marginBottom: 4 + } }, `重复规则降级为单次 ${outcome.degraded.length} 条`), react.default.createElement("ul", { style: { + margin: 0, + paddingLeft: 18, + fontSize: 12, + color: SECONDARY + } }, ...outcome.degraded.map((entry, index) => react.default.createElement("li", { key: index }, `${entry.rrule ?? entry.uid ?? ""}${entry.reason ? `(${entry.reason})` : ""}`)))) : void 0) : react.default.createElement("div", { style: { + display: "flex", + flexDirection: "column", + gap: 12 + } }, react.default.createElement("div", { + onDragEnter: (event) => { + event.preventDefault(); + setDragging(true); + }, + onDragOver: (event) => { + event.preventDefault(); + setDragging(true); + }, + onDragLeave: () => setDragging(false), + onDrop: async (event) => { + event.preventDefault(); + setDragging(false); + const file = event.dataTransfer?.files?.[0]; + if (file) await accept(file); + }, + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 6, + padding: "20px 16px", + borderRadius: 12, + textAlign: "center", + border: `1px dashed ${dragging ? "var(--dsw-alias-state-business-primary, #4d6bfe)" : "var(--dsw-alias-border-l2, #0000002e)"}`, + background: dragging ? "color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 6%, transparent)" : LAYER + } + }, react.default.createElement(IconFolderOpen), react.default.createElement("strong", { style: { fontSize: 13 } }, name === "" ? "把 .ics 文件拖到这里" : name), react.default.createElement(Muted, null, name === "" ? "也可以点下面的按钮选择文件" : "已读取文件内容,可以直接导入"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "outline", + size: "sm", + onClick: () => picker.current?.click() + }, "选择文件"), react.default.createElement("input", { + ref: (node) => { + picker.current = node; + }, + type: "file", + accept: ".ics,.ical,.txt,text/calendar", + style: { display: "none" }, + onChange: async (event) => { + const file = event.target.files?.[0]; + if (file) await accept(file); + event.target.value = ""; + } + })), react.default.createElement("textarea", { + value: text, + onChange: (event) => setText(event.target.value), + placeholder: "BEGIN:VCALENDAR …", + spellCheck: false, + style: { + width: "100%", + minHeight: 148, + resize: "vertical", + boxSizing: "border-box", + padding: 10, + borderRadius: 10, + border: CARD_BORDER, + background: "var(--dsw-alias-bg-layer-1, #fff)", + color: PRIMARY, + fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)", + fontSize: 12, + lineHeight: 1.5 + } + }), react.default.createElement("div", { style: { + display: "flex", + gap: 12, + alignItems: "flex-end", + flexWrap: "wrap" + } }, react.default.createElement("div", { style: { flex: "1 1 200px" } }, react.default.createElement(Field, { + label: "来源", + hint: "同一来源再次导入会替换它自己的旧记录" + }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + value: source, + onChange: (event) => setSource(event.target.value) + }))), react.default.createElement("div", { style: { paddingBottom: 18 } }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Switch, { + checked: replace, + onChange: setReplace, + label: "替换同来源的旧记录" + }))), problem ? react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 6, + fontSize: 12, + color: "var(--dsw-alias-state-error-primary, #d4553f)" + } }, react.default.createElement(IconWarning), problem) : void 0)); + } + /** The export dialog: the calendar text, ready to copy or to download. */ + function ExportDialog({ service, onClose, toast }) { + const [text, setText] = (0, react.useState)(""); + const [problem, setProblem] = (0, react.useState)(null); + const [copied, setCopied] = (0, react.useState)(false); + (0, react.useEffect)(() => { + let alive = true; + service.exportIcs({}).then((result) => { + if (alive) setText(result.text); + }).catch((error) => { + if (alive) setProblem(messageOf(error)); + }); + return () => { + alive = false; + }; + }, [service]); + const save = () => { + const blob = new Blob([text], { type: "text/calendar;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "日历.ics"; + link.click(); + URL.revokeObjectURL(url); + }; + const lines = text === "" ? 0 : text.split("\r\n").length; + return react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Modal, { + open: true, + onClose, + title: "导出日历", + closeLabel: "关闭", + description: "标准 iCalendar 文本,可以复制或下载后导入其他日历应用。", + footer: react.default.createElement("div", { style: { + display: "flex", + gap: 8, + alignItems: "center" + } }, react.default.createElement(Muted, null, text === "" ? "正在生成…" : `${lines} 行 · ${text.length} 字符`), react.default.createElement("span", { style: { flex: "1 1 auto" } }), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + icon: react.default.createElement(IconCopy), + disabled: text === "", + onClick: async () => { + const ok = await (0, _deepseek_ai_dsh_client_ui_primitives.writeClipboard)(text); + setCopied(ok); + toast(ok ? "已复制日历文本" : "复制失败,请手动选择文本"); + } + }, copied ? "已复制" : "复制"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "outline", + size: "sm", + icon: react.default.createElement(IconDownload), + disabled: text === "", + onClick: save + }, "下载 .ics"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + onClick: onClose + }, "完成")) + }, react.default.createElement("textarea", { + readOnly: true, + value: text, + spellCheck: false, + onFocus: (event) => event.target.select(), + style: { + width: "100%", + minHeight: 300, + maxHeight: "46vh", + resize: "vertical", + boxSizing: "border-box", + padding: 10, + borderRadius: 10, + border: CARD_BORDER, + background: "var(--dsw-alias-bg-layer-1, #fff)", + color: PRIMARY, + fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)", + fontSize: 12, + lineHeight: 1.5, + whiteSpace: "pre", + overflowX: "auto", + overflowY: "auto" + } + }), problem ? react.default.createElement("div", { style: { + marginTop: 8, + fontSize: 12, + color: "var(--dsw-alias-state-error-primary, #d4553f)" + } }, problem) : void 0); + } + /** One entry row of the agenda: date, time, place, and the actions that fit a row. */ + function EventRow({ occurrence, tint, onOpen, onCancel, onDelete }) { + const [hovered, hover] = useHover(); + const date = dateOf(occurrence.start); + const series = Boolean(occurrence.recurrence); + return react.default.createElement("div", { + ...hover, + style: { + display: "grid", + gridTemplateColumns: `${GUTTER}px minmax(0, 1fr) auto`, + alignItems: "center", + gap: 10, + padding: "8px 12px", + borderTop: HAIRLINE, + background: hovered ? HOVER : "transparent" + } + }, react.default.createElement("div", { style: { textAlign: "center" } }, react.default.createElement("div", { style: { + fontSize: 11, + color: SECONDARY + } }, weekdayOf(date)), react.default.createElement("div", { style: { + fontSize: 15, + fontWeight: 650, + color: PRIMARY + } }, dayNumber(date))), react.default.createElement("button", { + type: "button", + onClick: () => onOpen(occurrence), + style: { + display: "block", + minWidth: 0, + padding: 0, + border: "none", + background: "none", + textAlign: "left", + cursor: "pointer", + fontFamily: "inherit", + color: PRIMARY + } + }, react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 6, + minWidth: 0 + } }, react.default.createElement("span", { + "aria-hidden": true, + style: { + width: 8, + height: 8, + borderRadius: 999, + background: tint.accent, + flex: "0 0 auto" + } + }), react.default.createElement("span", { style: { + fontSize: 13, + fontWeight: 550, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis" + } }, occurrence.title), occurrence.overridden ? react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "info" }, "已改期") : void 0), react.default.createElement("div", { style: { + marginTop: 2, + fontSize: 12, + color: SECONDARY, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis" + } }, [clockRange(occurrence), occurrence.location].filter(Boolean).join(" · "))), react.default.createElement("div", { style: { + display: "flex", + gap: 2, + opacity: hovered ? 1 : 0, + transition: "opacity .12s ease" + } }, series ? react.default.createElement(IconButton, { + label: "取消这一次", + icon: react.default.createElement(IconArchive), + onClick: () => onCancel(occurrence) + }) : react.default.createElement(IconButton, { + label: "删除日程", + danger: true, + icon: react.default.createElement(IconTrash), + onClick: () => onDelete(occurrence) + }))); + } + /** Days between two `YYYY-MM-DD` dates, so a whole-day range keeps its span. */ + const daysBetween = (from, to) => Math.round((parseDate(to).getTime() - parseDate(from).getTime()) / 864e5); + /** The editable fields of a stored record, as the form holds them. */ + const draftOf = (event) => ({ + title: event?.title ?? "", + date: dateOf(event?.start ?? "") || localDate(), + start: clockOf(event?.start), + end: clockOf(event?.end), + location: event?.location ?? "", + description: noteOf(event), + repeat: Boolean(event?.recurrence), + until: event?.recurrence?.until ? dateOf(event.recurrence.until) : "" + }); + /** + * The record a draft describes, on top of the one it was read from. + * + * An emptied field disappears instead of lingering as an empty string: the host + * stores the record it is handed, so the form is the whole truth about it. + */ + function recordOf(draft, base = {}) { + const timed = draft.start !== ""; + const shift = base.start ? daysBetween(dateOf(base.start), draft.date) : 0; + return withoutEmpty({ + ...base, + uid: base.uid ?? newUid(), + title: draft.title.trim(), + start: timed ? `${draft.date}T${draft.start}` : draft.date, + end: timed ? draft.end === "" ? void 0 : `${draft.date}T${draft.end}` : base.end ? addDays(dateOf(base.end), shift) : void 0, + location: draft.location.trim(), + description: draft.description.trim(), + recurrence: draft.repeat ? withoutEmpty({ + ...base.recurrence ?? {}, + freq: "weekly", + until: draft.until + }) : void 0, + exceptions: draft.repeat ? base.exceptions : void 0, + overrides: draft.repeat ? base.overrides : void 0 + }); + } + /** Why a draft cannot be saved yet, or `null` when it can. */ + function draftProblem(draft) { + if (draft.title.trim() === "") return "标题不能为空"; + if (draft.date === "") return "需要选一个日期"; + if (draft.start !== "" && draft.end !== "" && draft.end <= draft.start) return "结束时间要晚于开始时间"; + return null; + } + /** + * The fields of one record, shared by the new-entry dialog and the entry page: + * what the reader owns about an entry, as opposed to one date of a series. + */ + function EventForm({ draft, onChange, autoFocus }) { + const set = (patch) => onChange({ + ...draft, + ...patch + }); + return react.default.createElement(react.default.Fragment, null, react.default.createElement(Field, { label: "标题" }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + value: draft.title, + placeholder: "例如:组会、体检、回家", + autoFocus, + onChange: (event) => set({ title: event.target.value }) + })), react.default.createElement("div", { style: { + display: "flex", + gap: 8, + flexWrap: "wrap" + } }, react.default.createElement("div", { style: { flex: "1 1 150px" } }, react.default.createElement(Field, { label: "日期" }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + type: "date", + value: draft.date, + onChange: (event) => set({ date: event.target.value }) + }))), react.default.createElement("div", { style: { flex: "1 1 90px" } }, react.default.createElement(Field, { label: "开始" }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + type: "time", + value: draft.start, + onChange: (event) => set({ start: event.target.value }) + }))), react.default.createElement("div", { style: { flex: "1 1 90px" } }, react.default.createElement(Field, { + label: "结束", + hint: "留空按 45 分钟显示" + }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + type: "time", + value: draft.end, + onChange: (event) => set({ end: event.target.value }) + })))), react.default.createElement(Field, { label: "地点" }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + value: draft.location, + placeholder: "可留空", + onChange: (event) => set({ location: event.target.value }) + })), react.default.createElement(Field, { + label: "备注", + hint: "随导出写进日历,不会同步到任何服务" + }, react.default.createElement("textarea", { + value: draft.description, + rows: 3, + spellCheck: false, + onChange: (event) => set({ description: event.target.value }), + style: { + width: "100%", + boxSizing: "border-box", + padding: "6px 8px", + borderRadius: 8, + resize: "vertical", + border: CARD_BORDER, + background: "var(--dsw-alias-bg-layer-1, #fff)", + color: PRIMARY, + fontSize: 13, + fontFamily: "inherit", + lineHeight: 1.5 + } + })), react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 12 + } }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Switch, { + checked: draft.repeat, + label: "每周重复", + onChange: (next) => set({ + repeat: next, + until: next ? draft.until : "" + }) + }), react.default.createElement("span", { style: { + fontSize: 13, + color: PRIMARY + } }, "每周重复"), react.default.createElement(Muted, null, "按开始日期的星期")), draft.repeat ? react.default.createElement(Field, { + label: "重复到", + hint: "留空表示一直重复" + }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Input, { + type: "date", + value: draft.until, + onChange: (event) => set({ until: event.target.value }) + })) : void 0); + } + /** The dialog that creates one entry by hand. */ + function CreateDialog({ defaultDate, defaultStart, defaultEnd, service, onClose, run, onSaved }) { + const [draft, setDraft] = (0, react.useState)(() => { + const date = defaultDate ?? localDate(); + const start = defaultStart ?? clockAt(suggestedStart(date)); + return { + ...draftOf(null), + date, + start, + end: defaultEnd ?? clockAt(Math.min(clockMinutes(start) + 60, 1439)) + }; + }); + const [problem, setProblem] = (0, react.useState)(null); + const complaint = draftProblem(draft); + const submit = async () => { + if (complaint) { + setProblem(complaint); + return; + } + if (await run(() => service.putEvent(recordOf(draft)), "已新建日程")) onSaved(); + }; + return react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Modal, { + open: true, + onClose, + title: "新建日程", + closeLabel: "关闭", + description: "写进本机日历,随时可以再改。", + footer: react.default.createElement("div", { style: { + display: "flex", + gap: 8, + alignItems: "center" + } }, react.default.createElement(Muted, null, draft.repeat ? "每周重复" : "单次日程"), react.default.createElement("span", { style: { flex: "1 1 auto" } }), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + onClick: onClose + }, "取消"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + disabled: Boolean(complaint), + onClick: submit + }, "新建")) + }, react.default.createElement("div", null, react.default.createElement(EventForm, { + draft, + onChange: setDraft, + autoFocus: true + }), problem ? react.default.createElement("div", { style: { + fontSize: 12, + color: "var(--dsw-alias-state-error-primary, #d4553f)" + } }, problem) : void 0)); + } + /** + * One stored entry: what it is on the left, the dates it produces on the right. + * Wide panels show both at once; a narrow one stacks them, because the panel + * width is the reader's, not ours. + */ + function EventPage({ event, agenda, onOpenOccurrence, onSave, onRestore, onCancel, onDelete, busy }) { + const [draft, setDraft] = (0, react.useState)(() => draftOf(event)); + const [problem, setProblem] = (0, react.useState)(null); + (0, react.useEffect)(() => { + setDraft(draftOf(event)); + setProblem(null); + }, [event]); + const uuid = event?.uid; + const occurrences = (0, react.useMemo)(() => (agenda ?? []).filter((occurrence) => occurrence.uid === uuid), [agenda, uuid]); + const groups = (0, react.useMemo)(() => { + const map = /* @__PURE__ */ new Map(); + for (const occurrence of occurrences) { + const key = occurrence.start.slice(0, 7); + if (!map.has(key)) map.set(key, []); + map.get(key).push(occurrence); + } + return [...map.entries()]; + }, [occurrences]); + if (!event) return null; + const tint = tintOf(event.source ?? event.uid); + const exceptions = (event.exceptions ?? []).map(dateOf); + const complaint = draftProblem(draft); + const dirty = JSON.stringify(draft) !== JSON.stringify(draftOf(event)); + const save = () => { + if (complaint) { + setProblem(complaint); + return; + } + if (dirty) onSave(recordOf(draft, event)); + }; + const infoCard = react.default.createElement("section", { style: { + padding: 16, + borderRadius: 12, + border: CARD_BORDER, + background: "var(--dsw-alias-bg-layer-1, #fff)" + } }, react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 12, + flexWrap: "wrap" + } }, react.default.createElement("span", { + "aria-hidden": true, + style: { + width: 10, + height: 10, + borderRadius: 999, + background: tint.accent + } + }), react.default.createElement("strong", { style: { fontSize: 13 } }, "日程信息"), react.default.createElement("span", { style: { flex: "1 1 auto" } }), event.recurrence ? react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "outline" }, seriesSummary(event)) : void 0, event.source ? react.default.createElement(Muted, null, event.source) : void 0), react.default.createElement(EventForm, { + draft, + onChange: setDraft + }), react.default.createElement("div", { style: { + display: "flex", + gap: 8, + alignItems: "center", + flexWrap: "wrap" + } }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + disabled: !dirty || busy || Boolean(complaint), + onClick: save + }, "保存更改"), dirty ? react.default.createElement(Muted, null, complaint ?? "有未保存的改动") : void 0, react.default.createElement("span", { style: { flex: "1 1 auto" } }), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + icon: react.default.createElement(IconTrash), + style: { color: "var(--dsw-alias-state-error-primary, #d4553f)" }, + onClick: () => onDelete(event) + }, "删除这条日程"))); + const cancelledCard = exceptions.length === 0 ? void 0 : react.default.createElement("section", { style: { + padding: 16, + borderRadius: 12, + border: CARD_BORDER, + background: "var(--dsw-alias-bg-layer-1, #fff)" + } }, react.default.createElement("strong", { style: { + display: "block", + marginBottom: 10, + fontSize: 13 + } }, "已取消的日期"), react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 6, + flexWrap: "wrap" + } }, ...exceptions.map((date) => react.default.createElement("span", { + key: date, + style: { + display: "inline-flex", + alignItems: "center", + gap: 2, + padding: "1px 4px 1px 7px", + borderRadius: 999, + background: LAYER, + fontSize: 11 + } + }, shortDate(date), react.default.createElement(IconButton, { + label: `恢复 ${shortDate(date)}`, + icon: react.default.createElement(IconRefreshSmall), + onClick: () => onRestore(event, date) + }))))); + const monthBlocks = groups.flatMap(([month, items]) => [react.default.createElement("div", { + key: month, + style: { + padding: "6px 12px", + background: LAYER, + fontSize: 12, + color: SECONDARY, + position: "sticky", + top: 0 + } + }, monthOf(`${month}-01`)), ...items.map((occurrence) => react.default.createElement(EventRow, { + key: occurrence.occurrenceId, + occurrence, + tint, + onOpen: onOpenOccurrence, + onCancel: (item) => onCancel(item), + onDelete: (item) => onDelete(item) + }))]); + const listCard = react.default.createElement("div", { style: { + flex: "2 1 420px", + minWidth: 280, + borderRadius: 12, + border: CARD_BORDER, + background: "var(--dsw-alias-bg-layer-1, #fff)", + overflow: "hidden" + } }, react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 8, + padding: "10px 12px", + flexWrap: "wrap" + } }, react.default.createElement("strong", { style: { fontSize: 13 } }, "出现的时间"), react.default.createElement(Muted, null, `近一年内 ${occurrences.length} 次`), react.default.createElement("span", { style: { flex: "1 1 auto" } }), react.default.createElement(Muted, null, "点一条查看或调整")), occurrences.length === 0 ? react.default.createElement("div", { style: { + padding: "24px 12px", + textAlign: "center" + } }, react.default.createElement(Muted, null, "近一年内没有它出现的时间")) : react.default.createElement("div", null, ...monthBlocks)); + return react.default.createElement("div", { style: { + flex: "1 1 auto", + minHeight: 0, + overflowY: "auto" + } }, react.default.createElement("div", { style: { + display: "flex", + gap: 16, + alignItems: "flex-start", + flexWrap: "wrap", + padding: "16px 20px 24px" + } }, react.default.createElement("div", { style: { + flex: "1 1 300px", + maxWidth: 420, + minWidth: 260, + display: "flex", + flexDirection: "column", + gap: 12 + } }, infoCard, cancelledCard), listCard)); + } + /** The panel: the week, the entry list behind a menu, and the dialogs. */ + function createCalendarPage(service) { + return function CalendarPanel() { + const [view, setView] = (0, react.useState)({ kind: "week" }); + const [anchor, setAnchor] = (0, react.useState)(() => startOfWeek()); + const [snapshot, setSnapshot] = (0, react.useState)(null); + const [week, setWeek] = (0, react.useState)(null); + const [agenda, setAgenda] = (0, react.useState)(null); + const [loading, setLoading] = (0, react.useState)(true); + const [problem, setProblem] = (0, react.useState)(null); + const [toast, setToast] = (0, react.useState)(null); + const [menuOpen, setMenuOpen] = (0, react.useState)(false); + const [dialog, setDialog] = (0, react.useState)(null); + const [dropping, setDropping] = (0, react.useState)(false); + const [busy, setBusy] = (0, react.useState)(false); + const events = snapshot?.events ?? []; + const weekRange = { + from: anchor, + to: addDays(anchor, 6) + }; + const agendaRange = { + from: addDays(localDate(), -180), + to: addDays(localDate(), 366) + }; + const loadSnapshot = (0, react.useCallback)(async () => { + setSnapshot(await service.snapshot()); + }, [service]); + const loadWeek = (0, react.useCallback)(async () => { + setWeek(await service.occurrences(weekRange)); + }, [service, anchor]); + const loadAgenda = (0, react.useCallback)(async () => { + setAgenda((await service.occurrences(agendaRange)).occurrences); + }, [service, view.id]); + const refresh = (0, react.useCallback)(async () => { + setLoading(true); + try { + await loadSnapshot(); + if (view.kind === "week") await loadWeek(); + if (view.kind === "event") await loadAgenda(); + setProblem(null); + } catch (error) { + setProblem(messageOf(error)); + } finally { + setLoading(false); + } + }, [ + loadSnapshot, + loadWeek, + loadAgenda, + view.kind + ]); + (0, react.useEffect)(() => { + refresh(); + }, [refresh]); + /** Run one write, then re-read: the panel never guesses what the host stored. */ + const run = (0, react.useCallback)(async (action, success) => { + setBusy(true); + try { + await action(); + await refresh(); + if (success) setToast(success); + return true; + } catch (error) { + setProblem(messageOf(error)); + return false; + } finally { + setBusy(false); + } + }, [refresh]); + const openEvent = (0, react.useCallback)((uid) => { + setDialog(null); + setMenuOpen(false); + setView({ + kind: "event", + id: uid + }); + }, []); + /** A drag or a double click in the week asks for a new entry over that span. */ + const createAt = (0, react.useCallback)((date, from, to) => { + setDialog({ + kind: "create", + date, + start: clockAt(from), + end: clockAt(Math.max(to, from + 15)) + }); + }, []); + const current = view.kind === "event" ? events.find((event) => event.uid === view.id) : void 0; + const weekCount = week?.occurrences?.length ?? 0; + /** The week on screen is today's, so an entry started from here means today. */ + const createDate = anchor === startOfWeek() ? localDate() : anchor; + const title = view.kind === "week" ? "我的日历" : current?.title ?? "日程"; + const subtitle = view.kind === "week" ? `${monthOf(anchor)}${dayNumber(anchor)}日 – ${shortDate(addDays(anchor, 6))} · ${weekCount} 条` : [ + current?.location, + current?.source, + current && noteOf(current) !== "" ? "有备注" : "" + ].filter(Boolean).join(" · "); + const menuItems = events.length === 0 ? [{ + type: "label", + id: "none", + text: "还没有日程" + }] : events.map((event) => ({ + id: event.uid, + label: event.title, + icon: react.default.createElement(IconClock) + })); + return react.default.createElement("div", { + onDragEnter: (event) => { + if (event.dataTransfer?.types?.includes("Files")) { + event.preventDefault(); + setDropping(true); + } + }, + onDragOver: (event) => { + if (event.dataTransfer?.types?.includes("Files")) event.preventDefault(); + }, + onDragLeave: (event) => { + if (event.currentTarget === event.target) setDropping(false); + }, + onDrop: async (event) => { + event.preventDefault(); + setDropping(false); + const file = event.dataTransfer?.files?.[0]; + if (!file) return; + try { + const text = await readCalendarFile(file); + setView({ kind: "week" }); + setDialog({ + kind: "import", + text, + name: file.name + }); + } catch (error) { + setProblem(messageOf(error)); + } + }, + style: { + position: "relative", + height: "100%", + display: "flex", + flexDirection: "column", + minHeight: 0, + background: PAGE_BACKGROUND, + color: PRIMARY + } + }, react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + padding: "12px 16px", + borderBottom: CARD_BORDER, + flex: "0 0 auto" + } }, view.kind === "event" ? react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + icon: react.default.createElement(IconChevronLeft), + onClick: () => setView({ kind: "week" }) + }, "返回") : void 0, react.default.createElement("div", { style: { minWidth: 0 } }, react.default.createElement("div", { style: { + fontSize: 16, + fontWeight: 650, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis" + } }, title), react.default.createElement("div", { style: { + fontSize: 12, + color: SECONDARY + } }, loading ? "正在读取…" : subtitle)), react.default.createElement("span", { style: { flex: "1 1 auto" } }), view.kind === "week" ? react.default.createElement(react.default.Fragment, null, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "outline", + size: "sm", + disabled: anchor === startOfWeek(), + onClick: () => setAnchor(startOfWeek()) + }, "今天"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tooltip, { + label: "上一周", + side: "bottom" + }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + "aria-label": "上一周", + icon: react.default.createElement(IconChevronLeft), + onClick: () => setAnchor(addDays(anchor, -7)) + })), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tooltip, { + label: "下一周", + side: "bottom" + }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + "aria-label": "下一周", + icon: react.default.createElement(IconChevronRight), + onClick: () => setAnchor(addDays(anchor, 7)) + }))) : void 0, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + icon: react.default.createElement(IconPlus), + onClick: () => setDialog({ + kind: "create", + date: view.kind === "event" && current ? dateOf(current.start) : createDate + }) + }, "新建日程"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Menu, { + open: menuOpen, + onClose: () => setMenuOpen(false), + items: menuItems, + selectedId: view.id, + onSelect: (id) => events.some((event) => event.uid === id) ? openEvent(id) : void 0, + align: "end", + anchor: react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + icon: react.default.createElement(IconClock), + onClick: () => setMenuOpen(!menuOpen) + }, "日程", react.default.createElement(IconChevronDown)) + }), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + icon: react.default.createElement(IconFolderOpen), + onClick: () => setDialog({ kind: "import" }) + }, "导入"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + icon: react.default.createElement(IconDownload), + onClick: () => setDialog({ kind: "export" }) + }, "导出"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Tooltip, { + label: "刷新", + side: "bottom" + }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + "aria-label": "刷新", + disabled: loading, + icon: react.default.createElement(IconRefresh), + onClick: () => void refresh() + }))), problem ? react.default.createElement("div", { style: { + display: "flex", + alignItems: "center", + gap: 6, + padding: "6px 16px", + fontSize: 12, + flex: "0 0 auto", + color: "var(--dsw-alias-state-error-primary, #d4553f)", + background: "color-mix(in srgb, var(--dsw-alias-state-error-primary, #d4553f) 8%, transparent)" + } }, react.default.createElement(IconWarning), problem) : void 0, view.kind === "week" ? events.length === 0 && weekCount === 0 && !loading ? react.default.createElement("div", { style: { + flex: "1 1 auto", + display: "grid", + placeItems: "center", + padding: 24 + } }, react.default.createElement("div", { style: { + maxWidth: 380, + padding: 20, + borderRadius: 12, + border: CARD_BORDER, + background: LAYER, + textAlign: "center" + } }, react.default.createElement(IconAlarmClock), react.default.createElement("div", { style: { + margin: "8px 0 4px", + fontSize: 14, + fontWeight: 600 + } }, "日历还是空的"), react.default.createElement(Muted, null, "先新建一条日程,或把从教务系统、其他日历导出的 .ics 文件拖进这个面板。双击时间轴上的空白处也能新建。"), react.default.createElement("div", { style: { + marginTop: 12, + display: "flex", + gap: 8, + justifyContent: "center" + } }, react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "primary", + size: "sm", + icon: react.default.createElement(IconPlus), + onClick: () => setDialog({ + kind: "create", + date: createDate + }) + }, "新建日程"), react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "outline", + size: "sm", + icon: react.default.createElement(IconFolderOpen), + onClick: () => setDialog({ kind: "import" }) + }, "导入日历")))) : react.default.createElement(WeekGrid, { + anchor, + occurrences: week?.occurrences ?? [], + onSelect: (occurrence) => setDialog({ + kind: "event", + occurrence + }), + onCreate: createAt + }) : react.default.createElement(EventPage, { + event: current, + agenda, + busy, + onOpenOccurrence: (occurrence) => setDialog({ + kind: "event", + occurrence + }), + onSave: (event) => run(() => service.putEvent(event), "日程信息已保存"), + onRestore: (event, date) => run(() => service.restoreOccurrence({ + uid: event.uid, + date + }), `已恢复 ${shortDate(date)} 的日程`), + onDelete: (event) => run(() => service.deleteEvent({ uid: event.uid }), "已删除这条日程").then((ok) => { + if (ok) setView({ kind: "week" }); + }), + onCancel: (occurrence) => run(() => service.cancelOccurrence({ + uid: occurrence.uid, + date: occurrence.occurrenceDate ?? dateOf(occurrence.start) + }), `已取消 ${shortDate(occurrence.occurrenceDate ?? dateOf(occurrence.start))} 的日程`) + }), dialog?.kind === "event" ? react.default.createElement(EventDialog, { + occurrence: dialog.occurrence, + event: events.find((item) => item.uid === dialog.occurrence?.uid), + service, + run, + onEdit: openEvent, + onClose: () => setDialog(null) + }) : void 0, dialog?.kind === "create" ? react.default.createElement(CreateDialog, { + defaultDate: dialog.date, + defaultStart: dialog.start, + defaultEnd: dialog.end, + service, + run, + onSaved: () => setDialog(null), + onClose: () => setDialog(null) + }) : void 0, dialog?.kind === "import" ? react.default.createElement(ImportDialog, { + initialText: dialog.text, + initialName: dialog.name, + service, + run, + onClose: () => { + setDialog(null); + refresh(); + } + }) : void 0, dialog?.kind === "export" ? react.default.createElement(ExportDialog, { + service, + toast: setToast, + onClose: () => setDialog(null) + }) : void 0, toast ? react.default.createElement(_deepseek_ai_dsh_client_ui_primitives.Toast, { + text: toast, + onDone: () => setToast(null) + }) : void 0, dropping ? react.default.createElement("div", { + "aria-hidden": true, + style: { + position: "fixed", + inset: 0, + zIndex: 90, + display: "grid", + placeItems: "center", + pointerEvents: "none", + background: "color-mix(in srgb, var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, .32)) 82%, transparent)", + backdropFilter: "blur(5px)" + } + }, react.default.createElement("div", { style: { + padding: "22px 28px", + textAlign: "center", + borderRadius: 16, + border: "1px dashed var(--dsw-alias-border-l2, #0000002e)", + background: "var(--dsw-alias-bg-module-platform, #fff)", + color: PRIMARY, + boxShadow: "0 18px 50px rgba(0, 0, 0, .16)" + } }, react.default.createElement("div", { style: { fontSize: 28 } }, "⇩"), react.default.createElement("strong", { style: { + display: "block", + fontSize: 14 + } }, "松开即可导入日历"), react.default.createElement(Muted, null, ".ics 文件会读进导入窗口,确认后再写入"))) : void 0); + }; + } + /** Sidebar glyph: a month grid with one marked day. */ + function CalendarIcon(props) { + return react.default.createElement("svg", { + width: 16, + height: 16, + viewBox: "0 0 16 16", + fill: "none", + "aria-hidden": true, + stroke: "currentColor", + strokeWidth: 1.2, + strokeLinecap: "round", + strokeLinejoin: "round", + ...props + }, react.default.createElement("rect", { + x: 2, + y: 3, + width: 12, + height: 11, + rx: 2 + }), react.default.createElement("path", { d: "M2 6.5h12M5.5 2v2.5M10.5 2v2.5" }), react.default.createElement("path", { + d: "M5 9.5h2.5v2.5H5z", + fill: "currentColor", + stroke: "none" + })); + } + async function apply(ctx) { + const disposeRemote = await ctx.remote.$mount(typert_remote_client_default); + ctx.inject(["remote.calendar"], (surface) => { + const call = (method) => (input) => unwrap(surface.remote.calendar[method](input)); + const service = { + snapshot: () => unwrap(surface.remote.calendar.snapshot()), + occurrences: call("occurrences"), + importIcs: call("importIcs"), + exportIcs: call("exportIcs"), + applyOverride: call("applyOverride"), + removeOverride: call("removeOverride"), + cancelOccurrence: call("cancelOccurrence"), + restoreOccurrence: call("restoreOccurrence"), + deleteEvent: call("deleteEvent"), + putEvent: call("putEvent") + }; + surface.slots.inject("main", () => surface.slots.register({ + name: "main", + key: PANEL_ID + }, createCalendarPage(service))); + surface.slots.inject("sidebar.panellist", () => surface.slots.register({ + name: "sidebar.panellist", + id: PANEL_ID, + order: 100, + label: "日历" + }, CalendarIcon)); + }); + return async () => { + await disposeRemote(); + }; + } + exports.apply = apply; + exports.inject = inject; + return module.exports; + } +}); diff --git a/dsh-plugins/calendar/lib/ics.js b/dsh-plugins/calendar/lib/ics.js new file mode 100644 index 00000000..99245d94 --- /dev/null +++ b/dsh-plugins/calendar/lib/ics.js @@ -0,0 +1,532 @@ +// The smallest ICS (RFC 5545) subset the calendar module needs: enough to +// import a calendar someone exported or saved from another tool, and enough to +// write one back out. No third-party parser, no timezone database, no network. +// +// Supported: `VCALENDAR`, `VEVENT`, `UID`, `SUMMARY`, `DTSTART`, `DTEND`, +// `LOCATION`, `DESCRIPTION`, `RRULE` (the weekly subset the model understands), +// `EXDATE`, `RECURRENCE-ID`, `STATUS:CANCELLED`, line folding, text escaping and +// unknown properties (kept in `extensions` and written back out). +// Not supported: `VTIMEZONE`, `VALARM`, CalDAV, subscriptions, monthly/yearly +// rules, `RDATE` combinations. +// +// Four choices worth knowing when reading this file: +// +// - An unsupported rule is never dropped: the event degrades to a single +// occurrence and the original `RRULE` text stays in `extensions.RRULE`, so a +// later version can pick it up and nothing is lost silently. +// - Properties of an earlier shape of this plugin that named a course +// (`X-EDUWORK-COURSES`, `X-EDUWORK-COURSE-*`, `X-EDUWORK-NOTE`) are recognized +// and dropped instead of being kept as unknown extensions. They describe an +// entity this module no longer has, so a round trip should not carry it on. +// - All-day events: ICS `DTEND` is exclusive, our records treat `end` as the last +// day covered, so the two directions shift by one day. Timed events are +// literal. +// - Timezones: no conversion except for an explicit `Z` stamp, whose instant is +// unambiguous. A `TZID` parameter is kept in `extensions` and written back, but +// the wall clock is what this version stores. +import { WEEKDAYS } from './model.js' + +/** Property names an earlier shape of this plugin wrote for a course; read and discarded. */ +const RETIRED_PROPERTIES = new Set([ + 'X-EDUWORK-COURSES', + 'X-EDUWORK-COURSE-ID', + 'X-EDUWORK-COURSE-NAME', + 'X-EDUWORK-COURSE-TEACHER', + 'X-EDUWORK-COURSE-DEFAULT-LOCATION', + 'X-EDUWORK-NOTE', +]) + +const TIMEZONE_KEY = 'X-EDUWORK-TZID' + +/** Octets one content line may occupy, the folding limit of RFC 5545 §3.1. */ +const LINE_LIMIT = 75 + +const WEEKDAY_TO_CODE = { MO: 'mo', TU: 'tu', WE: 'we', TH: 'th', FR: 'fr', SA: 'sa', SU: 'su' } +const CODE_TO_WEEKDAY = Object.fromEntries(WEEKDAYS.map(code => [code, code.toUpperCase()])) + +/** Property values holding free text: escaped on the way out, unescaped on the way in. */ +const TEXT_PROPERTIES = new Set(['UID', 'SUMMARY', 'LOCATION', 'DESCRIPTION']) + +const isTextProperty = name => TEXT_PROPERTIES.has(name) || name.startsWith('X-') + +/** Properties a `VEVENT` is read for; anything else is preserved in `extensions`. */ +const CONSUMED_PROPERTIES = new Set([ + 'UID', 'SUMMARY', 'DTSTART', 'DTEND', 'LOCATION', 'DESCRIPTION', + 'RRULE', 'EXDATE', 'RECURRENCE-ID', 'STATUS', +]) + +const encoder = new TextEncoder() +const pad = value => String(value).padStart(2, '0') + +/** Octets a string occupies in UTF-8, which is what the folding limit counts. */ +const octets = value => encoder.encode(value).length + +/** `YYYY-MM-DD` plus whole days. */ +function shiftDay(date, days) { + const [year, month, day] = String(date).split('-').map(Number) + const moved = new Date(year, month - 1, day) + moved.setDate(moved.getDate() + days) + return `${moved.getFullYear()}-${pad(moved.getMonth() + 1)}-${pad(moved.getDate())}` +} + +/** The time part of a start, `T08:00`, or an empty string for a whole-day event. */ +const timePartOf = value => (String(value ?? '').includes('T') ? String(value).slice(10) : '') + +/** The moment an occurrence of a series begins: the series' own time on that date. */ +const occurrenceStart = (event, date) => `${date}${timePartOf(event.start)}` + +/** + * Unescape one TEXT value: `\\`, `\;`, `\,` and `\n`/`\N`. + * @param value - Raw property value. + * @returns the readable text. + */ +export function unescapeText(value) { + return String(value).replace(/\\([\\;,nN])/g, (_, escaped) => (escaped === 'n' || escaped === 'N' ? '\n' : escaped)) +} + +/** + * Escape one TEXT value, so commas, semicolons, backslashes and newlines survive + * the trip through a single content line. + * @param value - Readable text. + * @returns the property value. + */ +export function escapeText(value) { + return String(value) + .replace(/([\\;,])/g, '\\$1') + .replace(/\r\n|\r|\n/g, '\\n') +} + +/** + * Split a calendar into unfolded logical lines. Folding is invisible here: a + * line break followed by a space or tab continues the previous line, and both + * CRLF and bare LF files are accepted. + * @param text - Calendar text. + * @returns logical lines without their line breaks. + */ +export function unfoldLines(text) { + const normalized = String(text).replace(/^\uFEFF/, '').replace(/\r\n|\r/g, '\n') + const lines = [] + for (const physical of normalized.split('\n')) { + if (lines.length && (physical.startsWith(' ') || physical.startsWith('\t'))) lines[lines.length - 1] += physical.slice(1) + else lines.push(physical) + } + return lines +} + +/** + * Fold one content line at 75 octets, measuring bytes so a multi-byte character + * is never cut in half. + * @param line - One content line, without a line break. + * @returns the physical lines, without line breaks. + */ +export function foldLine(line) { + const chunks = [] + let current = '' + let size = 0 + for (const character of line) { + const width = octets(character) + if (size + width > LINE_LIMIT) { + chunks.push(current) + current = ' ' + size = 1 + } + current += character + size += width + } + chunks.push(current) + return chunks +} + +/** + * Split one content line into `NAME`, parameters and value. The colon that ends + * the name section is the first one outside a quoted parameter value. + * @param line - One unfolded content line. + * @returns `{ name, params, value }`, or undefined when the line is not a property. + */ +export function parseContentLine(line) { + const trimmed = String(line).trim() + if (!trimmed) return undefined + let quoted = false + let colon = -1 + for (let index = 0; index < trimmed.length; index += 1) { + const character = trimmed[index] + if (character === '"') quoted = !quoted + else if (character === ':' && !quoted) { colon = index; break } + } + if (colon < 0) return undefined + const head = trimmed.slice(0, colon) + const value = trimmed.slice(colon + 1) + const [rawName, ...rawParams] = head.split(';') + const params = {} + for (const raw of rawParams) { + const equals = raw.indexOf('=') + if (equals < 0) continue + params[raw.slice(0, equals).toUpperCase()] = raw.slice(equals + 1).replace(/^"|"$/g, '') + } + return { name: rawName.trim().toUpperCase(), params, value } +} + +/** + * Read the component tree of a calendar: every component keeps its own + * properties and children, so a `VALARM` inside a `VEVENT` can never be mistaken + * for a `VEVENT` property. + * @param text - Calendar text. + * @returns top-level components. + */ +export function parseComponents(text) { + const components = [] + const stack = [] + for (const line of unfoldLines(text)) { + const parsed = parseContentLine(line) + if (!parsed) continue + if (parsed.name === 'BEGIN') { + stack.push({ name: parsed.value.trim().toUpperCase(), properties: [], children: [] }) + continue + } + if (parsed.name === 'END') { + const component = stack.pop() + if (!component) continue + if (stack.length) stack[stack.length - 1].children.push(component) + else components.push(component) + continue + } + const current = stack[stack.length - 1] + if (current) current.properties.push(parsed) + } + return components +} + +/** Every property of a component with the given name, in file order. */ +const propertiesNamed = (component, name) => component.properties.filter(property => property.name === name) + +/** The first value of a component property, unescaped when it holds text. */ +function singleValue(component, name) { + const [property] = propertiesNamed(component, name) + if (!property) return undefined + const value = property.value.trim() + if (!value) return undefined + return isTextProperty(name) ? unescapeText(value) : value +} + +/** + * Read one date or date-time property value. + * @param value - Property value, for example `20260316T080000Z`. + * @param params - Property parameters, used for `VALUE=DATE`. + * @returns a local `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm` string, or undefined. + */ +export function parseDateValue(value, params = {}) { + const text = String(value ?? '').trim() + if (/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/.test(text)) return text + const dateOnly = text.match(/^(\d{4})(\d{2})(\d{2})$/) + if (dateOnly) return `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}` + const dateTime = text.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z?)$/) + if (!dateTime) return undefined + const [, year, month, day, hour, minute, , zone] = dateTime + if (params.VALUE === 'DATE') return `${year}-${month}-${day}` + // A UTC stamp is the one conversion this version performs, because the instant + // it names is unambiguous. Everything else is wall-clock local time. + if (zone === 'Z') { + const utc = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute))) + return `${utc.getFullYear()}-${pad(utc.getMonth() + 1)}-${pad(utc.getDate())}T${pad(utc.getHours())}:${pad(utc.getMinutes())}` + } + return `${year}-${month}-${day}T${hour}:${minute}` +} + +/** + * Format a local time string as an ICS property value, following the form of + * `reference`: an all-day reference yields `YYYYMMDD`, a clock reference yields + * `YYYYMMDDTHHMMSS`. + * @param value - `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`. + * @param reference - The value whose form decides the output form. + * @returns the property value. + */ +export function stamp(value, reference) { + const date = String(value).slice(0, 10).replace(/-/g, '') + if (!String(reference ?? '').includes('T')) return date + const clock = String(value).includes('T') ? String(value).slice(11, 16).replace(':', '') : '0000' + return `${date}T${clock}00` +} + +/** A positive integer from an RRULE part, or undefined when it is not one. */ +function positiveInteger(value) { + if (value === undefined) return undefined + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined +} + +/** + * Read an `RRULE` value into the supported recurrence subset. + * @param raw - Property value, for example `FREQ=WEEKLY;COUNT=16;BYDAY=MO,WE`. + * @returns `{ recurrence }` when it fits the subset, `{ unsupported }` with a reason otherwise. + */ +export function parseRrule(raw) { + const parts = String(raw ?? '').split(';').map(part => part.trim()).filter(Boolean) + const fields = {} + for (const part of parts) { + const equals = part.indexOf('=') + if (equals < 0) return { unsupported: `unreadable rule part ${part}` } + fields[part.slice(0, equals).toUpperCase()] = part.slice(equals + 1) + } + const allowed = new Set(['FREQ', 'INTERVAL', 'COUNT', 'UNTIL', 'BYDAY', 'WKST']) + // The frequency is checked first: "MONTHLY is unsupported" says more than the + // first part that happens to be unfamiliar. + if (String(fields.FREQ ?? '').toUpperCase() !== 'WEEKLY') return { unsupported: `unsupported frequency ${fields.FREQ ?? 'none'}` } + for (const key of Object.keys(fields)) if (!allowed.has(key)) return { unsupported: `unsupported rule part ${key}` } + if (fields.WKST && fields.WKST.toUpperCase() !== 'MO') return { unsupported: `unsupported week start ${fields.WKST}` } + const recurrence = { freq: 'weekly' } + if (fields.INTERVAL !== undefined) { + const interval = positiveInteger(fields.INTERVAL) + if (!interval) return { unsupported: `unreadable interval ${fields.INTERVAL}` } + recurrence.interval = interval + } + if (fields.COUNT !== undefined) { + const count = positiveInteger(fields.COUNT) + if (!count) return { unsupported: `unreadable count ${fields.COUNT}` } + recurrence.count = count + } + if (fields.UNTIL !== undefined) { + const until = parseDateValue(fields.UNTIL) + if (!until) return { unsupported: `unreadable until ${fields.UNTIL}` } + recurrence.until = until + } + if (fields.BYDAY !== undefined) { + const codes = fields.BYDAY.split(',').map(code => code.trim().toUpperCase()).filter(Boolean) + const byDay = codes.map(code => WEEKDAY_TO_CODE[code]) + if (!byDay.length || byDay.some(code => !code)) return { unsupported: `unreadable byday ${fields.BYDAY}` } + recurrence.byDay = byDay + } + return { recurrence } +} + +/** + * The `RRULE` property value for one recurrence. A date-only `until` is written + * as the end of that day, which is what it means to the expansion. + * @param recurrence - Supported recurrence, or undefined. + * @returns the property value, or undefined. + */ +export function formatRrule(recurrence) { + if (!recurrence) return undefined + const parts = ['FREQ=WEEKLY'] + if (recurrence.interval) parts.push(`INTERVAL=${recurrence.interval}`) + if (recurrence.count) parts.push(`COUNT=${recurrence.count}`) + if (recurrence.until) { + const until = String(recurrence.until) + parts.push(`UNTIL=${until.includes('T') ? stamp(until, until) : `${until.replace(/-/g, '')}T235959`}`) + } + if (recurrence.byDay?.length) parts.push(`BYDAY=${recurrence.byDay.map(code => CODE_TO_WEEKDAY[code] ?? String(code).toUpperCase()).join(',')}`) + return parts.join(';') +} + +/** + * The change one `RECURRENCE-ID` `VEVENT` asks for, expressed as an override: + * only the fields that differ from the series are kept. A field the overriding + * `VEVENT` did not carry at all is inherited, never pinned to a default. + * @param master - The series record. + * @param date - The date the series produced. + * @param record - The fields the overriding `VEVENT` carried. + * @param carried - Which of those fields the `VEVENT` actually named. + * @returns an override entry, possibly carrying only `date`. + */ +function overridePatch(master, date, record, carried) { + const patch = { date } + const inheritedStart = occurrenceStart(master, date) + if (carried.start && record.start !== inheritedStart) patch.start = record.start + const effectiveStart = patch.start ?? inheritedStart + if (carried.end && record.end) { + const inheritedEnd = master.end ? `${String(effectiveStart).slice(0, 10)}${timePartOf(master.end)}` : undefined + if (record.end !== inheritedEnd) patch.end = record.end + } + if (carried.title && record.title !== master.title) patch.title = record.title + if (carried.location && record.location !== master.location) patch.location = record.location + if (carried.description && record.description !== master.description) patch.description = record.description + return patch +} + +/** + * Read one `VEVENT` into the fields this module maps, or report why it cannot. + * @param component - The `VEVENT` component. + * @param source - Source stamp for the record. + * @returns `{ record, date, cancelled }` or `{ skipped }`. + */ +function readVevent(component, source) { + const startProperty = propertiesNamed(component, 'DTSTART')[0] + const start = startProperty ? parseDateValue(startProperty.value, startProperty.params) : undefined + const recurrenceProperty = propertiesNamed(component, 'RECURRENCE-ID')[0] + const recurrenceDate = recurrenceProperty ? parseDateValue(recurrenceProperty.value, recurrenceProperty.params) : undefined + const uid = singleValue(component, 'UID') + const cancelled = String(singleValue(component, 'STATUS') ?? '').toUpperCase() === 'CANCELLED' + if (!start) { + // A cancellation often carries only `RECURRENCE-ID` and `STATUS`, and the + // series it belongs to is the one this file also lists. + if (recurrenceDate && cancelled) return { uid, date: String(recurrenceDate).slice(0, 10), cancelled: true } + return { skipped: { uid, reason: 'a VEVENT without a readable DTSTART cannot be placed' } } + } + const endProperty = propertiesNamed(component, 'DTEND')[0] + const rawEnd = endProperty ? parseDateValue(endProperty.value, endProperty.params) : undefined + const allDay = !String(start).includes('T') + // ICS ends an all-day event on the next day, exclusively; our records hold the + // last day it covers. Timed ends are literal. + const end = rawEnd === undefined ? undefined : (!allDay ? rawEnd : (rawEnd > start ? shiftDay(rawEnd, -1) : undefined)) + const extensions = {} + for (const property of component.properties) { + if (CONSUMED_PROPERTIES.has(property.name) || RETIRED_PROPERTIES.has(property.name)) continue + extensions[property.name] = isTextProperty(property.name) ? unescapeText(property.value) : property.value + } + if (startProperty.params.TZID) extensions[TIMEZONE_KEY] = startProperty.params.TZID + const record = { + uid: uid || `${String(start).slice(0, 10)}-${(singleValue(component, 'SUMMARY') ?? 'event').slice(0, 24)}`, + title: singleValue(component, 'SUMMARY') || '未命名事件', + start, + source, + extensions, + } + if (end) record.end = end + const location = singleValue(component, 'LOCATION') + const description = singleValue(component, 'DESCRIPTION') + if (location !== undefined) record.location = location + if (description !== undefined) record.description = description + const rule = propertiesNamed(component, 'RRULE')[0] + const degraded = [] + if (rule && !recurrenceDate) { + const parsed = parseRrule(rule.value) + if (parsed.recurrence) record.recurrence = parsed.recurrence + else { + // Degrade instead of dropping: the original text stays readable and the + // event still shows up on its own start date. + extensions.RRULE = rule.value + degraded.push({ uid: record.uid, reason: parsed.unsupported, rrule: rule.value }) + } + } + const exceptions = [] + for (const property of propertiesNamed(component, 'EXDATE')) { + for (const part of property.value.split(',')) { + const date = parseDateValue(part, property.params) + if (date) exceptions.push(String(date).slice(0, 10)) + } + } + if (exceptions.length) record.exceptions = [...new Set(exceptions)].sort() + const summary = singleValue(component, 'SUMMARY') + return { + record, + uid: record.uid, + date: recurrenceDate ? String(recurrenceDate).slice(0, 10) : undefined, + cancelled, + degraded, + carried: { + title: summary !== undefined, + start: true, + end: rawEnd !== undefined, + location: location !== undefined, + description: description !== undefined, + }, + } +} + +/** + * Import one calendar. + * + * @param text - Calendar text. + * @param options - `source` stamps imported events (default `ics`), so a later + * import can replace exactly this source's records. + * @returns `{ events, skipped, degraded }`: records in the shape + * `importSnapshot` accepts, plus what was left out and what was degraded. + */ +export function parseIcs(text, options = {}) { + const source = typeof options.source === 'string' && options.source ? options.source : 'ics' + const components = parseComponents(text) + const calendar = components.find(component => component.name === 'VCALENDAR') + const vevents = (calendar ? calendar.children : components).filter(component => component.name === 'VEVENT') + + const events = [] + const byUid = new Map() + const pending = [] + const skipped = [] + const degraded = [] + + for (const component of vevents) { + const read = readVevent(component, source) + if (read.skipped) { skipped.push(read.skipped); continue } + degraded.push(...(read.degraded ?? [])) + if (read.cancelled && !read.record) { pending.push({ uid: read.uid, date: read.date, cancelled: true }); continue } + if (read.date) { pending.push({ uid: read.uid, date: read.date, cancelled: read.cancelled, record: read.record, carried: read.carried }); continue } + if (read.cancelled) { + // A cancelled series is left out, and said so rather than vanishing. + skipped.push({ uid: read.record.uid, reason: 'the event is cancelled' }) + continue + } + events.push(read.record) + byUid.set(read.record.uid, read.record) + } + + for (const entry of pending) { + const master = entry.uid ? byUid.get(entry.uid) : undefined + if (!master) { + // An override whose series is not in this file is still a lesson: keep it + // as an event of its own instead of dropping the time it carries. + if (entry.record) { events.push(entry.record); byUid.set(entry.record.uid, entry.record) } + else skipped.push({ uid: entry.uid, reason: `a cancellation for an unknown series (${entry.date})` }) + continue + } + if (entry.cancelled) { + master.exceptions = [...new Set([...(master.exceptions ?? []), entry.date])].sort() + continue + } + const patch = overridePatch(master, entry.date, entry.record, entry.carried ?? {}) + if (Object.keys(patch).length === 1) continue + const existing = master.overrides ?? [] + master.overrides = [...existing.filter(item => item.date !== patch.date), patch].sort((left, right) => left.date.localeCompare(right.date)) + } + + return { events, skipped, degraded } +} + +/** + * Export records as one calendar. + * + * @param snapshot - `{ events }`, the shape `snapshot()` returns. + * @param options - `name` sets `X-WR-CALNAME`. + * @returns calendar text with CRLF line endings and folded long lines. + */ +export function buildIcs(snapshot = {}, options = {}) { + const events = [...(snapshot.events ?? [])] + .sort((left, right) => left.start.localeCompare(right.start) || left.uid.localeCompare(right.uid)) + + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//EduWork//Calendar//CN', 'CALSCALE:GREGORIAN'] + if (options.name) lines.push(`X-WR-CALNAME:${escapeText(options.name)}`) + + for (const event of events) { + const allDay = !String(event.start).includes('T') + lines.push('BEGIN:VEVENT') + lines.push(`UID:${escapeText(event.uid)}`) + lines.push(`SUMMARY:${escapeText(event.title)}`) + lines.push(`DTSTART${allDay ? ';VALUE=DATE' : ''}:${stamp(event.start, event.start)}`) + if (event.end) lines.push(`DTEND${allDay ? ';VALUE=DATE' : ''}:${stamp(allDay ? shiftDay(event.end, 1) : event.end, event.start)}`) + if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`) + if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`) + const rule = formatRrule(event.recurrence) ?? event.extensions?.RRULE + if (rule) lines.push(`RRULE:${rule}`) + if (event.exceptions?.length) lines.push(`EXDATE:${event.exceptions.map(date => stamp(occurrenceStart(event, date), event.start)).join(',')}`) + for (const [key, value] of Object.entries(event.extensions ?? {})) { + if (key === 'RRULE' || RETIRED_PROPERTIES.has(key)) continue + const text = typeof value === 'string' ? value : JSON.stringify(value) + lines.push(`${key}:${isTextProperty(key) ? escapeText(text) : text}`) + } + lines.push('END:VEVENT') + + for (const override of event.overrides ?? []) { + const movedStart = override.start ?? occurrenceStart(event, override.date) + lines.push('BEGIN:VEVENT') + lines.push(`UID:${escapeText(event.uid)}`) + lines.push(`RECURRENCE-ID${allDay ? ';VALUE=DATE' : ''}:${stamp(occurrenceStart(event, override.date), event.start)}`) + if (override.title) lines.push(`SUMMARY:${escapeText(override.title)}`) + lines.push(`DTSTART${allDay ? ';VALUE=DATE' : ''}:${stamp(movedStart, event.start)}`) + if (override.end) lines.push(`DTEND${allDay ? ';VALUE=DATE' : ''}:${stamp(allDay ? shiftDay(override.end, 1) : override.end, event.start)}`) + if (override.location !== undefined) lines.push(`LOCATION:${escapeText(override.location)}`) + if (override.description !== undefined) lines.push(`DESCRIPTION:${escapeText(override.description)}`) + lines.push('END:VEVENT') + } + } + + lines.push('END:VCALENDAR') + return lines.flatMap(line => foldLine(line)).join('\r\n') + '\r\n' +} diff --git a/dsh-plugins/calendar/lib/index.js b/dsh-plugins/calendar/lib/index.js new file mode 100644 index 00000000..8d0fbd87 --- /dev/null +++ b/dsh-plugins/calendar/lib/index.js @@ -0,0 +1,240 @@ +// Host half of the calendar plugin. +// +// It owns the records and exposes them as the `calendar` service, so the client +// half, future tools and the reminder work all read one source of truth. The +// panel itself stays in the client half; this file registers no UI and no tool. +// +// The domain is opened during initialization: the service becomes visible only +// once stored records are loaded and validated, and a domain that cannot be +// opened fails this plugin loudly instead of handing out a half-working +// service. Nothing here leaves the machine. +// +// Methods marked `Remote` below take exactly one object argument and return one +// JSON value, which is the shape the remote gateway carries. Their parameter +// names and schemas live in `lib/typert-schemas.js`, their descriptors in +// `lib/typert-descriptors.js`, and their line numbers here are what the +// descriptors point at. +import { Service } from '@deepseek-ai/cordis' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { buildIcs, parseIcs } from './ics.js' +import { SCHEMA_VERSION } from './model.js' +import { CalendarStore } from './store.js' + +export const name = 'dsh-calendar' + +const remoteInitializers = [] + +/** The calendar service (`ctx.calendar`). Reads are synchronous; writes resolve after the record is durable. */ +export class CalendarService extends TypertRemoteService { + static inject = ['storageDomain'] + + /** + * @param ctx - Plugin context. + */ + constructor(ctx) { + super(ctx, 'calendar') + for (const initialize of remoteInitializers) initialize.call(this) + this.store = new CalendarStore(ctx) + ctx.effect(() => async () => { await this.store.close() }, 'dsh-calendar: close the calendar domain') + } + + /** Open the domain before the service is handed to anyone. */ + async [Service.init]() { + await this.store.open() + } + + /** @returns the public snapshot: `{ schemaVersion, events }`. */ + snapshot() { + return this.store.snapshot() + } + + /** + * @param range - Optional `{ from, to }` inclusive local-time bounds. + * @returns events overlapping the range, as stored. + */ + listEvents(range) { + return this.store.listEvents(range) + } + + /** + * One range of the calendar as a view needs it. `listEvents` and the store + * keep the plain record queries; this is the payload that crosses to the + * client half, and its shape is the one `lib/typert-schemas.js` declares. + * @param range - Optional `{ from, to }` inclusive local-time bounds. + * @returns `{ schemaVersion, occurrences }`. + */ + occurrences(range) { + return { + schemaVersion: SCHEMA_VERSION, + occurrences: this.store.listOccurrences(range), + } + } + + /** + * Import calendar text. The records keep the source they were imported under, + * so a second import of the same source replaces the first one instead of + * piling up next to it; anything the text did not carry is reported rather + * than dropped quietly. + * @param input - `{ text, source?, replace? }`; `source` defaults to `ics` and + * `replace` to true. + * @returns `{ events, removedEvents, skipped, degraded }`. + */ + async importIcs(input) { + const source = input.source ?? 'ics' + const parsed = parseIcs(input.text, { source }) + const written = await this.store.importSnapshot(parsed, { source, replace: input.replace !== false }) + return { ...written, skipped: parsed.skipped, degraded: parsed.degraded } + } + + /** + * Export the records as calendar text. + * @param options - `{ name? }`, the calendar name written as `X-WR-CALNAME`. + * @returns `{ text }`. + */ + exportIcs(options = {}) { + const snapshot = this.store.snapshot() + return { + text: buildIcs(snapshot, { name: options.name ?? '我的日历' }), + } + } + + /** @returns the stored event, or undefined. */ + getEvent(uid) { + return this.store.getEvent(uid) + } + + /** + * Insert or replace one event: how the panel creates a personal entry and how + * editing one of its own fields lands. The caller owns the `uid`, so a record + * can be written, corrected and replayed without asking the store for an + * identity first. + * @param event - Event fields. + * @returns the stored event. + */ + putEvent(event) { + return this.store.putEvent(event) + } + + /** + * Remove one event. + * @param input - `{ uid }`. + * @returns `{ removed }`. + */ + async deleteEvent(input) { + return { removed: await this.store.deleteEvent(input.uid) } + } + + /** + * Move, rename or relocate one date a series produces. + * @param input - `{ uid, date, patch }`: the series, the date it produces, and + * the fields to change. + * @returns the stored record. + */ + applyOverride(input) { + return this.store.applyOverride(input.uid, input.date, input.patch) + } + + /** + * Drop a per-date change, so that occurrence follows the series again. + * @param input - `{ uid, date }`. + * @returns the stored record. + */ + removeOverride(input) { + return this.store.removeOverride(input.uid, input.date) + } + + /** + * Cancel one date a series produces, so that occurrence does not happen. + * @param input - `{ uid, date }`. + * @returns the stored record. + */ + cancelOccurrence(input) { + return this.store.cancelOccurrence(input.uid, input.date) + } + + /** + * Undo a cancellation, so that occurrence follows the series again. + * @param input - `{ uid, date }`. + * @returns the stored record. + */ + restoreOccurrence(input) { + return this.store.restoreOccurrence(input.uid, input.date) + } + + /** + * Merge a snapshot in, keyed by `uid`. + * @param snapshot - `{ events }`. + * @param options - `source` and `replace`, as in `importIcs`. + * @returns counts of the records written and removed. + */ + importSnapshot(snapshot, options) { + return this.store.importSnapshot(snapshot, options) + } + + /** + * Observe durable changes. + * @param listener - Called with each change of this domain. + * @returns the disposer that stops observing. + */ + onChanged(listener) { + return this.store.onChanged(listener) + } +} + +// Mark the methods the client half may call. `Remote` records a versioned +// descriptor on the prototype and hands the instance initializer back here, so +// the exported surface is declared next to the class that implements it. The +// matching wire schemas are in `lib/typert-schemas.js`: a method marked here +// without a descriptor is not callable, and the descriptor test compares the two +// lists. +Remote('snapshot')(CalendarService.prototype.snapshot, { + kind: 'method', name: 'snapshot', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('occurrences')(CalendarService.prototype.occurrences, { + kind: 'method', name: 'occurrences', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('importIcs')(CalendarService.prototype.importIcs, { + kind: 'method', name: 'importIcs', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('exportIcs')(CalendarService.prototype.exportIcs, { + kind: 'method', name: 'exportIcs', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('applyOverride')(CalendarService.prototype.applyOverride, { + kind: 'method', name: 'applyOverride', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('removeOverride')(CalendarService.prototype.removeOverride, { + kind: 'method', name: 'removeOverride', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('cancelOccurrence')(CalendarService.prototype.cancelOccurrence, { + kind: 'method', name: 'cancelOccurrence', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('restoreOccurrence')(CalendarService.prototype.restoreOccurrence, { + kind: 'method', name: 'restoreOccurrence', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('deleteEvent')(CalendarService.prototype.deleteEvent, { + kind: 'method', name: 'deleteEvent', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +Remote('putEvent')(CalendarService.prototype.putEvent, { + kind: 'method', name: 'putEvent', static: false, private: false, + addInitializer(initializer) { remoteInitializers.push(initializer) }, +}) + +export default CalendarService diff --git a/dsh-plugins/calendar/lib/model.js b/dsh-plugins/calendar/lib/model.js new file mode 100644 index 00000000..11d79928 --- /dev/null +++ b/dsh-plugins/calendar/lib/model.js @@ -0,0 +1,326 @@ +// Event records for the public calendar module. +// +// This file is the single source of the public data shape: the durable record +// schema, the normalization applied before anything is stored, and the snapshot +// handed to consumers. It stays free of IO so it can be unit tested on its own. +// +// A calendar holds events, not courses: one record is one event (a single one, +// or the anchor of a series), and every grouping a data source wants — a school +// timetable, a mail thread, an activity — arrives as events of that source. The +// public shape therefore carries no course entity to keep in sync. +// +// Field names follow the ICS vocabulary so no single data source owns them: +// `uid`/`title`/`start`/`end`/`location`/`description` mirror `UID`/`SUMMARY`/ +// `DTSTART`/`DTEND`/`LOCATION`/`DESCRIPTION`, and anything unrecognized is kept +// verbatim in `extensions` (`X-*` on the way out). Times are local strings, not +// instants: the first version does no timezone conversion. +import { z } from 'zod' + +/** + * Version of the public snapshot shape. Bump only for incompatible changes. + * 2: the snapshot and the occurrence payload no longer carry a course list, and + * an event no longer carries `courseId`. + */ +export const SCHEMA_VERSION = 2 + +/** Local time string: `YYYY-MM-DD`, or `YYYY-MM-DDTHH:mm` where a clock applies. */ +export const LocalTimeSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/, 'expected YYYY-MM-DD or YYYY-MM-DDTHH:mm') + +/** Weekday codes accepted in `recurrence.byDay`, as in RRULE. */ +export const WEEKDAYS = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] + +/** + * Supported recurrence subset. Deliberately narrower than RRULE: rules outside + * it (monthly, yearly, BYMONTHDAY, BYSETPOS) are not represented here — the + * importer keeps their original RRULE text in `extensions` and degrades the + * event to a single occurrence instead of dropping it silently. + */ +export const RecurrenceSchema = z + .object({ + freq: z.literal('weekly'), + interval: z.number().int().positive().optional(), + count: z.number().int().positive().optional(), + until: LocalTimeSchema.optional(), + byDay: z.array(z.enum(WEEKDAYS)).optional(), + }) + .strict() + +/** + * One single-occurrence change to a series — the `RECURRENCE-ID` case of ICS: + * one date of the series is moved, renamed or relocated. `date` is the date the + * series itself produced, so the change survives later edits to the series + * time; every other field is optional and inherited from the series, which + * keeps "moved to Friday, room changed" down to two fields. + * + * A cancellation is not an override: it is listed in `exceptions` (`EXDATE`), + * and an extra date that belongs to no series is a plain event of its own. + */ +export const OverrideSchema = z + .object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'expected YYYY-MM-DD'), + title: z.string().min(1).optional(), + start: LocalTimeSchema.optional(), + end: LocalTimeSchema.optional(), + location: z.string().optional(), + description: z.string().optional(), + }) + .strict() + .refine(value => Object.keys(value).length > 1, 'an override must change at least one field') + +const OverridesSchema = z + .array(OverrideSchema) + .refine(list => new Set(list.map(entry => entry.date)).size === list.length, 'each date may be overridden once') + +/** + * Fields every event-shaped value carries — the stored record and the + * occurrence handed to a view. Shared so the two cannot drift apart. + */ +export const EventFields = { + uid: z.string().min(1), + title: z.string().min(1), + start: LocalTimeSchema, + end: LocalTimeSchema.optional(), + location: z.string().optional(), + description: z.string().optional(), + recurrence: RecurrenceSchema.optional(), + source: z.string().optional(), + extensions: z.record(z.string(), z.unknown()).optional(), +} + +/** Durable shape of one event: the shared fields plus series bookkeeping. */ +export const EventSchema = z + .object({ + ...EventFields, + exceptions: z.array(LocalTimeSchema).optional(), + overrides: OverridesSchema.optional(), + }) + .strict() + +/** + * Fields this contract retired. A store written before the change still holds + * them, so the medium is read with {@link StoredEventSchema} and rewritten into + * the current shape once (see `store.js`); the public contract above never + * accepts them, and neither does anything a caller can send. + */ +export const RETIRED_EVENT_FIELDS = ['courseId'] + +/** + * What a medium may hold: the record contract plus the fields it retired. Being + * able to read one is what lets a store written before the change open at all + * instead of taking the whole calendar down with it. + */ +export const StoredEventSchema = EventSchema.extend( + Object.fromEntries(RETIRED_EVENT_FIELDS.map(field => [field, z.unknown().optional()])), +) + +/** + * Drop the retired fields from one stored record. + * @param event - Record read from the medium. + * @returns the current shape, or `undefined` when there is nothing to drop. + */ +export function dropRetiredFields(event) { + const present = RETIRED_EVENT_FIELDS.filter(field => field in event) + if (present.length === 0) return undefined + const clean = { ...event } + for (const field of present) delete clean[field] + return clean +} + +/** + * Validate one event and fill its defaults. + * @param input - Event fields as supplied by a caller, an import or an adapter. + * @returns the durable record. + */ +export function normalizeEvent(input) { + const candidate = { ...input, source: input?.source ?? 'manual', extensions: input?.extensions ?? {} } + for (const key of ['end', 'location', 'description', 'recurrence', 'exceptions', 'overrides']) { + if (candidate[key] === undefined || candidate[key] === null) delete candidate[key] + } + return EventSchema.parse(candidate) +} + +/** Events in display order: by start, then by title for a stable tie-break. */ +export function compareEvents(left, right) { + return left.start.localeCompare(right.start) || left.title.localeCompare(right.title) || left.uid.localeCompare(right.uid) +} + +/** + * Build the public snapshot: what the client half renders and what export and + * injection read. Ordering is part of the shape so two runs over the same + * records produce the same document. + * @param events - Event records. + * @returns the versioned snapshot. + */ +export function buildSnapshot(events) { + return { + schemaVersion: SCHEMA_VERSION, + events: [...events].sort(compareEvents), + } +} + +/** Last instant an event occupies, used for range overlap. */ +function effectiveEnd(event) { + const end = event.end ?? event.start + return end.length === 10 ? `${end}T23:59` : end +} + +/** + * Whether an event overlaps a range. Bounds are inclusive and compared as local + * strings; an omitted bound is open on that side. A date-only upper bound means + * the whole of that day, which is what a week view asks for. + * @param event - Stored event or occurrence. + * @param range - `{ from, to }` bounds, either optional. + * @returns true when the event overlaps. + */ +export function overlaps(event, range = {}) { + const { from } = range + const to = range.to && range.to.length === 10 ? `${range.to}T23:59` : range.to + if (from && effectiveEnd(event) < from) return false + if (to && event.start > to) return false + return true +} + +const DAY_NAMES = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] +/** Monday-based offset of each RRULE weekday code. */ +const WEEKDAY_OFFSET = { mo: 0, tu: 1, we: 2, th: 3, fr: 4, sa: 5, su: 6 } + +const pad = value => String(value).padStart(2, '0') +/** Local midnight of a `YYYY-MM-DD` string. */ +function parseDate(date) { + const [year, month, day] = date.split('-').map(Number) + return new Date(year, month - 1, day) +} +/** Local `YYYY-MM-DD` of a `Date`. */ +function formatDate(value) { + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` +} +/** The date `days` away, as a local `YYYY-MM-DD`. */ +function shiftDate(date, days) { + const value = parseDate(date) + value.setDate(value.getDate() + days) + return formatDate(value) +} +/** Monday of the week a date belongs to. */ +function weekStart(date) { + return shiftDate(date, -((parseDate(date).getDay() + 6) % 7)) +} +/** Whole days from one local date to another. */ +function daysBetween(from, to) { + return Math.round((parseDate(to) - parseDate(from)) / 86400000) +} +/** `YYYY-MM-DD` part of a local time string. */ +function dateOf(value) { + return value.slice(0, 10) +} +/** `THH:mm` part of a local time string, empty for a whole-day value. */ +function timeOf(value) { + return value.length > 10 ? value.slice(10) : '' +} + +/** + * Dates one recurring event produces, from its own start up to `limit`. + * Iteration begins at the series anchor because RRULE counts `count` from + * `DTSTART`, and it stops at `limit`: callers that know an override may move an + * occurrence out of their window pass a wider limit instead of scanning forever. + */ +function recurrenceDates(recurrence, start, limit) { + const anchor = dateOf(start) + const seriesTime = timeOf(start) + const days = (recurrence.byDay?.length ? [...recurrence.byDay] : [DAY_NAMES[parseDate(anchor).getDay()]]) + .sort((left, right) => WEEKDAY_OFFSET[left] - WEEKDAY_OFFSET[right]) + const step = (recurrence.interval ?? 1) * 7 + const dates = [] + for (let week = weekStart(anchor); week <= limit; week = shiftDate(week, step)) { + for (const day of days) { + const date = shiftDate(week, WEEKDAY_OFFSET[day]) + if (date < anchor) continue + if (recurrence.until) { + const until = recurrence.until.length > 10 ? recurrence.until : `${recurrence.until}T23:59` + if (`${date}${seriesTime}` > until) return dates + } + if (recurrence.count && dates.length >= recurrence.count) return dates + dates.push(date) + } + } + return dates +} + +/** + * One occurrence of an event. `occurrenceDate` is the date the series produced + * and `occurrenceId` is the stable key built from it — the same key the view, + * a later reminder and any feature that hangs off one date of a series uses. + */ +function occurrenceOf(event, date, override) { + const { overrides: _overrides, exceptions: _exceptions, ...series } = event + const start = override?.start ?? `${date}${timeOf(event.start)}` + // The stored end keeps the number of days it sits past the start: a whole-day + // record covering three days, or a session that runs past midnight, must not + // have that end pulled back onto its own start date — it would claim to finish + // before it began, and a multi-day record would silently lose its span. The + // offset is applied to the occurrence's own start, so each week of a repeating + // whole-day series carries the same span. + const inheritedEnd = event.end + ? `${shiftDate(dateOf(start), daysBetween(dateOf(event.start), dateOf(event.end)))}${timeOf(event.end)}` + : undefined + const occurrence = { + ...series, + start, + occurrenceId: `${event.uid}#${date}`, + occurrenceDate: date, + } + const end = override?.end ?? inheritedEnd + if (end) occurrence.end = end + else delete occurrence.end + if (override?.title !== undefined) occurrence.title = override.title + if (override?.location !== undefined) occurrence.location = override.location + if (override?.description !== undefined) occurrence.description = override.description + if (override) occurrence.overridden = true + return occurrence +} + +/** + * Occurrences one event produces in a window. A single event yields at most one; + * `exceptions` cancel dates and `overrides` change them. Overrides may move an + * occurrence by weeks, so an event that carries any is scanned up to a year past + * the window before the window filter runs. + * @param event - Stored event. + * @param range - `{ from, to }` bounds, either optional. + * @returns the occurrences overlapping the range, in list order. + */ +export function occurrencesOf(event, range = {}) { + const from = range.from ?? '0000-01-01' + const to = range.to ?? '9999-12-31' + const anchor = dateOf(event.start) + const exceptions = new Set((event.exceptions ?? []).map(dateOf)) + const overrides = new Map((event.overrides ?? []).map(entry => [entry.date, entry])) + if (!event.recurrence) { + if (exceptions.has(anchor)) return [] + const single = occurrenceOf(event, anchor, overrides.get(anchor)) + return overlaps(single, range) ? [single] : [] + } + const dates = recurrenceDates(event.recurrence, event.start, overrides.size ? shiftDate(to, 366) : to) + const list = [] + for (const date of dates) { + if (exceptions.has(date)) continue + if (date > to && !overrides.has(date)) continue + list.push(occurrenceOf(event, date, overrides.get(date))) + } + return list.filter(occurrence => overlaps(occurrence, range)) +} + +/** Occurrences in display order: by start, then by the occurrence key. */ +export function compareOccurrences(left, right) { + return left.start.localeCompare(right.start) || left.title.localeCompare(right.title) || left.occurrenceId.localeCompare(right.occurrenceId) +} + +/** + * Occurrences of many events in one window, in display order. + * @param events - Stored events. + * @param range - `{ from, to }` bounds, either optional. + * @returns the sorted occurrences. + */ +export function occurrencesIn(events, range = {}) { + return events.flatMap(event => occurrencesOf(event, range)).sort(compareOccurrences) +} diff --git a/dsh-plugins/calendar/lib/store.js b/dsh-plugins/calendar/lib/store.js new file mode 100644 index 00000000..e158eb92 --- /dev/null +++ b/dsh-plugins/calendar/lib/store.js @@ -0,0 +1,253 @@ +// Local storage for calendar records. +// +// Records live in a storage-domain table, so writes are durable before they +// become visible, every stored record is validated against the schema in +// model.js at the durability boundary, and changes emit `domain/changed`. The +// module owns no file paths: the domain facility routes the domain to whatever +// backend the assembly configured. +// +// Nothing here leaves the machine. Records are never uploaded and never logged. +import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' +import { StoredEventSchema, buildSnapshot, dropRetiredFields, normalizeEvent, occurrencesIn, overlaps } from './model.js' + +/** + * Domain layout: events keyed by `uid`. + * + * The version stays 1 although the former `courses` table is gone: stored event + * records are still readable, and a backend reads only the tables this spec + * declares, so an existing store opens as it did and simply loses the retired + * table at the next write. Bumping the version instead would reject every store + * written before the change (the JSON backend requires an exact version on the + * whole-unit layout) over a table that is no longer read. + * + * The table is declared with {@link StoredEventSchema} rather than the public + * record schema, for the fields this contract retired: reading one has to be + * possible, and {@link CalendarStore.open} rewrites such a record into the + * current shape once, after which the two schemas accept the same records. + */ +export const calendarDomain = defineDomain({ + name: 'calendar', + version: 1, + tables: { + events: domainTable(StoredEventSchema), + }, +}) + +/** + * CRUD over the calendar domain. Callers must await {@link open} (the plugin + * does it while starting) before touching records; reads are synchronous from + * the domain's in-memory state, writes resolve only after durability. + */ +export class CalendarStore { + /** + * @param ctx - Plugin context carrying `storageDomain` and the domain events. + */ + constructor(ctx) { + this.ctx = ctx + this.domain = undefined + this.tables = undefined + } + + /** + * Open the domain, resolve the table handles, and fold records written before + * the record contract changed into the shape it now guarantees. + * @returns resolution once stored records are loaded, validated and folded. + */ + async open() { + const domain = await this.ctx.storageDomain.open(calendarDomain) + this.domain = domain + this.tables = { events: domain.table('events') } + await this.foldRetiredFields() + return domain + } + + /** + * Rewrite the records that still carry a retired field. One write per such + * record, and none at all for a store already in the current shape: the move + * happens on the first open after the upgrade and never runs again. + */ + async foldRetiredFields() { + const events = this.requireTables().events + for (const [uid, record] of [...events.entries()]) { + const clean = dropRetiredFields(record) + if (clean) await events.put(uid, clean) + } + } + + /** Close the domain, draining queued writes. Idempotent. */ + async close() { + await this.domain?.close() + } + + /** @returns the table handles; a caller before `open` is a bug and throws. */ + requireTables() { + if (!this.tables) throw new Error('calendar storage is not open yet') + return this.tables + } + + /** @returns the public snapshot of everything stored. */ + snapshot() { + const { events } = this.requireTables() + return buildSnapshot([...events.entries()].map(([, event]) => event)) + } + + /** + * Events overlapping a range, as stored: a weekly series contributes one + * record, not one per week. + * @param range - Optional `{ from, to }` inclusive local-time bounds. + * @returns matching events in display order. + */ + listEvents(range = {}) { + return this.snapshot().events.filter(event => overlaps(event, range)) + } + + /** + * What a range of the calendar actually holds: every date the stored series + * produce, minus `exceptions`, with `overrides` applied. This is what a view + * renders, and it is computed per call — no per-week copy is stored. + * @param range - Optional `{ from, to }` inclusive local-time bounds. + * @returns occurrences in display order. + */ + listOccurrences(range = {}) { + const events = [...this.requireTables().events.entries()].map(([, event]) => event) + return occurrencesIn(events, range) + } + + /** @returns the stored event, or undefined. */ + getEvent(uid) { + return this.requireTables().events.get(uid) + } + + /** + * Insert or replace one event. + * @param input - Event fields; validated and defaulted before the write. + * @returns the stored record. + */ + async putEvent(input) { + const record = normalizeEvent(input) + await this.requireTables().events.put(record.uid, record) + return record + } + + /** + * Remove one event. + * @param uid - Event identifier. + * @returns true when a record was removed. + */ + async deleteEvent(uid) { + return await this.requireTables().events.delete(uid) + } + + /** + * Record a per-date change to a series — the `RECURRENCE-ID` case: the + * occurrence the series produces on `date` is moved, renamed or relocated. + * Fields left out of `patch` keep following the series, and later edits to the + * series still reach this occurrence. + * @param uid - Identifier of the series event. + * @param date - Date the series produces, `YYYY-MM-DD`. + * @param patch - Fields to change: `title`, `start`, `end`, `location`, `description`. + * @returns the stored record. + */ + async applyOverride(uid, date, patch) { + const event = this.getEvent(uid) + if (!event) throw new Error(`no event ${uid} to override`) + const kept = (event.overrides ?? []).filter(entry => entry.date !== date) + const overrides = [...kept, { ...patch, date }].sort((left, right) => left.date.localeCompare(right.date)) + return await this.putEvent({ ...event, overrides }) + } + + /** + * Drop a per-date change, so that occurrence follows the series again. + * @param uid - Identifier of the series event. + * @param date - Date the series produces, `YYYY-MM-DD`. + * @returns the stored record. + */ + async removeOverride(uid, date) { + const event = this.getEvent(uid) + if (!event) throw new Error(`no event ${uid} to un-override`) + const overrides = (event.overrides ?? []).filter(entry => entry.date !== date) + return await this.putEvent({ ...event, overrides: overrides.length ? overrides : undefined }) + } + + /** + * Cancel one date a series produces — that occurrence does not happen. A move + * that had been scheduled for that date goes with it, because there is nothing + * left to move. + * @param uid - Identifier of the series event. + * @param date - Date the series produces, `YYYY-MM-DD`. + * @returns the stored record. + */ + async cancelOccurrence(uid, date) { + const event = this.getEvent(uid) + if (!event) throw new Error(`no event ${uid} to cancel`) + if (!event.recurrence) throw new Error(`${uid} does not repeat; delete it instead of cancelling one date`) + const exceptions = [...new Set([...(event.exceptions ?? []), date])].sort() + const overrides = (event.overrides ?? []).filter(entry => entry.date !== date) + return await this.putEvent({ ...event, exceptions, overrides: overrides.length ? overrides : undefined }) + } + + /** + * Undo a cancellation, so that occurrence follows the series again. + * @param uid - Identifier of the series event. + * @param date - Date the series produces, `YYYY-MM-DD`. + * @returns the stored record. + */ + async restoreOccurrence(uid, date) { + const event = this.getEvent(uid) + if (!event) throw new Error(`no event ${uid} to restore`) + const exceptions = (event.exceptions ?? []).filter(item => item !== date) + return await this.putEvent({ ...event, exceptions: exceptions.length ? exceptions : undefined }) + } + + /** + * Merge a snapshot into the store, keyed by `uid`: the entry point for imports + * and for data a source injects. A record that is already present is replaced, + * so importing the same snapshot twice is idempotent. Every record is + * validated before the first write, so a snapshot that is rejected part way + * through leaves the store as it was. + * + * With `replace`, the snapshot is the whole truth for its `source`: events + * already stored under that source which the snapshot does not list are + * removed. That is what makes re-importing an updated calendar remove entries + * that moved away, and it is also why the scope is the source: records from + * any other source, personal ones included, are never touched. + * + * @param snapshot - `{ events }`; `schemaVersion` is accepted and ignored for now. + * @param options - `source` for records that carry none; `replace` to also drop + * that source's records which the snapshot omits. + * @returns counts of the events written and removed. + */ + async importSnapshot(snapshot, options = {}) { + const source = typeof options.source === 'string' && options.source ? options.source : 'import' + if (!Array.isArray(snapshot?.events)) { + throw new Error('calendar snapshot must carry an events array') + } + const events = snapshot.events.map(event => normalizeEvent({ source, ...event })) + const { events: eventTable } = this.requireTables() + for (const event of events) await eventTable.put(event.uid, event) + let removedEvents = 0 + if (options.replace === true) { + const incoming = new Set(events.map(event => event.uid)) + const stale = [...eventTable.entries()] + .filter(([uid, event]) => event.source === source && !incoming.has(uid)) + .map(([uid]) => uid) + for (const uid of stale) { + await eventTable.delete(uid) + removedEvents += 1 + } + } + return { events: events.length, removedEvents } + } + + /** + * Observe durable changes. + * @param listener - Called with each `domain/changed` payload of this domain. + * @returns the disposer that stops observing. + */ + onChanged(listener) { + return this.ctx.on('domain/changed', change => { + if (change?.domain !== calendarDomain.name) return + listener(change) + }) + } +} diff --git a/dsh-plugins/calendar/lib/tool-permission.js b/dsh-plugins/calendar/lib/tool-permission.js new file mode 100644 index 00000000..318250d0 --- /dev/null +++ b/dsh-plugins/calendar/lib/tool-permission.js @@ -0,0 +1,31 @@ +// Who may change the calendar through an agent tool. +// +// Reading a range is ordinary work. Writing is not: the records live only on this +// machine and a wrong write is the user's own calendar, so every tool that writes +// asks first unless the session already runs with approvals disabled. The same +// decision is made for the whole group of tools in one place, so a new write tool +// cannot forget to opt in. + +/** Tools that change stored records. */ +export const WRITE_TOOLS = new Set([ + 'calendar_create_event', + 'calendar_update_event', + 'calendar_delete_event', + 'calendar_occurrence', +]) + +const WRITE_REASON = 'Allow the Agent to create, change or delete entries in the local calendar' + +/** + * Decide one calendar tool call. + * @param ctx - Plugin context, for the current permission preset. + * @param exec - The pending tool call. + * @param next - Continues to the next decider. + * @returns Nothing when the call proceeds, otherwise a decision. + */ +export function decideCalendarPermission(ctx, exec, next) { + if (!WRITE_TOOLS.has(exec.name)) return next() + if (!exec.agent) return Promise.resolve({ kind: 'deny', reason: 'Calendar writes require an Agent-backed session' }) + if (ctx.permissionPresets.current(exec.agent.session) === 'danger-full-access') return next() + return Promise.resolve({ kind: 'ask', reason: WRITE_REASON }) +} diff --git a/dsh-plugins/calendar/lib/tools.js b/dsh-plugins/calendar/lib/tools.js new file mode 100644 index 00000000..73a81afb --- /dev/null +++ b/dsh-plugins/calendar/lib/tools.js @@ -0,0 +1,376 @@ +// Agent tools over the calendar service. +// +// The weekly panel is no longer the only writer. These tools let a session read a +// range of the calendar and change it, and they call the same `calendar` service +// the client half reaches over the remote gateway — in process, so there is one +// source of truth and one set of validation rules. Who may write is decided in +// `lib/tool-permission.js`, not here. +// +// Two rules shape the surface. Every window is bounded, because a weekly series +// with no end expands up to whatever bound it is given, and a caller that leaves +// both bounds out would ask for four thousand years of dates. And every write +// takes the fields it changes rather than a whole record, because a record that +// has to be supplied in full is a record a model can quietly lose fields from. +import { randomUUID } from 'node:crypto' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { decideCalendarPermission } from './tool-permission.js' + +export const name = 'dsh-calendar-tools' +export const inject = ['tools', 'calendar', 'permissionPresets'] + +/** Slack used when a caller leaves a bound out, as the panel's own agenda does. */ +const PAST_DAYS = 180 +const FUTURE_DAYS = 366 +/** Widest window one call may ask for; a series is expanded inside it. */ +const MAX_WINDOW_DAYS = 3660 + +const pad2 = value => String(value).padStart(2, '0') +/** Local `YYYY-MM-DD` of a date. */ +const localDate = (value = new Date()) => `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}` + +/** Local date moved by whole days. The host half keeps its own copy in `model.js`. */ +function shiftDate(date, days) { + const [year, month, day] = date.split('-').map(Number) + return localDate(new Date(year, month - 1, day + days)) +} + +const daysBetween = (from, to) => + Math.round((Date.parse(`${to}T00:00`) - Date.parse(`${from}T00:00`)) / 86_400_000) + +const dateOf = value => value.slice(0, 10) +/** Clock part of a local time value, empty for a whole-day one. */ +const clockOf = value => (value.length > 10 ? value.slice(11, 16) : '') +const clockMinutes = clock => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5)) +const clockAt = minutes => `${pad2(Math.floor(minutes / 60))}:${pad2(minutes % 60)}` + +/** Drop the fields a call left out, so a patch carries only what it changes. */ +function withoutEmpty(input) { + const output = {} + for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null || value === '') continue + output[key] = value + } + return output +} + +/** + * The window one call reads. + * + * Both bounds are inclusive local dates, and one is derived when only the other + * was given, so `from` alone still means a bounded question. + * @param args - Tool arguments. + * @returns `{ from, to }`. + */ +function resolveRange(args) { + const today = localDate() + const from = args.from ?? shiftDate(today, -PAST_DAYS) + const to = args.to ?? shiftDate(args.from ? from : today, FUTURE_DAYS) + if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) { + throw new Error('from and to are local dates written as YYYY-MM-DD') + } + if (to < from) throw new Error(`to (${to}) is earlier than from (${from})`) + if (daysBetween(from, to) > MAX_WINDOW_DAYS) { + throw new Error(`that window is wider than ${MAX_WINDOW_DAYS} days; ask for a narrower one`) + } + return { from, to } +} + +/** The fields of one stored record that a caller reads back. */ +function recordFields(event) { + return withoutEmpty({ + uid: event.uid, + title: event.title, + start: event.start, + end: event.end, + location: event.location, + source: event.source, + until: event.recurrence?.until, + repeats: Boolean(event.recurrence), + }) +} + +/** One line describing one occurrence, as both the model and the reader see it. */ +function lineOf(entry) { + const clock = clockOf(entry.start) + const end = entry.end ? clockOf(entry.end) : '' + const when = clock === '' + ? entry.end && dateOf(entry.end) > dateOf(entry.start) + ? `${entry.date} – ${dateOf(entry.end)} 全天` + : `${entry.date} 全天` + : end === '' + ? `${entry.date} ${clock}` + : `${entry.date} ${clock}–${end}` + return [ + when, + entry.title, + entry.location ? `· ${entry.location}` : '', + entry.repeats ? '· 每周重复' : '', + `[uid ${entry.uid}]`, + ].filter(Boolean).join(' ') +} + +/** The text one write answers with, built from the record it stored. */ +function recordLine(prefix, event) { + const clock = clockOf(event.start) + const end = event.end ? clockOf(event.end) : '' + const when = clock === '' + ? (event.end ? `${dateOf(event.start)} – ${dateOf(event.end)} 全天` : `${dateOf(event.start)} 全天`) + : end === '' + ? `${dateOf(event.start)} ${clock}` + : `${dateOf(event.start)} ${clock}–${end}` + return `${prefix} ${when} ${event.title}${event.location ? ` · ${event.location}` : ''}${event.recurrence ? ' · 每周重复' : ''} [uid ${event.uid}]` +} + +/** + * The end an entry keeps when its start moved without a new end. + * + * The duration is what a reader means by "move it to five", so it is carried + * over: the same number of minutes for a timed entry, the same span in days for a + * whole-day one. An entry that carried no end, or whose old end cannot be read as + * a clock, keeps none. + * @param existing - The stored record. + * @param start - The new start. + * @returns The new end, or `undefined`. + */ +function movedEnd(existing, start) { + if (!existing.end) return undefined + if (clockOf(start) === '' || clockOf(existing.start) === '') { + if (clockOf(start) !== '' || clockOf(existing.start) === '') return undefined + if (clockOf(existing.end) !== '') return undefined + return shiftDate(dateOf(existing.end), daysBetween(dateOf(existing.start), dateOf(start))) + } + if (clockOf(existing.end) === '') return undefined + const duration = clockMinutes(clockOf(existing.end)) - clockMinutes(clockOf(existing.start)) + if (duration <= 0) return undefined + return `${dateOf(start)}T${clockAt(Math.min(23 * 60 + 59, clockMinutes(clockOf(start)) + duration))}` +} + +/** + * The record one update describes, on top of the record it changes. + * + * Only what the call carries moves: a field left out keeps its stored value and an + * empty string clears one. Turning the repeat off drops the dates that were + * cancelled or moved while it was a series, exactly as the panel does, because a + * single entry owns no series bookkeeping. + * @param existing - The stored record. + * @param args - Tool arguments. + * @returns The record to store. + */ +function updatedRecord(existing, args) { + const record = { ...existing } + const moves = args.start !== undefined + if (moves) record.start = args.start + if (args.end !== undefined) record.end = args.end === '' ? undefined : args.end + else if (moves) record.end = movedEnd(existing, args.start) + if (args.title !== undefined) record.title = args.title + if (args.location !== undefined) record.location = args.location === '' ? undefined : args.location + if (args.description !== undefined) record.description = args.description === '' ? undefined : args.description + if (args.repeat === 'none') { + record.recurrence = undefined + record.exceptions = undefined + record.overrides = undefined + } else if (args.repeat === 'weekly' || (args.until !== undefined && existing.recurrence)) { + record.recurrence = withoutEmpty({ ...(existing.recurrence ?? {}), freq: 'weekly', until: args.until }) + } + return record +} + +const occurrenceSchema = { + type: 'object', + additionalProperties: false, + properties: { + uid: { type: 'string', required: true, description: 'Identity of the stored record, for a later write.' }, + date: { type: 'string', required: true, description: 'The date this occurrence falls on.' }, + title: { type: 'string', required: true }, + start: { type: 'string', required: true, description: 'Local start, `YYYY-MM-DDTHH:mm` or `YYYY-MM-DD`.' }, + end: { type: 'string' }, + location: { type: 'string' }, + source: { type: 'string' }, + repeats: { type: 'boolean', required: true }, + }, +} + +const recordSchema = { + type: 'object', + additionalProperties: false, + properties: { + uid: { type: 'string', required: true }, + title: { type: 'string', required: true }, + start: { type: 'string', required: true }, + end: { type: 'string' }, + location: { type: 'string' }, + source: { type: 'string' }, + until: { type: 'string' }, + repeats: { type: 'boolean', required: true }, + }, +} + +const timeParameter = description => ({ + type: 'string', + description: `${description} A local wall-clock time written as YYYY-MM-DDTHH:mm, or YYYY-MM-DD for a whole-day value.`, +}) + +/** + * Register the calendar tools. + * @param ctx - Plugin context. + */ +export function apply(ctx) { + ctx.on('tools/pre-execute', (exec, next) => decideCalendarPermission(ctx, exec, next)) + + ctx.tools.register(defineTool({ + name: 'calendar_list_events', + description: 'Read this machine\'s local calendar over a range of dates, as the weekly panel shows it: one entry per date a repeating entry produces, with cancelled dates left out and moved dates applied. Times are local wall-clock times and carry no time zone. An entry only appears here if it overlaps the window, so widen the window rather than assuming a series has ended.', + parameters: { + from: { type: 'string', description: 'First local date to include, `YYYY-MM-DD`. Defaults to 180 days before today.' }, + to: { type: 'string', description: 'Last local date to include, `YYYY-MM-DD`. Defaults to a year after `from`.' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + from: { type: 'string', required: true }, + to: { type: 'string', required: true }, + count: { type: 'integer', required: true }, + events: { type: 'array', required: true, items: occurrenceSchema }, + }, + }, + render: (_args, value) => [{ + type: 'text', + text: value.count === 0 + ? `${value.from} – ${value.to}:这段日期没有日程。` + : `${value.from} – ${value.to},共 ${value.count} 条:\n${value.events.map(lineOf).join('\n')}`, + }], + }, + execute(args) { + const { from, to } = resolveRange(args) + const occurrences = ctx.calendar.occurrences({ from, to }).occurrences + return { + from, + to, + count: occurrences.length, + events: occurrences.map(occurrence => withoutEmpty({ + uid: occurrence.uid, + date: occurrence.occurrenceDate, + title: occurrence.title, + start: occurrence.start, + end: occurrence.end, + location: occurrence.location, + source: occurrence.source, + repeats: Boolean(occurrence.recurrence), + })), + } + }, + presentCall: args => ({ card: 'generic', title: 'Calendar · 读日程', kind: 'read', rawInput: `${args.from ?? ''} – ${args.to ?? ''}`.trim() }), + })) + + ctx.tools.register(defineTool({ + name: 'calendar_create_event', + description: 'Create one entry in this machine\'s local calendar. Times are local wall-clock times without a time zone. `repeat: "weekly"` repeats on the weekday of `start`, and `until` is the last date it happens. The entry is written immediately, so ask the user before creating something they did not ask for.', + parameters: { + title: { type: 'string', required: true, description: 'What the entry is called, for example 组会.' }, + start: { ...timeParameter('When it begins.'), required: true }, + end: timeParameter('When it ends. Defaults to the start time with no end, which the panel shows as a short block.'), + location: { type: 'string', description: 'Where it happens.' }, + description: { type: 'string', description: 'A note kept with the entry.' }, + repeat: { type: 'string', enum: ['none', 'weekly'], description: 'Set to weekly to repeat every week.' }, + until: { type: 'string', description: 'Last date of the repetition, `YYYY-MM-DD`. Only with `repeat: "weekly"`.' }, + }, + output: { + schema: { type: 'object', additionalProperties: false, properties: { event: { ...recordSchema, required: true } } }, + render: (_args, value) => [{ type: 'text', text: recordLine('已新建:', value.event) }], + }, + async execute(args) { + const record = withoutEmpty({ + uid: randomUUID(), + title: args.title, + start: args.start, + end: args.end, + location: args.location, + description: args.description, + recurrence: args.repeat === 'weekly' ? withoutEmpty({ freq: 'weekly', until: args.until }) : undefined, + }) + return { event: recordFields(await ctx.calendar.putEvent(record)) } + }, + presentCall: args => ({ card: 'generic', title: `Calendar · 新建 ${args.title}`, kind: 'write', rawInput: `${args.start ?? ''} ${args.title ?? ''}`.trim() }), + })) + + ctx.tools.register(defineTool({ + name: 'calendar_update_event', + description: 'Change one stored entry of this machine\'s local calendar, identified by the `uid` a read returned. Only the fields the call carries change; an empty string clears `location` or `description`. A moved start keeps the entry\'s duration unless `end` is given. This changes the whole entry, including every date of a repeating one: use `calendar_occurrence` to change a single date.', + parameters: { + uid: { type: 'string', required: true, description: 'Identity of the stored entry, as returned by a read.' }, + title: { type: 'string' }, + start: timeParameter('New start.'), + end: timeParameter('New end.'), + location: { type: 'string', description: 'New place; an empty string removes it.' }, + description: { type: 'string', description: 'New note; an empty string removes it.' }, + repeat: { type: 'string', enum: ['none', 'weekly'], description: 'Set to weekly to repeat every week, none to make it a single entry.' }, + until: { type: 'string', description: 'Last date of the repetition, `YYYY-MM-DD`.' }, + }, + output: { + schema: { type: 'object', additionalProperties: false, properties: { event: { ...recordSchema, required: true } } }, + render: (_args, value) => [{ type: 'text', text: recordLine('已更新:', value.event) }], + }, + async execute(args) { + const existing = ctx.calendar.getEvent(args.uid) + if (!existing) throw new Error(`没有 uid 为 ${args.uid} 的日程`) + return { event: recordFields(await ctx.calendar.putEvent(updatedRecord(existing, args))) } + }, + presentCall: args => ({ card: 'generic', title: 'Calendar · 改日程', kind: 'write', rawInput: args.uid }), + })) + + ctx.tools.register(defineTool({ + name: 'calendar_delete_event', + description: 'Delete one stored entry of this machine\'s local calendar, including every date of a repeating one. Use `calendar_occurrence` with action `cancel` to drop a single date instead. Deleting what is not there reports `removed: false` rather than failing.', + parameters: { + uid: { type: 'string', required: true, description: 'Identity of the stored entry, as returned by a read.' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { uid: { type: 'string', required: true }, removed: { type: 'boolean', required: true } }, + }, + render: (_args, value) => [{ type: 'text', text: value.removed ? `已删除 ${value.uid}` : `没有 uid 为 ${value.uid} 的日程,未做改动` }], + }, + async execute(args) { + const { removed } = await ctx.calendar.deleteEvent({ uid: args.uid }) + return { uid: args.uid, removed } + }, + presentCall: args => ({ card: 'generic', title: 'Calendar · 删除日程', kind: 'write', rawInput: args.uid }), + })) + + ctx.tools.register(defineTool({ + name: 'calendar_occurrence', + description: 'Change one date a repeating entry produces, leaving the rest of the series alone — the `RECURRENCE-ID` case. `move` changes that date only, `reset` makes it follow the series again, `cancel` drops it, and `restore` brings a cancelled date back. A single, non-repeating entry has no dates to act on: update or delete it instead.', + parameters: { + uid: { type: 'string', required: true, description: 'Identity of the repeating entry, as returned by a read.' }, + date: { type: 'string', required: true, description: 'The date the series produces, `YYYY-MM-DD`.' }, + action: { type: 'string', required: true, enum: ['move', 'reset', 'cancel', 'restore'] }, + start: timeParameter('New start for this date, for action `move`.'), + end: timeParameter('New end for this date, for action `move`.'), + title: { type: 'string', description: 'New title for this date, for action `move`.' }, + location: { type: 'string', description: 'New place for this date, for action `move`.' }, + }, + output: { + schema: { type: 'object', additionalProperties: false, properties: { event: { ...recordSchema, required: true } } }, + render: (args, value) => [{ + type: 'text', + text: recordLine(`已${args.action === 'move' ? '改期' : args.action === 'reset' ? '恢复为每周' : args.action === 'cancel' ? '取消这一次' : '恢复这一次'}:`, value.event), + }], + }, + async execute(args) { + const patch = withoutEmpty({ start: args.start, end: args.end, title: args.title, location: args.location }) + const input = { uid: args.uid, date: args.date, patch } + if (args.action === 'move') { + if (Object.keys(patch).length === 0) throw new Error('move 需要至少给出 start、end、title 或 location 之一') + return { event: recordFields(await ctx.calendar.applyOverride(input)) } + } + if (args.action === 'reset') return { event: recordFields(await ctx.calendar.removeOverride(input)) } + if (args.action === 'cancel') return { event: recordFields(await ctx.calendar.cancelOccurrence(input)) } + return { event: recordFields(await ctx.calendar.restoreOccurrence(input)) } + }, + presentCall: args => ({ card: 'generic', title: `Calendar · ${args.action} 单次`, kind: 'write', rawInput: `${args.date ?? ''} ${args.uid ?? ''}`.trim() }), + })) +} diff --git a/dsh-plugins/calendar/lib/typert-descriptors.js b/dsh-plugins/calendar/lib/typert-descriptors.js new file mode 100644 index 00000000..925f1e78 --- /dev/null +++ b/dsh-plugins/calendar/lib/typert-descriptors.js @@ -0,0 +1,57 @@ +// The one descriptor list both faces of the calendar remote surface publish. +// +// The host manifest (`lib/typert.host.js`) and the client mount +// (`lib/typert.remote-client.js`) have to declare the same methods, the same +// argument names and the same schemas; building both from this list is what makes +// that true by construction instead of by review. `sourceLocation` points at the +// method that implements the descriptor in `lib/index.js`. +import { + cancelParameter, + deleteEventParameter, + eventParameter, + eventResult, + exportParameter, + exportResult, + importParameter, + importResult, + occurrencesResult, + overrideParameter, + rangeParameter, + removalResult, + removeOverrideParameter, + restoreParameter, + snapshotResult, +} from './typert-schemas.js' + +const pkg = '@eduwork/dsh-calendar' + +/** + * @param method - Method name, which is also the service's own method name. + * @param line - Line of the method in `lib/index.js`. + * @param parameters - One entry per argument, in call order. + * @param result - Result type of the method. + * @returns a frozen descriptor. + */ +const descriptor = (method, line, parameters, result) => Object.freeze({ + id: `${pkg}#calendar/${method}`, + service: 'calendar', + namespace: 'calendar', + method, + invocation: { kind: 'direct' }, + parameters: Object.freeze(parameters), + result, + sourceLocation: { file: 'dsh-plugins/calendar/lib/index.js', line, column: 3 }, +}) + +export const descriptors = Object.freeze([ + descriptor('snapshot', 47, [], snapshotResult), + descriptor('occurrences', 66, [rangeParameter], occurrencesResult), + descriptor('importIcs', 82, [importParameter], importResult), + descriptor('exportIcs', 94, [exportParameter], exportResult), + descriptor('deleteEvent', 123, [deleteEventParameter], removalResult), + descriptor('applyOverride', 133, [overrideParameter], eventResult), + descriptor('removeOverride', 142, [removeOverrideParameter], eventResult), + descriptor('cancelOccurrence', 151, [cancelParameter], eventResult), + descriptor('restoreOccurrence', 160, [restoreParameter], eventResult), + descriptor('putEvent', 114, [eventParameter], eventResult), +]) diff --git a/dsh-plugins/calendar/lib/typert-schemas.js b/dsh-plugins/calendar/lib/typert-schemas.js new file mode 100644 index 00000000..c4cfdd1e --- /dev/null +++ b/dsh-plugins/calendar/lib/typert-schemas.js @@ -0,0 +1,120 @@ +// Wire schemas of the calendar remote face. +// +// The snapshot that crosses to the client half is exactly the shape the domain +// stores, so the record schemas are reused rather than restated: a change to a +// stored record cannot silently disagree with the wire contract. Occurrences are +// the stored fields minus series bookkeeping, plus the keys a view renders by. +// Every remote method takes one object argument and returns one value; the +// descriptors that point at those schemas are in `lib/typert-descriptors.js`. +import { z } from 'zod' +import { EventFields, EventSchema, LocalTimeSchema } from './model.js' + +const snapshot = z.object({ + schemaVersion: z.number().int().positive(), + events: z.array(EventSchema), +}).strict() + +const occurrence = z.object({ + ...EventFields, + occurrenceId: z.string().min(1), + occurrenceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'expected YYYY-MM-DD'), + overridden: z.boolean().optional(), +}).strict() + +const occurrences = z.object({ + schemaVersion: z.number().int().positive(), + occurrences: z.array(occurrence), +}).strict() + +/** Bounds of a range query; either side may be omitted. */ +export const rangeSchema = z.object({ + from: LocalTimeSchema.optional(), + to: LocalTimeSchema.optional(), +}).strict() + +/** A day of a series, as the occurrence itself names it. */ +const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'expected YYYY-MM-DD') + +/** Calendar text to import, and how it should land. */ +const importInput = z.object({ + text: z.string().min(1), + source: z.string().min(1).optional(), + replace: z.boolean().optional(), +}).strict() + +/** What an import wrote, what it removed, and what it could not use. */ +const importOutcome = z.object({ + events: z.number().int().nonnegative(), + removedEvents: z.number().int().nonnegative(), + skipped: z.array(z.object({ uid: z.string().optional(), reason: z.string() }).strict()), + degraded: z.array(z.object({ + uid: z.string().optional(), + reason: z.string().optional(), + rrule: z.string().optional(), + }).strict()), +}).strict() + +/** What to call the exported calendar. */ +const exportInput = z.object({ + name: z.string().min(1).optional(), +}).strict() + +const exportOutcome = z.object({ text: z.string() }).strict() + +/** One date of one series. */ +const occurrenceInput = z.object({ uid: z.string().min(1), date: dateSchema }).strict() + +/** The changeable fields of one occurrence; `date` is the series' own date. */ +const overrideInput = z.object({ + uid: z.string().min(1), + date: dateSchema, + patch: z.object({ + title: z.string().min(1).optional(), + start: LocalTimeSchema.optional(), + end: LocalTimeSchema.optional(), + location: z.string().optional(), + description: z.string().optional(), + }).strict(), +}).strict() + +const uidInput = z.object({ uid: z.string().min(1) }).strict() + +const removalOutcome = z.object({ removed: z.boolean() }).strict() + +/** Wrap a zod schema in the descriptor shape the gateway reads. */ +const result = (typeSymbol, schema) => Object.freeze({ + mode: 'strict', + create() { return this.schema }, + typeSymbol, + schema, +}) + +/** One JSON argument of one remote method, named the way the method reads it. */ +const jsonParameter = (method, name, schema) => Object.freeze({ + name, + wire: name, + source: 'json', + codec: Object.freeze({ + mode: 'strict', + create() { return this.schema }, + typeSymbol: `@eduwork/dsh-calendar#calendar/${method}:${name}`, + schema, + }), +}) + +export const rangeParameter = jsonParameter('occurrences', 'range', rangeSchema) +export const importParameter = jsonParameter('importIcs', 'input', importInput) +export const exportParameter = jsonParameter('exportIcs', 'options', exportInput) +export const overrideParameter = jsonParameter('applyOverride', 'input', overrideInput) +export const removeOverrideParameter = jsonParameter('removeOverride', 'input', occurrenceInput) +export const cancelParameter = jsonParameter('cancelOccurrence', 'input', occurrenceInput) +export const restoreParameter = jsonParameter('restoreOccurrence', 'input', occurrenceInput) +export const deleteEventParameter = jsonParameter('deleteEvent', 'input', uidInput) +export const eventParameter = jsonParameter('putEvent', 'event', EventSchema) + +export const snapshotResult = result('@eduwork/dsh-calendar#CalendarSnapshot', snapshot) +export const occurrencesResult = result('@eduwork/dsh-calendar#CalendarOccurrences', occurrences) +export const importResult = result('@eduwork/dsh-calendar#CalendarImport', importOutcome) +export const exportResult = result('@eduwork/dsh-calendar#CalendarExport', exportOutcome) +export const eventResult = result('@eduwork/dsh-calendar#CalendarEvent', EventSchema) +export const removalResult = result('@eduwork/dsh-calendar#CalendarRemoval', removalOutcome) diff --git a/dsh-plugins/calendar/lib/typert.host.js b/dsh-plugins/calendar/lib/typert.host.js new file mode 100644 index 00000000..44bc014c --- /dev/null +++ b/dsh-plugins/calendar/lib/typert.host.js @@ -0,0 +1,20 @@ +// Host face of the calendar remote surface. +// +// typert-loader imports this module through the package's "./typert" export and +// registers the manifest, which is what makes `remote.calendar` callable from +// the client half. `package` must equal the installed package name, or the +// loader rejects the manifest and names the mismatch. The descriptor list is +// shared with the client face, so the two cannot drift apart. +import { descriptors } from './typert-descriptors.js' + +const pkg = '@eduwork/dsh-calendar' + +export const TYPERT = { + package: pkg, + face: 'host', + schemas: [], + invocations: descriptors, + model: { services: [], events: [], objects: [] }, +} + +export default TYPERT diff --git a/dsh-plugins/calendar/lib/typert.remote-client.js b/dsh-plugins/calendar/lib/typert.remote-client.js new file mode 100644 index 00000000..36b20f35 --- /dev/null +++ b/dsh-plugins/calendar/lib/typert.remote-client.js @@ -0,0 +1,11 @@ +// Client face of the calendar remote surface. +// +// The client half mounts this with `ctx.remote.$mount(...)` before it may call +// `remote.calendar`. The descriptor list is the same object the host manifest +// publishes, so a method the host does not declare cannot be mounted here. +import { descriptors } from './typert-descriptors.js' + +export default { + package: '@eduwork/dsh-calendar', + descriptors, +} diff --git a/dsh-plugins/calendar/package.json b/dsh-plugins/calendar/package.json new file mode 100644 index 00000000..dba46c05 --- /dev/null +++ b/dsh-plugins/calendar/package.json @@ -0,0 +1,54 @@ +{ + "name": "@eduwork/dsh-calendar", + "version": "0.1.0-dev", + "private": true, + "license": "MIT", + "description": "Weekly calendar panel for the EduWork workbench", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./tools": "./lib/tools.js", + "./client": "./lib/client.js", + "./typert": "./lib/typert.host.js", + "./remote": "./lib/typert.remote-client.js", + "./package.json": "./package.json" + }, + "files": ["lib/**/*.js", "README.md", "README_EN.md"], + "scripts": { + "build:client": "powershell -ExecutionPolicy Bypass -File ./build-client.ps1", + "test": "node --check lib/client.js && node --test test/*.test.mjs" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web", + "immediately": true + } + }, + "chatecnuWork": { + "schemaVersion": 1, + "kind": "ui-extension", + "uses": ["sidebar.panellist", "main"], + "replaces": [], + "removable": true + }, + "peerDependencies": { + "@deepseek-ai/cordis": "4.0.2", + "@deepseek-ai/dsh-api-remotes": "0.1.5-rc.1", + "@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2", + "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-rc.1", + "@deepseek-ai/dsh-client-ui-sidebar": "0.1.2-rc.1", + "@deepseek-ai/dsh-storage-domain": "0.1.5-rc.1", + "@deepseek-ai/dsh-tools": "0.1.5-rc.1", + "@deepseek-ai/dsh-permission-presets": "0.1.5-rc.1", + "@deepseek-ai/dsh-typert-protocol": "0.1.5-rc.1", + "react": "^18.2.0", + "zod": "^4.4.3" + } +} \ No newline at end of file diff --git a/dsh-plugins/calendar/src/client.ts b/dsh-plugins/calendar/src/client.ts new file mode 100644 index 00000000..18a0b48c --- /dev/null +++ b/dsh-plugins/calendar/src/client.ts @@ -0,0 +1,1511 @@ +// Client half of the calendar panel: the week view, one entry's page, and the +// ICS dialogs. +// +// A calendar shows time, not rows: the week is drawn on an hour axis with one +// column per day, and an entry is a block whose top and height come from its +// clock. Two entries at the same hour split their day's width instead of +// stacking, and everything that is not a clock time lives in the all-day row +// above the axis. The arithmetic of that placement is in `src/layout.js`; this +// file scales it to pixels and paints it. +// +// The panel holds events, never courses. A timetable is one data source that an +// institution plugin may feed in, and it arrives as ordinary entries carrying +// that source's stamp: the panel groups and colours by source for exactly that +// reason, and an entry the reader writes by hand is a `manual` one. +// +// The panel is built from the product's own primitives (buttons, dialogs, menu, +// inputs, icons) rather than hand-rolled controls, so it looks like the rest of +// the shell in both light and dark themes and keeps their keyboard behaviour. +// The two generations of this package that the product ships name their icons +// differently: the earlier one carries the drawn size in the name +// (`IconPlusOutline16`), the later one drops it and offers stroke variants with +// the size as a prop (`IconPlusOutlineRegular`). The components below are the +// same in both; each icon is resolved from whichever set is loaded, so the panel +// draws on either runtime instead of importing a name one of them lacks. +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Button, Input, Menu, Modal, Switch, Tag, Toast, Tooltip, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' +import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' +import calendarRemote from '../lib/typert.remote-client.js' +import { dayWindow, layoutWeek, minutesOf, shiftedEnd } from './layout.js' + +/** + * Resolve one icon from the loaded primitives package. + * @param names - the names it may be exported under, older generation first. + * @param size - the size to draw at when the name does not already fix it. + */ +const icon = (names: string[], size: number) => { + const artwork = names.map(name => (primitives as any)[name]).find(Boolean) + return artwork ? (props: any = {}) => React.createElement(artwork, { size, ...props }) : () => null +} + +const IconAlarmClock = icon(['IconAlarmClockOutline16', 'IconAlarmClockOutlineRegular'], 16) +const IconArchive = icon(['IconArchiveOutline20', 'IconArchiveOutlineRegular'], 20) +const IconCheck = icon(['IconCheckOutline16', 'IconCheckOutlineRegular'], 16) +const IconChevronDown = icon(['IconChevronDownOutline14', 'IconChevronDownOutlineRegular'], 14) +const IconChevronLeft = icon(['IconChevronLeftOutline14', 'IconChevronLeftOutlineRegular'], 14) +const IconChevronRight = icon(['IconChevronRightOutline14', 'IconChevronRightOutlineRegular'], 14) +const IconClock = icon(['IconClockOutline16', 'IconClockOutlineRegular'], 16) +const IconCopy = icon(['IconCopyOutline16', 'IconCopyOutlineRegular'], 16) +const IconDownload = icon(['IconDownloadOutline16', 'IconDownloadOutlineRegular'], 16) +const IconFolderOpen = icon(['IconFolderOpenOutline16', 'IconFolderOpenOutlineRegular'], 16) +const IconPlus = icon(['IconPlusOutline16', 'IconPlusOutlineRegular'], 16) +const IconRefresh = icon(['IconRefreshOutline16', 'IconRefreshOutlineRegular'], 16) +const IconRefreshSmall = icon(['IconRefreshOutline14', 'IconRefreshOutlineRegular'], 14) +const IconTrash = icon(['IconTrashOutline16', 'IconTrashOutlineRegular'], 16) +const IconWarning = icon(['IconWarningOutline16', 'IconWarningOutlineRegular'], 16) + +export const inject = ['slots', 'remote'] + +const PANEL_ID = 'calendar' +const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] +/** Recurrence weekday codes, for the summary line of a stored series. */ +const WEEKDAY_BY_CODE: Record = { mo: '周一', tu: '周二', we: '周三', th: '周四', fr: '周五', sa: '周六', su: '周日' } + +/** Source an import writes unless the reader says otherwise; the scope that is replaced. */ +const DEFAULT_SOURCE = 'ics' + +/** + * The `uid` of an entry created here. The caller owns it — the host stores the + * record it is handed — so it has to be one no import would ever produce. + */ +const newUid = () => (globalThis.crypto?.randomUUID + ? `manual-${globalThis.crypto.randomUUID()}` + : `manual-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`) + +/** Row height of one hour on the axis, in pixels; the axis is drawn from this. */ +const HOUR_HEIGHT = 48 +const PX_PER_MINUTE = HOUR_HEIGHT / 60 +/** Width of the hour gutter, in pixels. */ +const GUTTER = 54 +/** + * The axis is a whole day, as in the calendars this view follows. A fixed + * working day left the lower half of a tall window empty; with the whole day on + * the axis the grid always reaches the bottom, and the view scrolls to where + * this week's entries are instead of cropping them to office hours. + */ +const DAY_MINUTES = 24 * 60 + +const PAGE_BACKGROUND = 'var(--dsw-alias-bg-base, #fff)' +const PRIMARY = 'var(--dsw-alias-label-primary, #222)' +const SECONDARY = 'var(--dsw-alias-label-secondary, #666)' +const TERTIARY = 'var(--dsw-alias-label-tertiary, #888)' +const HAIRLINE = '.5px solid var(--dsw-alias-border-l1, #00000014)' +const CARD_BORDER = '1px solid var(--dsw-alias-border-l2, #0000001f)' +const LAYER = 'var(--dsw-alias-bg-layer-2, #f6f6f6)' +const HOVER = 'var(--dsw-alias-interactive-bg-hover, #0000000a)' + +/** The hues a source is drawn in, as the product's static palette names; the fallbacks are for themes without them. */ +const TINTS: Record = { blue: '#4d6bfe', amber: '#d99a2b', green: '#2f9e68', deepseek: '#4d6bfe', red: '#d4553f' } +const TINT_FAMILIES = Object.keys(TINTS) + +/** Hover feedback for a row or a block, which inline styles cannot express. */ +function useHover() { + const [hovered, setHovered] = useState(false) + return [hovered, { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false) }] as const +} + +const pad2 = (value: number) => String(value).padStart(2, '0') +/** Local `YYYY-MM-DD` of a date. */ +const localDate = (value = new Date()) => `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}` +/** Local midnight of a `YYYY-MM-DD` string. */ +function parseDate(date: string) { + const [year, month, day] = date.split('-').map(Number) + return new Date(year, month - 1, day) +} +const addDays = (date: string, days: number) => { + const value = parseDate(date) + value.setDate(value.getDate() + days) + return localDate(value) +} +/** Monday of the week a date belongs to. */ +const startOfWeek = (date: string = localDate()) => addDays(date, -((parseDate(date).getDay() + 6) % 7)) +const dateOf = (value: string) => value.slice(0, 10) +/** Clock part of a local time string, empty for a whole-day value. */ +const clockOf = (value?: string) => (value && value.length > 10 ? value.slice(11, 16) : '') +const weekdayOf = (date: string) => WEEKDAYS[(parseDate(date).getDay() + 6) % 7] +const dayNumber = (date: string) => parseDate(date).getDate() +const shortDate = (date: string) => `${parseDate(date).getMonth() + 1}月${parseDate(date).getDate()}日` +const monthOf = (date: string) => `${parseDate(date).getFullYear()}年${parseDate(date).getMonth() + 1}月` +const hourLabel = (minutes: number) => `${pad2(Math.floor(minutes / 60))}:00` +/** Minutes from midnight of an `HH:mm` clock string. */ +const clockMinutes = (value: string) => Number(value.slice(0, 2)) * 60 + Number(value.slice(3, 5)) +/** The `HH:mm` clock string of minutes from midnight. */ +const clockAt = (minutes: number) => `${pad2(Math.floor(minutes / 60))}:${pad2(minutes % 60)}` +const isToday = (date: string) => date === localDate() + +/** + * The clock a new entry is offered: the next whole hour when it is for today, + * otherwise the start of a working day. A new entry therefore always carries a + * time, and nothing is written as a whole-day record by accident. + */ +function suggestedStart(date: string) { + if (date !== localDate()) return 9 * 60 + const now = new Date() + return Math.min(22 * 60, Math.ceil((now.getHours() * 60 + now.getMinutes()) / 60) * 60) +} + +/** The clock range a block shows, from its own times. */ +function clockRange(occurrence: any) { + const start = clockOf(occurrence?.start) + const end = clockOf(occurrence?.end) + if (start === '') return '全天' + return end === '' ? start : `${start}–${end}` +} + +/** A stable hue per data source, so one import keeps its colour across views and restarts. */ +function tintOf(key?: string) { + let hash = 0 + const text = key ?? 'event' + for (let index = 0; index < text.length; index += 1) hash = (hash * 31 + text.charCodeAt(index)) % 100003 + const family = TINT_FAMILIES[hash % TINT_FAMILIES.length] + const accent = `var(--dsw-static-${family}-500, ${TINTS[family]})` + return { accent, background: `color-mix(in srgb, ${accent} 15%, var(--dsw-alias-bg-layer-1, #fff))` } +} + +/** Drop undefined and blank entries, so a patch or an edit carries only what it means. */ +function withoutEmpty(input: Record) { + const output: Record = {} + for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null) continue + if (typeof value === 'string' && value.trim() === '') continue + output[key] = value + } + return output +} + +const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)) + +/** Unwrap one remote result: the gateway answers with `{ ok, value }` or `{ ok, error }`. */ +async function unwrap(operation: any) { + const result = await operation + if (result?.ok === true) return result.value + throw new Error(result?.error?.message || result?.error?.code || '日历数据不可用') +} + +/** The note of a record: a field of its own, written out as `DESCRIPTION`. */ +const noteOf = (event: any) => (typeof event?.description === 'string' ? event.description : '') + +/** One line describing how often a stored series repeats. */ +function seriesSummary(event: any) { + if (!event.recurrence) return '单次' + const days = (event.recurrence.byDay ?? []).map((code: string) => WEEKDAY_BY_CODE[code]).filter(Boolean).join('、') + const interval = event.recurrence.interval + const every = interval && interval > 1 ? `每 ${interval} 周` : '每周' + const limit = event.recurrence.count + ? ` · 共 ${event.recurrence.count} 次` + : event.recurrence.until + ? ` · 至 ${shortDate(dateOf(event.recurrence.until))}` + : '' + return `${every}${days ? ` ${days}` : ''}${limit}` +} + +/** A labelled form row: the label above its control, as in the shell's own settings. */ +function Field({ label, children, hint }: any) { + return React.createElement('label', { style: { display: 'block', marginBottom: 12 } }, + React.createElement('span', { style: { display: 'block', marginBottom: 6, fontSize: 12, color: SECONDARY } }, label), + children, + hint ? React.createElement('span', { style: { display: 'block', marginTop: 4, fontSize: 11, color: TERTIARY } }, hint) : undefined) +} + + +const Muted = ({ children, style }: any) => React.createElement('span', { style: { fontSize: 12, color: SECONDARY, ...style } }, children) + +/** Icon-only button with the product's tooltip, used for every row-level action. */ +function IconButton({ label, icon, onClick, danger, disabled }: any) { + return React.createElement(Tooltip, { label, side: 'bottom' }, + React.createElement(Button, { + variant: 'ghost', size: 'sm', icon, onClick, disabled, + 'aria-label': label, + style: danger ? { color: 'var(--dsw-alias-state-error-primary, #d4553f)' } : undefined, + })) +} + +/** + * One entry on the axis. + * + * The block is a button so a keyboard reaches it, and its width is the lane the + * layout gave it: `100 / lanes` percent, inset by a hairline so neighbours stay + * apart. + */ +function Block({ entry, axisStart, tint, onSelect }: any) { + const [hovered, hover] = useHover() + const width = 100 / entry.lanes + const height = entry.height * PX_PER_MINUTE + const inner = height - 8 + const label = `${clockRange(entry.occurrence)} ${entry.occurrence.title}${entry.occurrence.location ? ` · ${entry.occurrence.location}` : ''}` + // A block shows what fits: the clock and the title always, the place when + // there is room for a third line. + const line = { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } + return React.createElement('button', { + type: 'button', + title: label, + 'aria-label': label, + onClick: () => onSelect(entry.occurrence), + ...hover, + style: { + position: 'absolute', top: (entry.top - axisStart) * PX_PER_MINUTE, height, + left: `calc(${entry.lane * width}% + 2px)`, width: `calc(${width}% - 4px)`, + display: 'block', overflow: 'hidden', textAlign: 'left', cursor: 'pointer', + padding: '3px 6px', border: 'none', borderLeft: `3px solid ${tint.accent}`, borderRadius: 6, + background: tint.background, color: PRIMARY, fontFamily: 'inherit', + boxShadow: hovered ? 'var(--dsw-elevation-panel, 0 6px 18px rgba(0, 0, 0, .16))' : 'none', + transition: 'box-shadow .12s ease', zIndex: hovered ? 2 : 1, + }, + }, + React.createElement('div', { style: { ...line, fontSize: 11, lineHeight: '15px', color: SECONDARY, fontVariantNumeric: 'tabular-nums' } }, clockRange(entry.occurrence)), + React.createElement('div', { + style: { ...line, marginTop: 1, fontSize: 12, lineHeight: '17px', fontWeight: 600 }, + }, entry.occurrence.title), + inner >= 34 && entry.occurrence.location + ? React.createElement('div', { style: { ...line, fontSize: 11, lineHeight: '15px', color: SECONDARY } }, entry.occurrence.location) + : undefined) +} + +/** + * The week: a day header row and the hour axis below it. The axis is one scroll + * container so the day columns can never drift apart vertically. A record that + * arrives without a clock is drawn as a block at midnight, not in a row of its + * own, so the week has a single geometry. + * + * Dragging on an empty spot starts an entry spanning what was dragged, and a + * double click starts a one-hour one there: between them the view is writable + * rather than only readable. + */ +function WeekGrid({ anchor, occurrences, onSelect, onCreate }: any) { + const [now, setNow] = useState(() => new Date()) + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 60_000) + return () => clearInterval(timer) + }, []) + + const days = useMemo(() => Array.from({ length: 7 }, (_, index) => { + const date = addDays(anchor, index) + return { key: date, label: WEEKDAYS[index], day: dayNumber(date), today: isToday(date) } + }), [anchor]) + // The whole day is drawn; the entries of the week decide what the view opens on. + const focus = useMemo(() => dayWindow(occurrences).start, [occurrences]) + const scroller = useRef(null) + const scrolledTo = useRef('') + useEffect(() => { + const node = scroller.current + const key = `${anchor}:${focus}` + if (!node || scrolledTo.current === key) return + scrolledTo.current = key + node.scrollTop = Math.max(0, focus * PX_PER_MINUTE - 8) + }, [anchor, focus]) + const axis = useMemo(() => ({ start: 0, end: DAY_MINUTES }), []) + const hours = useMemo(() => { + const list: number[] = [] + for (let minutes = axis.start; minutes < axis.end; minutes += 60) list.push(minutes) + return list + }, [axis]) + const axisHeight = (axis.end - axis.start) * PX_PER_MINUTE + // A pointer gesture on empty space: `press` is what has been started, `dragging` + // what is drawn while it runs. The column captures the pointer, so the gesture + // keeps tracking after the cursor leaves it. + const [dragging, setDragging] = useState<{ day: string; from: number; to: number } | null>(null) + const press = useRef<{ day: string; from: number; pointerId: number; moved: boolean } | null>(null) + /** The fifteen-minute slot under a pointer inside one column. */ + const slotAt = (event: any) => { + const box = event.currentTarget.getBoundingClientRect() + const raw = axis.start + (event.clientY - box.top) / PX_PER_MINUTE + return Math.max(0, Math.min(DAY_MINUTES - 15, Math.round(raw / 15) * 15)) + } + const timed = useMemo(() => (occurrences ?? []).filter((occurrence: any) => clockOf(occurrence.start) !== ''), [occurrences]) + const byDay = useMemo(() => layoutWeek(timed), [timed]) + /** + * Whole-day occurrences, listed under every day of this week they cover. + * + * A value that arrived without a clock (`DTSTART;VALUE=DATE`, which is what a + * holiday or an academic calendar is made of) has no slot on the axis, so it is + * shown in the day header: visible whatever the axis is scrolled to, and not + * claiming a time it does not have. A record that covers several days is shown + * on each of them, the way a calendar draws a multi-day bar. + */ + const allDayByDay = useMemo(() => { + const first = anchor + const last = addDays(anchor, 6) + const map = new Map() + for (const occurrence of occurrences ?? []) { + if (clockOf(occurrence.start) !== '') continue + const start = dateOf(occurrence.start) + const spanned = occurrence.end && clockOf(occurrence.end) === '' && dateOf(occurrence.end) > start + ? dateOf(occurrence.end) + : start + const from = start < first ? first : start + const to = spanned > last ? last : spanned + for (let date = from; date <= to; date = addDays(date, 1)) { + const list = map.get(date) + if (list) list.push(occurrence) + else map.set(date, [occurrence]) + } + } + return map + }, [occurrences, anchor]) + + const nowMinutes = now.getHours() * 60 + now.getMinutes() + const nowVisible = days.some(day => day.today) + + // The pieces are built apart and assembled at the end: the nesting of one + // expression per row was unreadable, and a misread parenthesis there is a + // silent layout bug rather than a syntax error. + /** The whole-day entries of one day, as the chips its header shows. */ + const allDayChips = (date: string) => { + const list = allDayByDay.get(date) ?? [] + if (list.length === 0) return undefined + return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4 } }, + ...list.slice(0, 2).map((occurrence: any) => { + const tint = tintOf(occurrence.source ?? occurrence.uid) + const span = occurrence.end && dateOf(occurrence.end) > dateOf(occurrence.start) + ? `${shortDate(dateOf(occurrence.start))}–${shortDate(dateOf(occurrence.end))}` + : shortDate(dateOf(occurrence.start)) + const label = `全天 · ${span} ${occurrence.title}` + return React.createElement('button', { + key: occurrence.occurrenceId, + type: 'button', + title: label, + 'aria-label': label, + onClick: () => onSelect(occurrence), + style: { + display: 'block', width: '100%', padding: '1px 4px', border: 'none', borderRadius: 4, cursor: 'pointer', + textAlign: 'left', fontSize: 10, lineHeight: '15px', fontFamily: 'inherit', color: PRIMARY, + background: tint.background, borderLeft: `3px solid ${tint.accent}`, + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', + }, + }, occurrence.title) + }), + list.length > 2 + ? React.createElement('div', { style: { fontSize: 10, lineHeight: '14px', color: SECONDARY, textAlign: 'left' } }, `还有 ${list.length - 2} 条全天`) + : undefined) + } + + const headerRow = React.createElement('div', { + style: { display: 'flex', borderBottom: CARD_BORDER, background: PAGE_BACKGROUND, paddingRight: 8 }, + }, + React.createElement('div', { style: { width: GUTTER, flex: '0 0 auto' } }), + ...days.map(day => React.createElement('div', { + key: day.key, + style: { flex: '1 1 0', minWidth: 0, padding: '8px 4px', textAlign: 'center', borderLeft: HAIRLINE }, + }, + React.createElement('div', { style: { fontSize: 11, color: day.today ? PRIMARY : SECONDARY } }, day.label), + React.createElement('div', { + style: day.today + ? { + width: 26, height: 26, margin: '2px auto 0', borderRadius: 999, lineHeight: '26px', + fontSize: 13, fontWeight: 650, background: 'var(--dsw-alias-state-business-primary, #4d6bfe)', color: '#fff', + } + : { marginTop: 2, fontSize: 13, fontWeight: 600, lineHeight: '26px', color: PRIMARY }, + }, day.day), + allDayChips(day.key)))) + + const hourLabels = hours.map((minutes, index) => React.createElement('div', { + key: minutes, + style: { + // The label sits on its line, half above and half below; the first line is + // the top edge of the axis, so it moves inside instead of being cut off. + position: 'absolute', top: index === 0 ? 2 : (minutes - axis.start) * PX_PER_MINUTE - 6, right: 8, + fontSize: 11, color: TERTIARY, fontVariantNumeric: 'tabular-nums', + }, + }, hourLabel(minutes))) + + // One column per day: its hour lines, then its blocks, then today's "now" line. + const columns = days.map(day => React.createElement('div', { + key: day.key, + // Empty space in the column is a time on that day. A drag across it asks for + // an entry spanning what was dragged; a double click asks for a one-hour one, + // because a single click would fire while the reader is only looking. A press + // that lands on a block belongs to that block, so the gesture starts only when + // the column itself is the target — and the decorative lines below take no + // pointer events, so they never become one. + onPointerDown: (event: any) => { + if (!onCreate || event.target !== event.currentTarget) return + // Touch keeps its own scrolling: a finger dragged down the axis must pan it. + if (event.pointerType === 'touch' || event.button !== 0) return + const from = slotAt(event) + press.current = { day: day.key, from, pointerId: event.pointerId, moved: false } + event.currentTarget.setPointerCapture(event.pointerId) + setDragging({ day: day.key, from, to: from }) + event.preventDefault() + }, + onPointerMove: (event: any) => { + const active = press.current + if (!active || active.pointerId !== event.pointerId) return + const to = slotAt(event) + if (!active.moved && Math.abs(to - active.from) < 15) return + active.moved = true + setDragging({ day: active.day, from: Math.min(active.from, to), to: Math.max(active.from, to) }) + }, + onPointerUp: (event: any) => { + const active = press.current + if (!active || active.pointerId !== event.pointerId) return + press.current = null + setDragging(null) + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) + if (!active.moved) return + const to = slotAt(event) + if (Math.abs(to - active.from) < 15) return + onCreate(day.key, Math.min(active.from, to), Math.max(active.from, to)) + }, + onPointerCancel: (event: any) => { + if (press.current?.pointerId !== event.pointerId) return + press.current = null + setDragging(null) + }, + onDoubleClick: (event: any) => { + if (!onCreate || event.target !== event.currentTarget) return + const from = slotAt(event) + onCreate(day.key, from, Math.min(from + 60, 24 * 60 - 1)) + }, + style: { + position: 'relative', flex: '1 1 0', minWidth: 0, borderLeft: HAIRLINE, + background: day.today ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 4%, transparent)' : undefined, + }, + }, + ...hours.map(minutes => React.createElement('div', { + key: minutes, + style: { + position: 'absolute', left: 0, right: 0, top: (minutes - axis.start) * PX_PER_MINUTE, + height: 1, background: 'var(--dsw-alias-border-l1, #00000010)', pointerEvents: 'none', + }, + })), + dragging?.day === day.key + ? React.createElement('div', { + key: 'drag-preview', + 'aria-hidden': true, + style: { + position: 'absolute', left: 2, right: 2, + top: dragging.from * PX_PER_MINUTE, + height: Math.max(22, dragging.to - dragging.from) * PX_PER_MINUTE, + borderRadius: 6, pointerEvents: 'none', zIndex: 2, + border: '1px dashed var(--dsw-alias-state-business-primary, #4d6bfe)', + background: 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 14%, transparent)', + }, + }, + dragging.to > dragging.from + ? React.createElement('div', { + style: { padding: '2px 6px', fontSize: 11, color: PRIMARY, fontVariantNumeric: 'tabular-nums' }, + }, `${clockAt(dragging.from)}–${clockAt(dragging.to)}`) + : undefined) + : undefined, + ...(byDay.get(day.key) ?? []).map(entry => React.createElement(Block, { + key: entry.occurrence.occurrenceId, + entry, + axisStart: axis.start, + tint: tintOf(entry.occurrence.source ?? entry.occurrence.uid), + onSelect, + })))) + + const nowLine = nowVisible ? React.createElement('div', { + 'aria-hidden': true, + style: { + position: 'absolute', left: 0, right: 0, top: (nowMinutes - axis.start) * PX_PER_MINUTE, + height: 2, background: 'var(--dsw-alias-state-error-primary, #d4553f)', zIndex: 3, pointerEvents: 'none', + }, + }) : undefined + + const axisRow = React.createElement('div', { + // A whole day is taller than most panels; `minHeight` keeps the day columns + // and their rules running to the bottom edge of a very tall one. + style: { display: 'flex', height: axisHeight + 12, minHeight: '100%', paddingRight: 8 }, + }, + React.createElement('div', { style: { position: 'relative', width: GUTTER, flex: '0 0 auto' } }, ...hourLabels), + React.createElement('div', { style: { position: 'relative', flex: '1 1 0', display: 'flex' } }, ...columns, nowLine)) + + return React.createElement('div', { + style: { display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: 0 }, + }, + headerRow, + React.createElement('div', { + ref: scroller, + style: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }, + }, axisRow)) +} + +/** The details of one occurrence, and every change the panel can make to it. */ +function EventDialog({ occurrence, event, service, onClose, run, onEdit }: any) { + const [editing, setEditing] = useState(false) + const [confirming, setConfirming] = useState(false) + const [form, setForm] = useState({ + start: occurrence?.start ?? '', + end: occurrence?.end ?? '', + location: occurrence?.location ?? '', + }) + useEffect(() => { + setEditing(false) + setConfirming(false) + setForm({ start: occurrence?.start ?? '', end: occurrence?.end ?? '', location: occurrence?.location ?? '' }) + }, [occurrence]) + + if (!occurrence) return null + const date = occurrence.occurrenceDate ?? dateOf(occurrence.start) + const series = Boolean(event?.recurrence) + // A whole-day entry has no clock to move, so its dialog points at the page that + // owns the date and the place instead of offering two blank time fields. + const wholeDay = clockOf(occurrence.start) === '' + const changed = form.start !== (occurrence.start ?? '') || form.end !== (occurrence.end ?? '') || form.location !== (occurrence.location ?? '') + // The form opens with the occurrence's own end filled in, so a start that moved + // on its own would carry the old clock time over and could finish before it + // begins. Leaving the end as it opened means "same length", not "same clock". + const keptLength = !wholeDay && form.end === (occurrence.end ?? '') + ? shiftedEnd(form.start, occurrence.start ?? form.start, occurrence.end ?? '') + : undefined + const patch = withoutEmpty({ + start: form.start !== occurrence.start ? form.start : undefined, + end: keptLength ?? (form.end !== (occurrence.end ?? '') ? form.end : undefined), + location: form.location !== (occurrence.location ?? '') ? form.location : undefined, + }) + + /** + * A series keeps one record per rule and marks the exceptions, so a change to + * a single occurrence of one is an override. A one-off entry has no series to + * keep in step: an override there would be a `RECURRENCE-ID` on a `VEVENT` + * without `RRULE`, which RFC 5545 does not allow, so the record itself is what + * gets edited. + */ + const save = () => (series + ? service.applyOverride({ uid: occurrence.uid, date, patch }) + : service.putEvent(withoutEmpty({ + ...event, + start: patch.start ?? event?.start, + end: patch.end ?? event?.end, + location: patch.location ?? event?.location, + }))) + + const actions = [] + if (!editing) { + actions.push(React.createElement(Button, { + key: 'when', variant: 'outline', size: 'sm', + onClick: () => (wholeDay && event ? onEdit(event.uid) : setEditing(true)), + }, wholeDay ? '改日期或地点' : series ? '改期或改地点' : '改时间或地点')) + } + // The dialog speaks for one occurrence; the title, the date and the repeat + // rule belong to the record, and the record has a page of its own. + if (event) { + actions.push(React.createElement(Button, { + key: 'record', variant: 'ghost', size: 'sm', onClick: () => onEdit(event.uid), + }, '编辑日程信息')) + } + if (occurrence.overridden) { + actions.push(React.createElement(Button, { + key: 'undo', variant: 'ghost', size: 'sm', disabled: false, + onClick: () => run(() => service.removeOverride({ uid: occurrence.uid, date }), '已恢复原定安排').then(ok => ok && onClose()), + }, '撤销改期')) + } + if (series) { + actions.push(React.createElement(Button, { + key: 'cancel', variant: 'ghost', size: 'sm', + onClick: () => run(() => service.cancelOccurrence({ uid: occurrence.uid, date }), `已取消:${shortDate(date)}`).then(ok => ok && onClose()), + }, '取消这一次')) + } else { + actions.push(React.createElement(Button, { + key: 'delete', variant: 'ghost', size: 'sm', + style: { color: 'var(--dsw-alias-state-error-primary, #d4553f)' }, + onClick: () => setConfirming(true), + }, '删除日程')) + } + + return React.createElement(Modal, { + open: true, + onClose, + title: occurrence.title, + closeLabel: '关闭', + description: series ? seriesSummary(event) : undefined, + footer: React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' } }, + ...actions, + React.createElement('span', { style: { flex: '1 1 auto' } }), + React.createElement(Button, { variant: 'ghost', size: 'sm', onClick: onClose }, '关闭')), + }, + React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, + React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } }, + React.createElement('span', { style: { fontSize: 13, color: PRIMARY, fontVariantNumeric: 'tabular-nums' } }, + `${monthOf(date)}${dayNumber(date)}日 ${weekdayOf(date)} ${clockRange(occurrence)}`), + occurrence.overridden ? React.createElement(Tag, { tone: 'info' }, '已改期') : undefined, + series ? React.createElement(Tag, { tone: 'outline' }, '重复日程中的一次') : React.createElement(Tag, { tone: 'quiet' }, '单次日程')), + occurrence.location + ? React.createElement('div', { style: { fontSize: 13, color: PRIMARY } }, `地点:${occurrence.location}`) + : undefined, + occurrence.description + ? React.createElement('div', { style: { fontSize: 12, color: SECONDARY, whiteSpace: 'pre-wrap' } }, occurrence.description) + : undefined, + event?.source ? React.createElement('div', null, React.createElement(Muted, null, `来源:${event.source}`)) : undefined, + editing + ? React.createElement('div', { + style: { marginTop: 4, padding: 12, borderRadius: 10, background: LAYER, display: 'flex', flexDirection: 'column', gap: 10 }, + }, + React.createElement(Field, { label: '开始' }, + React.createElement(Input, { + type: 'datetime-local', value: form.start, + onChange: (event: any) => setForm({ ...form, start: event.target.value }), + })), + React.createElement(Field, { label: '结束', hint: '留空表示沿用原本的时长' }, + React.createElement(Input, { + type: 'datetime-local', value: form.end, + onChange: (event: any) => setForm({ ...form, end: event.target.value }), + })), + React.createElement(Field, { label: '地点' }, + React.createElement(Input, { + value: form.location, placeholder: '留空表示沿用原来的地点', + onChange: (event: any) => setForm({ ...form, location: event.target.value }), + })), + React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } }, + React.createElement(Button, { + variant: 'primary', size: 'sm', disabled: Object.keys(patch).length === 0 || (!series && !event), + onClick: () => run(save, series ? '已保存这次调整' : '已保存修改').then(ok => ok && onClose()), + }, '保存'), + React.createElement(Button, { variant: 'ghost', size: 'sm', onClick: () => setEditing(false) }, '取消'), + React.createElement(Muted, null, series ? '只改这一次,系列其他日期不受影响' : '只改这一条日程'))) + : undefined, + confirming + ? React.createElement('div', { + style: { marginTop: 4, padding: 12, borderRadius: 10, background: LAYER, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, + }, + React.createElement('span', { style: { fontSize: 13 } }, '确定删除这条日程?'), + React.createElement(Button, { + variant: 'primary', size: 'sm', + style: { background: 'var(--dsw-alias-state-error-primary, #d4553f)' }, + onClick: () => run(() => service.deleteEvent({ uid: occurrence.uid }), '已删除这条日程').then(ok => ok && onClose()), + }, '删除'), + React.createElement(Button, { variant: 'ghost', size: 'sm', onClick: () => setConfirming(false) }, '取消')) + : undefined)) +} + +/** Read a dropped or chosen file as text, with the few checks a calendar import needs. */ +async function readCalendarFile(file: File) { + const name = file.name.toLowerCase() + if (!(name.endsWith('.ics') || name.endsWith('.ical') || name.endsWith('.txt') || file.type.includes('calendar') || file.type.startsWith('text/'))) { + throw new Error('只支持 .ics 日历文件') + } + const text = await file.text() + if (text.trim() === '') throw new Error('文件是空的') + return text +} + +/** The import dialog: drop a file or paste text, then read what the import did. */ +function ImportDialog({ initialText, initialName, service, onClose, run }: any) { + const [text, setText] = useState(initialText ?? '') + const [source, setSource] = useState(DEFAULT_SOURCE) + const [replace, setReplace] = useState(true) + const [name, setName] = useState(initialName ?? '') + const [dragging, setDragging] = useState(false) + const [problem, setProblem] = useState(null) + const [outcome, setOutcome] = useState(null) + const [busy, setBusy] = useState(false) + const picker = useRef(null) + + const accept = useCallback(async (file: File) => { + try { + const next = await readCalendarFile(file) + setText(next) + setName(file.name) + setProblem(null) + } catch (error) { + setProblem(messageOf(error)) + } + }, []) + + const submit = async () => { + setBusy(true) + setProblem(null) + try { + const result = await service.importIcs({ text, source: source.trim() === '' ? DEFAULT_SOURCE : source.trim(), replace }) + setOutcome(result) + } catch (error) { + setProblem(messageOf(error)) + } finally { + setBusy(false) + } + } + + const count = text.trim() === '' ? 0 : text.split(/\r\n|\r|\n/).filter((line: string) => line.startsWith('BEGIN:VEVENT')).length + + return React.createElement(Modal, { + open: true, + onClose, + title: outcome ? '导入结果' : '导入日历', + closeLabel: '关闭', + description: outcome + ? `来源 ${source.trim() === '' ? DEFAULT_SOURCE : source.trim()}` + : '粘贴日历文本,或把 .ics 文件拖到这里;同一来源的旧记录会被替换,自己手动改过的记录不受影响。', + footer: React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } }, + outcome + ? React.createElement(React.Fragment, null, + React.createElement(Button, { variant: 'ghost', size: 'sm', onClick: () => { setOutcome(null); setText(''); setName('') } }, '再导一份'), + React.createElement('span', { style: { flex: '1 1 auto' } }), + React.createElement(Button, { variant: 'primary', size: 'sm', onClick: onClose }, '完成')) + : React.createElement(React.Fragment, null, + React.createElement(Muted, null, count > 0 ? `识别到 ${count} 条日程` : '等待日历文本'), + React.createElement('span', { style: { flex: '1 1 auto' } }), + React.createElement(Button, { variant: 'ghost', size: 'sm', onClick: onClose }, '取消'), + React.createElement(Button, { variant: 'primary', size: 'sm', disabled: text.trim() === '' || busy, onClick: submit }, busy ? '正在导入…' : '导入'))), + }, + outcome + ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } }, + React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, + React.createElement(Tag, { tone: 'neutral' }, `日程 ${outcome.events} 条`), + React.createElement(Tag, { tone: 'neutral' }, `移除旧日程 ${outcome.removedEvents} 条`)), + React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: PRIMARY } }, + React.createElement(IconCheck), + outcome.events > 0 ? '已写入日历' : '没有写入新的日程'), + outcome.skipped.length > 0 + ? React.createElement('div', null, + React.createElement(Muted, { style: { display: 'block', marginBottom: 4 } }, `未导入 ${outcome.skipped.length} 条`), + React.createElement('ul', { style: { margin: 0, paddingLeft: 18, fontSize: 12, color: SECONDARY } }, + ...outcome.skipped.map((entry: any, index: number) => React.createElement('li', { key: index }, `${entry.uid ? `${entry.uid}:` : ''}${entry.reason}`)))) + : undefined, + outcome.degraded.length > 0 + ? React.createElement('div', null, + React.createElement(Muted, { style: { display: 'block', marginBottom: 4 } }, `重复规则降级为单次 ${outcome.degraded.length} 条`), + React.createElement('ul', { style: { margin: 0, paddingLeft: 18, fontSize: 12, color: SECONDARY } }, + ...outcome.degraded.map((entry: any, index: number) => React.createElement('li', { key: index }, `${entry.rrule ?? entry.uid ?? ''}${entry.reason ? `(${entry.reason})` : ''}`)))) + : undefined) + : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } }, + React.createElement('div', { + onDragEnter: (event: any) => { event.preventDefault(); setDragging(true) }, + onDragOver: (event: any) => { event.preventDefault(); setDragging(true) }, + onDragLeave: () => setDragging(false), + onDrop: async (event: any) => { + event.preventDefault() + setDragging(false) + const file = event.dataTransfer?.files?.[0] + if (file) await accept(file) + }, + style: { + display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, + padding: '20px 16px', borderRadius: 12, textAlign: 'center', + border: `1px dashed ${dragging ? 'var(--dsw-alias-state-business-primary, #4d6bfe)' : 'var(--dsw-alias-border-l2, #0000002e)'}`, + background: dragging ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 6%, transparent)' : LAYER, + }, + }, + React.createElement(IconFolderOpen), + React.createElement('strong', { style: { fontSize: 13 } }, name === '' ? '把 .ics 文件拖到这里' : name), + React.createElement(Muted, null, name === '' ? '也可以点下面的按钮选择文件' : '已读取文件内容,可以直接导入'), + React.createElement(Button, { + variant: 'outline', size: 'sm', + onClick: () => picker.current?.click(), + }, '选择文件'), + React.createElement('input', { + ref: (node: any) => { picker.current = node }, + type: 'file', accept: '.ics,.ical,.txt,text/calendar', style: { display: 'none' }, + onChange: async (event: any) => { + const file = event.target.files?.[0] + if (file) await accept(file) + event.target.value = '' + }, + })), + React.createElement('textarea', { + value: text, + onChange: (event: any) => setText(event.target.value), + placeholder: 'BEGIN:VCALENDAR …', + spellCheck: false, + style: { + width: '100%', minHeight: 148, resize: 'vertical', boxSizing: 'border-box', padding: 10, + borderRadius: 10, border: CARD_BORDER, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: PRIMARY, + fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)', fontSize: 12, lineHeight: 1.5, + }, + }), + React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' } }, + React.createElement('div', { style: { flex: '1 1 200px' } }, + React.createElement(Field, { label: '来源', hint: '同一来源再次导入会替换它自己的旧记录' }, + React.createElement(Input, { value: source, onChange: (event: any) => setSource(event.target.value) }))), + React.createElement('div', { style: { paddingBottom: 18 } }, + React.createElement(Switch, { checked: replace, onChange: setReplace, label: '替换同来源的旧记录' }))), + problem ? React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #d4553f)' } }, + React.createElement(IconWarning), problem) : undefined)) +} + +/** The export dialog: the calendar text, ready to copy or to download. */ +function ExportDialog({ service, onClose, toast }: any) { + const [text, setText] = useState('') + const [problem, setProblem] = useState(null) + const [copied, setCopied] = useState(false) + useEffect(() => { + let alive = true + service.exportIcs({}) + .then((result: any) => { if (alive) setText(result.text) }) + .catch((error: unknown) => { if (alive) setProblem(messageOf(error)) }) + return () => { alive = false } + }, [service]) + + const save = () => { + const blob = new Blob([text], { type: 'text/calendar;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = '日历.ics' + link.click() + URL.revokeObjectURL(url) + } + + const lines = text === '' ? 0 : text.split('\r\n').length + return React.createElement(Modal, { + open: true, + onClose, + title: '导出日历', + closeLabel: '关闭', + description: '标准 iCalendar 文本,可以复制或下载后导入其他日历应用。', + footer: React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } }, + React.createElement(Muted, null, text === '' ? '正在生成…' : `${lines} 行 · ${text.length} 字符`), + React.createElement('span', { style: { flex: '1 1 auto' } }), + React.createElement(Button, { + variant: 'primary', size: 'sm', icon: React.createElement(IconCopy), disabled: text === '', + onClick: async () => { + const ok = await writeClipboard(text) + setCopied(ok) + toast(ok ? '已复制日历文本' : '复制失败,请手动选择文本') + }, + }, copied ? '已复制' : '复制'), + React.createElement(Button, { + variant: 'outline', size: 'sm', icon: React.createElement(IconDownload), disabled: text === '', + onClick: save, + }, '下载 .ics'), + React.createElement(Button, { variant: 'ghost', size: 'sm', onClick: onClose }, '完成')), + }, + React.createElement('textarea', { + readOnly: true, value: text, spellCheck: false, + onFocus: (event: any) => event.target.select(), + style: { + width: '100%', minHeight: 300, maxHeight: '46vh', resize: 'vertical', boxSizing: 'border-box', padding: 10, + borderRadius: 10, border: CARD_BORDER, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: PRIMARY, + fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)', fontSize: 12, lineHeight: 1.5, + // The text is a file, not prose: show the folded lines as they are written + // (a continuation starts with a space) instead of soft-wrapping them into + // something that no longer looks like the export. + whiteSpace: 'pre', overflowX: 'auto', overflowY: 'auto', + }, + }), + problem ? React.createElement('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #d4553f)' } }, problem) : undefined) +} + +/** One entry row of the agenda: date, time, place, and the actions that fit a row. */ +function EventRow({ occurrence, tint, onOpen, onCancel, onDelete }: any) { + const [hovered, hover] = useHover() + const date = dateOf(occurrence.start) + const series = Boolean(occurrence.recurrence) + return React.createElement('div', { + ...hover, + style: { + display: 'grid', gridTemplateColumns: `${GUTTER}px minmax(0, 1fr) auto`, alignItems: 'center', gap: 10, + padding: '8px 12px', borderTop: HAIRLINE, background: hovered ? HOVER : 'transparent', + }, + }, + React.createElement('div', { style: { textAlign: 'center' } }, + React.createElement('div', { style: { fontSize: 11, color: SECONDARY } }, weekdayOf(date)), + React.createElement('div', { style: { fontSize: 15, fontWeight: 650, color: PRIMARY } }, dayNumber(date))), + React.createElement('button', { + type: 'button', + onClick: () => onOpen(occurrence), + style: { display: 'block', minWidth: 0, padding: 0, border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontFamily: 'inherit', color: PRIMARY }, + }, + React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 } }, + React.createElement('span', { 'aria-hidden': true, style: { width: 8, height: 8, borderRadius: 999, background: tint.accent, flex: '0 0 auto' } }), + React.createElement('span', { style: { fontSize: 13, fontWeight: 550, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, occurrence.title), + occurrence.overridden ? React.createElement(Tag, { tone: 'info' }, '已改期') : undefined), + React.createElement('div', { style: { marginTop: 2, fontSize: 12, color: SECONDARY, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, + [clockRange(occurrence), occurrence.location].filter(Boolean).join(' · '))), + React.createElement('div', { style: { display: 'flex', gap: 2, opacity: hovered ? 1 : 0, transition: 'opacity .12s ease' } }, + series + ? React.createElement(IconButton, { + label: '取消这一次', icon: React.createElement(IconArchive), + onClick: () => onCancel(occurrence), + }) + : React.createElement(IconButton, { + label: '删除日程', danger: true, icon: React.createElement(IconTrash), + onClick: () => onDelete(occurrence), + }))) +} + +/** Days between two `YYYY-MM-DD` dates, so a whole-day range keeps its span. */ +const daysBetween = (from: string, to: string) => Math.round((parseDate(to).getTime() - parseDate(from).getTime()) / 86_400_000) + +/** The editable fields of a stored record, as the form holds them. */ +const draftOf = (event: any) => ({ + title: event?.title ?? '', + date: dateOf(event?.start ?? '') || localDate(), + start: clockOf(event?.start), + end: clockOf(event?.end), + location: event?.location ?? '', + description: noteOf(event), + repeat: Boolean(event?.recurrence), + until: event?.recurrence?.until ? dateOf(event.recurrence.until) : '', +}) + +/** + * The record a draft describes, on top of the one it was read from. + * + * An emptied field disappears instead of lingering as an empty string: the host + * stores the record it is handed, so the form is the whole truth about it. + */ +function recordOf(draft: any, base: any = {}) { + const timed = draft.start !== '' + const shift = base.start ? daysBetween(dateOf(base.start), draft.date) : 0 + return withoutEmpty({ + ...base, + uid: base.uid ?? newUid(), + title: draft.title.trim(), + start: timed ? `${draft.date}T${draft.start}` : draft.date, + // A whole-day entry keeps the span it had, moved by however far its date moved. + end: timed + ? (draft.end === '' ? undefined : `${draft.date}T${draft.end}`) + : (base.end ? addDays(dateOf(base.end), shift) : undefined), + location: draft.location.trim(), + description: draft.description.trim(), + // A single entry owns no series bookkeeping: turning the repeat off drops the + // dates that were cancelled or moved while it was a series. + recurrence: draft.repeat ? withoutEmpty({ ...(base.recurrence ?? {}), freq: 'weekly', until: draft.until }) : undefined, + exceptions: draft.repeat ? base.exceptions : undefined, + overrides: draft.repeat ? base.overrides : undefined, + }) +} + +/** Why a draft cannot be saved yet, or `null` when it can. */ +function draftProblem(draft: any) { + if (draft.title.trim() === '') return '标题不能为空' + if (draft.date === '') return '需要选一个日期' + if (draft.start !== '' && draft.end !== '' && draft.end <= draft.start) return '结束时间要晚于开始时间' + return null +} + +/** + * The fields of one record, shared by the new-entry dialog and the entry page: + * what the reader owns about an entry, as opposed to one date of a series. + */ +function EventForm({ draft, onChange, autoFocus }: any) { + const set = (patch: any) => onChange({ ...draft, ...patch }) + return React.createElement(React.Fragment, null, + React.createElement(Field, { label: '标题' }, + React.createElement(Input, { + value: draft.title, placeholder: '例如:组会、体检、回家', autoFocus, + onChange: (event: any) => set({ title: event.target.value }), + })), + React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, + React.createElement('div', { style: { flex: '1 1 150px' } }, + React.createElement(Field, { label: '日期' }, + React.createElement(Input, { + type: 'date', value: draft.date, + onChange: (event: any) => set({ date: event.target.value }), + }))), + React.createElement('div', { style: { flex: '1 1 90px' } }, + React.createElement(Field, { label: '开始' }, + React.createElement(Input, { + type: 'time', value: draft.start, + onChange: (event: any) => set({ start: event.target.value }), + }))), + React.createElement('div', { style: { flex: '1 1 90px' } }, + React.createElement(Field, { label: '结束', hint: '留空按 45 分钟显示' }, + React.createElement(Input, { + type: 'time', value: draft.end, + onChange: (event: any) => set({ end: event.target.value }), + })))), + React.createElement(Field, { label: '地点' }, + React.createElement(Input, { + value: draft.location, placeholder: '可留空', + onChange: (event: any) => set({ location: event.target.value }), + })), + React.createElement(Field, { label: '备注', hint: '随导出写进日历,不会同步到任何服务' }, + React.createElement('textarea', { + value: draft.description, rows: 3, spellCheck: false, + onChange: (event: any) => set({ description: event.target.value }), + style: { + width: '100%', boxSizing: 'border-box', padding: '6px 8px', borderRadius: 8, resize: 'vertical', + border: CARD_BORDER, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: PRIMARY, fontSize: 13, + fontFamily: 'inherit', lineHeight: 1.5, + }, + })), + React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 } }, + React.createElement(Switch, { + checked: draft.repeat, label: '每周重复', + onChange: (next: boolean) => set({ repeat: next, until: next ? draft.until : '' }), + }), + React.createElement('span', { style: { fontSize: 13, color: PRIMARY } }, '每周重复'), + React.createElement(Muted, null, '按开始日期的星期')), + draft.repeat + ? React.createElement(Field, { label: '重复到', hint: '留空表示一直重复' }, + React.createElement(Input, { + type: 'date', value: draft.until, + onChange: (event: any) => set({ until: event.target.value }), + })) + : undefined) +} + +/** The dialog that creates one entry by hand. */ +function CreateDialog({ defaultDate, defaultStart, defaultEnd, service, onClose, run, onSaved }: any) { + const [draft, setDraft] = useState(() => { + const date = defaultDate ?? localDate() + const start = defaultStart ?? clockAt(suggestedStart(date)) + return { + ...draftOf(null), + date, + start, + end: defaultEnd ?? clockAt(Math.min(clockMinutes(start) + 60, 23 * 60 + 59)), + } + }) + const [problem, setProblem] = useState(null) + const complaint = draftProblem(draft) + + const submit = async () => { + if (complaint) { + setProblem(complaint) + return + } + const created = await run(() => service.putEvent(recordOf(draft)), '已新建日程') + if (created) onSaved() + } + + return React.createElement(Modal, { + open: true, + onClose, + title: '新建日程', + closeLabel: '关闭', + description: '写进本机日历,随时可以再改。', + footer: React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } }, + React.createElement(Muted, null, draft.repeat ? '每周重复' : '单次日程'), + React.createElement('span', { style: { flex: '1 1 auto' } }), + React.createElement(Button, { variant: 'ghost', size: 'sm', onClick: onClose }, '取消'), + React.createElement(Button, { + variant: 'primary', size: 'sm', disabled: Boolean(complaint), onClick: submit, + }, '新建')), + }, + React.createElement('div', null, + React.createElement(EventForm, { draft, onChange: setDraft, autoFocus: true }), + problem + ? React.createElement('div', { + style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #d4553f)' }, + }, problem) + : undefined)) +} + +/** + * One stored entry: what it is on the left, the dates it produces on the right. + * Wide panels show both at once; a narrow one stacks them, because the panel + * width is the reader's, not ours. + */ +function EventPage({ event, agenda, onOpenOccurrence, onSave, onRestore, onCancel, onDelete, busy }: any) { + const [draft, setDraft] = useState(() => draftOf(event)) + const [problem, setProblem] = useState(null) + useEffect(() => { setDraft(draftOf(event)); setProblem(null) }, [event]) + const uuid = event?.uid + const occurrences = useMemo( + () => (agenda ?? []).filter((occurrence: any) => occurrence.uid === uuid), + [agenda, uuid], + ) + const groups = useMemo(() => { + const map = new Map() + for (const occurrence of occurrences) { + const key = occurrence.start.slice(0, 7) + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(occurrence) + } + return [...map.entries()] + }, [occurrences]) + + if (!event) return null + const tint = tintOf(event.source ?? event.uid) + const exceptions = (event.exceptions ?? []).map(dateOf) + const complaint = draftProblem(draft) + const dirty = JSON.stringify(draft) !== JSON.stringify(draftOf(event)) + + const save = () => { + if (complaint) { + setProblem(complaint) + return + } + if (dirty) onSave(recordOf(draft, event)) + } + + // The pieces are built apart and assembled at the end: one nested expression + // per row was unreadable, and a misread parenthesis there is a silent layout + // bug rather than a syntax error. + const infoCard = React.createElement('section', { + style: { padding: 16, borderRadius: 12, border: CARD_BORDER, background: 'var(--dsw-alias-bg-layer-1, #fff)' }, + }, + React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' } }, + React.createElement('span', { 'aria-hidden': true, style: { width: 10, height: 10, borderRadius: 999, background: tint.accent } }), + React.createElement('strong', { style: { fontSize: 13 } }, '日程信息'), + React.createElement('span', { style: { flex: '1 1 auto' } }), + event.recurrence ? React.createElement(Tag, { tone: 'outline' }, seriesSummary(event)) : undefined, + event.source ? React.createElement(Muted, null, event.source) : undefined), + React.createElement(EventForm, { draft, onChange: setDraft }), + React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } }, + React.createElement(Button, { + variant: 'primary', size: 'sm', disabled: !dirty || busy || Boolean(complaint), onClick: save, + }, '保存更改'), + dirty ? React.createElement(Muted, null, complaint ?? '有未保存的改动') : undefined, + React.createElement('span', { style: { flex: '1 1 auto' } }), + React.createElement(Button, { + variant: 'ghost', size: 'sm', icon: React.createElement(IconTrash), + style: { color: 'var(--dsw-alias-state-error-primary, #d4553f)' }, + onClick: () => onDelete(event), + }, '删除这条日程'))) + + const cancelledCard = exceptions.length === 0 ? undefined : React.createElement('section', { + style: { padding: 16, borderRadius: 12, border: CARD_BORDER, background: 'var(--dsw-alias-bg-layer-1, #fff)' }, + }, + React.createElement('strong', { style: { display: 'block', marginBottom: 10, fontSize: 13 } }, '已取消的日期'), + React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' } }, + ...exceptions.map((date: string) => React.createElement('span', { + key: date, + style: { display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 4px 1px 7px', borderRadius: 999, background: LAYER, fontSize: 11 }, + }, + shortDate(date), + React.createElement(IconButton, { + label: `恢复 ${shortDate(date)}`, icon: React.createElement(IconRefreshSmall), + onClick: () => onRestore(event, date), + }))))) + + const monthBlocks = groups.flatMap(([month, items]) => [ + React.createElement('div', { + key: month, + style: { padding: '6px 12px', background: LAYER, fontSize: 12, color: SECONDARY, position: 'sticky', top: 0 }, + }, monthOf(`${month}-01`)), + ...items.map((occurrence: any) => React.createElement(EventRow, { + key: occurrence.occurrenceId, + occurrence, + tint, + onOpen: onOpenOccurrence, + onCancel: (item: any) => onCancel(item), + onDelete: (item: any) => onDelete(item), + })), + ]) + + const listCard = React.createElement('div', { + style: { flex: '2 1 420px', minWidth: 280, borderRadius: 12, border: CARD_BORDER, background: 'var(--dsw-alias-bg-layer-1, #fff)', overflow: 'hidden' }, + }, + React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', flexWrap: 'wrap' } }, + React.createElement('strong', { style: { fontSize: 13 } }, '出现的时间'), + React.createElement(Muted, null, `近一年内 ${occurrences.length} 次`), + React.createElement('span', { style: { flex: '1 1 auto' } }), + React.createElement(Muted, null, '点一条查看或调整')), + occurrences.length === 0 + ? React.createElement('div', { style: { padding: '24px 12px', textAlign: 'center' } }, + React.createElement(Muted, null, '近一年内没有它出现的时间')) + : React.createElement('div', null, ...monthBlocks)) + + return React.createElement('div', { style: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' } }, + React.createElement('div', { + style: { display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap', padding: '16px 20px 24px' }, + }, + // Left: the entry itself, and the dates that were cancelled in it. + React.createElement('div', { + style: { flex: '1 1 300px', maxWidth: 420, minWidth: 260, display: 'flex', flexDirection: 'column', gap: 12 }, + }, infoCard, cancelledCard), + // Right: the dates this entry covers, grouped by month. + listCard)) +} + +/** The panel: the week, the entry list behind a menu, and the dialogs. */ +function createCalendarPage(service: any) { + return function CalendarPanel() { + const [view, setView] = useState<{ kind: string; id?: string }>({ kind: 'week' }) + const [anchor, setAnchor] = useState(() => startOfWeek()) + const [snapshot, setSnapshot] = useState(null) + const [week, setWeek] = useState(null) + const [agenda, setAgenda] = useState(null) + const [loading, setLoading] = useState(true) + const [problem, setProblem] = useState(null) + const [toast, setToast] = useState(null) + const [menuOpen, setMenuOpen] = useState(false) + const [dialog, setDialog] = useState(null) + const [dropping, setDropping] = useState(false) + const [busy, setBusy] = useState(false) + + const events = snapshot?.events ?? [] + const weekRange = { from: anchor, to: addDays(anchor, 6) } + const agendaRange = { from: addDays(localDate(), -180), to: addDays(localDate(), 366) } + + const loadSnapshot = useCallback(async () => { setSnapshot(await service.snapshot()) }, [service]) + const loadWeek = useCallback(async () => { setWeek(await service.occurrences(weekRange)) }, [service, anchor]) + const loadAgenda = useCallback(async () => { setAgenda((await service.occurrences(agendaRange)).occurrences) }, [service, view.id]) + + const refresh = useCallback(async () => { + setLoading(true) + try { + await loadSnapshot() + if (view.kind === 'week') await loadWeek() + if (view.kind === 'event') await loadAgenda() + setProblem(null) + } catch (error) { + setProblem(messageOf(error)) + } finally { + setLoading(false) + } + }, [loadSnapshot, loadWeek, loadAgenda, view.kind]) + + useEffect(() => { void refresh() }, [refresh]) + + /** Run one write, then re-read: the panel never guesses what the host stored. */ + const run = useCallback(async (action: () => Promise, success?: string) => { + setBusy(true) + try { + await action() + await refresh() + if (success) setToast(success) + return true + } catch (error) { + setProblem(messageOf(error)) + return false + } finally { + setBusy(false) + } + }, [refresh]) + + const openEvent = useCallback((uid: string) => { + setDialog(null) + setMenuOpen(false) + setView({ kind: 'event', id: uid }) + }, []) + + /** A drag or a double click in the week asks for a new entry over that span. */ + const createAt = useCallback((date: string, from: number, to: number) => { + setDialog({ kind: 'create', date, start: clockAt(from), end: clockAt(Math.max(to, from + 15)) }) + }, []) + + const current = view.kind === 'event' ? events.find((event: any) => event.uid === view.id) : undefined + const weekCount = week?.occurrences?.length ?? 0 + /** The week on screen is today's, so an entry started from here means today. */ + const createDate = anchor === startOfWeek() ? localDate() : anchor + const title = view.kind === 'week' ? '我的日历' : (current?.title ?? '日程') + const subtitle = view.kind === 'week' + ? `${monthOf(anchor)}${dayNumber(anchor)}日 – ${shortDate(addDays(anchor, 6))} · ${weekCount} 条` + : [current?.location, current?.source, current && noteOf(current) !== '' ? '有备注' : ''].filter(Boolean).join(' · ') + + const menuItems = events.length === 0 + ? [{ type: 'label' as const, id: 'none', text: '还没有日程' }] + : events.map((event: any) => ({ id: event.uid, label: event.title, icon: React.createElement(IconClock) })) + + return React.createElement('div', { + onDragEnter: (event: any) => { if (event.dataTransfer?.types?.includes('Files')) { event.preventDefault(); setDropping(true) } }, + onDragOver: (event: any) => { if (event.dataTransfer?.types?.includes('Files')) event.preventDefault() }, + onDragLeave: (event: any) => { if (event.currentTarget === event.target) setDropping(false) }, + onDrop: async (event: any) => { + event.preventDefault() + setDropping(false) + const file = event.dataTransfer?.files?.[0] + if (!file) return + try { + const text = await readCalendarFile(file) + setView({ kind: 'week' }) + setDialog({ kind: 'import', text, name: file.name }) + } catch (error) { + setProblem(messageOf(error)) + } + }, + style: { + position: 'relative', height: '100%', display: 'flex', flexDirection: 'column', + minHeight: 0, background: PAGE_BACKGROUND, color: PRIMARY, + }, + }, + // Header: what is on screen, then the ways to move and to read or write a calendar file. + React.createElement('div', { + style: { + display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', + padding: '12px 16px', borderBottom: CARD_BORDER, flex: '0 0 auto', + }, + }, + view.kind === 'event' + ? React.createElement(Button, { + variant: 'ghost', size: 'sm', icon: React.createElement(IconChevronLeft), + onClick: () => setView({ kind: 'week' }), + }, '返回') + : undefined, + React.createElement('div', { style: { minWidth: 0 } }, + React.createElement('div', { style: { fontSize: 16, fontWeight: 650, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, title), + React.createElement('div', { style: { fontSize: 12, color: SECONDARY } }, loading ? '正在读取…' : subtitle)), + React.createElement('span', { style: { flex: '1 1 auto' } }), + view.kind === 'week' + ? React.createElement(React.Fragment, null, + React.createElement(Button, { + variant: 'outline', size: 'sm', disabled: anchor === startOfWeek(), + onClick: () => setAnchor(startOfWeek()), + }, '今天'), + React.createElement(Tooltip, { label: '上一周', side: 'bottom' }, + React.createElement(Button, { + variant: 'ghost', size: 'sm', 'aria-label': '上一周', icon: React.createElement(IconChevronLeft), + onClick: () => setAnchor(addDays(anchor, -7)), + })), + React.createElement(Tooltip, { label: '下一周', side: 'bottom' }, + React.createElement(Button, { + variant: 'ghost', size: 'sm', 'aria-label': '下一周', icon: React.createElement(IconChevronRight), + onClick: () => setAnchor(addDays(anchor, 7)), + }))) + : undefined, + React.createElement(Button, { + variant: 'primary', size: 'sm', icon: React.createElement(IconPlus), + onClick: () => setDialog({ kind: 'create', date: view.kind === 'event' && current ? dateOf(current.start) : createDate }), + }, '新建日程'), + React.createElement(Menu, { + open: menuOpen, + onClose: () => setMenuOpen(false), + items: menuItems, + selectedId: view.id, + onSelect: (id: string) => (events.some((event: any) => event.uid === id) ? openEvent(id) : undefined), + align: 'end', + anchor: React.createElement(Button, { + variant: 'ghost', size: 'sm', + icon: React.createElement(IconClock), + onClick: () => setMenuOpen(!menuOpen), + }, '日程', React.createElement(IconChevronDown)), + }), + React.createElement(Button, { + variant: 'ghost', size: 'sm', icon: React.createElement(IconFolderOpen), + onClick: () => setDialog({ kind: 'import' }), + }, '导入'), + React.createElement(Button, { + variant: 'ghost', size: 'sm', icon: React.createElement(IconDownload), + onClick: () => setDialog({ kind: 'export' }), + }, '导出'), + React.createElement(Tooltip, { label: '刷新', side: 'bottom' }, + React.createElement(Button, { + variant: 'ghost', size: 'sm', 'aria-label': '刷新', disabled: loading, + icon: React.createElement(IconRefresh), + onClick: () => void refresh(), + }))), + problem + ? React.createElement('div', { + style: { + display: 'flex', alignItems: 'center', gap: 6, padding: '6px 16px', fontSize: 12, flex: '0 0 auto', + color: 'var(--dsw-alias-state-error-primary, #d4553f)', background: 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #d4553f) 8%, transparent)', + }, + }, + React.createElement(IconWarning), problem) + : undefined, + view.kind === 'week' + ? (events.length === 0 && weekCount === 0 && !loading + ? React.createElement('div', { style: { flex: '1 1 auto', display: 'grid', placeItems: 'center', padding: 24 } }, + React.createElement('div', { + style: { maxWidth: 380, padding: 20, borderRadius: 12, border: CARD_BORDER, background: LAYER, textAlign: 'center' }, + }, + React.createElement(IconAlarmClock), + React.createElement('div', { style: { margin: '8px 0 4px', fontSize: 14, fontWeight: 600 } }, '日历还是空的'), + React.createElement(Muted, null, '先新建一条日程,或把从教务系统、其他日历导出的 .ics 文件拖进这个面板。双击时间轴上的空白处也能新建。'), + React.createElement('div', { style: { marginTop: 12, display: 'flex', gap: 8, justifyContent: 'center' } }, + React.createElement(Button, { + variant: 'primary', size: 'sm', icon: React.createElement(IconPlus), + onClick: () => setDialog({ kind: 'create', date: createDate }), + }, '新建日程'), + React.createElement(Button, { + variant: 'outline', size: 'sm', icon: React.createElement(IconFolderOpen), + onClick: () => setDialog({ kind: 'import' }), + }, '导入日历')))) + : React.createElement(WeekGrid, { + anchor, + occurrences: week?.occurrences ?? [], + onSelect: (occurrence: any) => setDialog({ kind: 'event', occurrence }), + onCreate: createAt, + })) + : React.createElement(EventPage, { + event: current, + agenda, + busy, + onOpenOccurrence: (occurrence: any) => setDialog({ kind: 'event', occurrence }), + onSave: (event: any) => run(() => service.putEvent(event), '日程信息已保存'), + onRestore: (event: any, date: string) => run(() => service.restoreOccurrence({ uid: event.uid, date }), `已恢复 ${shortDate(date)} 的日程`), + onDelete: (event: any) => run(() => service.deleteEvent({ uid: event.uid }), '已删除这条日程') + .then((ok: boolean) => { if (ok) setView({ kind: 'week' }) }), + onCancel: (occurrence: any) => run( + () => service.cancelOccurrence({ uid: occurrence.uid, date: occurrence.occurrenceDate ?? dateOf(occurrence.start) }), + `已取消 ${shortDate(occurrence.occurrenceDate ?? dateOf(occurrence.start))} 的日程`, + ), + }), + dialog?.kind === 'event' + ? React.createElement(EventDialog, { + occurrence: dialog.occurrence, + event: events.find((item: any) => item.uid === dialog.occurrence?.uid), + service, + run, + onEdit: openEvent, + onClose: () => setDialog(null), + }) + : undefined, + dialog?.kind === 'create' + ? React.createElement(CreateDialog, { + defaultDate: dialog.date, + defaultStart: dialog.start, + defaultEnd: dialog.end, + service, + run, + onSaved: () => setDialog(null), + onClose: () => setDialog(null), + }) + : undefined, + dialog?.kind === 'import' + ? React.createElement(ImportDialog, { + initialText: dialog.text, + initialName: dialog.name, + service, + run, + onClose: () => { setDialog(null); void refresh() }, + }) + : undefined, + dialog?.kind === 'export' + ? React.createElement(ExportDialog, { service, toast: setToast, onClose: () => setDialog(null) }) + : undefined, + toast + ? React.createElement(Toast, { text: toast, onDone: () => setToast(null) }) + : undefined, + dropping + ? React.createElement('div', { + 'aria-hidden': true, + style: { + position: 'fixed', inset: 0, zIndex: 90, display: 'grid', placeItems: 'center', pointerEvents: 'none', + background: 'color-mix(in srgb, var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, .32)) 82%, transparent)', + backdropFilter: 'blur(5px)', + }, + }, + React.createElement('div', { + style: { + padding: '22px 28px', textAlign: 'center', borderRadius: 16, + border: '1px dashed var(--dsw-alias-border-l2, #0000002e)', + background: 'var(--dsw-alias-bg-module-platform, #fff)', color: PRIMARY, + boxShadow: '0 18px 50px rgba(0, 0, 0, .16)', + }, + }, + React.createElement('div', { style: { fontSize: 28 } }, '⇩'), + React.createElement('strong', { style: { display: 'block', fontSize: 14 } }, '松开即可导入日历'), + React.createElement(Muted, null, '.ics 文件会读进导入窗口,确认后再写入'))) + : undefined) + } +} + +/** Sidebar glyph: a month grid with one marked day. */ +function CalendarIcon(props: any) { + return React.createElement('svg', { + width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, + stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', + ...props, + }, + React.createElement('rect', { x: 2, y: 3, width: 12, height: 11, rx: 2 }), + React.createElement('path', { d: 'M2 6.5h12M5.5 2v2.5M10.5 2v2.5' }), + React.createElement('path', { d: 'M5 9.5h2.5v2.5H5z', fill: 'currentColor', stroke: 'none' })) +} + +export async function apply(ctx: any) { + // The descriptors the package publishes as "./remote" are what makes the + // host `calendar` service callable from this half. + const disposeRemote = await ctx.remote.$mount(calendarRemote) + ctx.inject(['remote.calendar'], (surface: any) => { + // The gateway checks the argument count of every call, so the one method + // that takes none is called with none. + const call = (method: string) => (input?: any) => unwrap(surface.remote.calendar[method](input)) + const service = { + snapshot: () => unwrap(surface.remote.calendar.snapshot()), + occurrences: call('occurrences'), + importIcs: call('importIcs'), + exportIcs: call('exportIcs'), + applyOverride: call('applyOverride'), + removeOverride: call('removeOverride'), + cancelOccurrence: call('cancelOccurrence'), + restoreOccurrence: call('restoreOccurrence'), + deleteEvent: call('deleteEvent'), + putEvent: call('putEvent'), + } + surface.slots.inject('main', () => surface.slots.register( + { name: 'main', key: PANEL_ID }, + createCalendarPage(service), + )) + surface.slots.inject('sidebar.panellist', () => surface.slots.register( + { name: 'sidebar.panellist', id: PANEL_ID, order: 100, label: '日历' }, + CalendarIcon, + )) + }) + return async () => { await disposeRemote() } +} diff --git a/dsh-plugins/calendar/src/layout.js b/dsh-plugins/calendar/src/layout.js new file mode 100644 index 00000000..521289b9 --- /dev/null +++ b/dsh-plugins/calendar/src/layout.js @@ -0,0 +1,220 @@ +// View geometry of the week grid, plus the one piece of clock arithmetic the edit +// form needs: pure arithmetic, no DOM and no React. +// +// The week view is a time axis, so every timed occurrence becomes a block whose +// top and height come from its clock, and blocks that overlap inside one day +// share that day's width. That arithmetic is the part of the view worth testing +// on its own, so it lives here; `src/client.ts` only scales and paints it. +// +// Every returned position is in minutes from local midnight, not pixels, so the +// same layout serves any row height and cannot disagree with the axis labels. + +/** Minutes in a whole day; also the exclusive upper bound of the axis. */ +export const MINUTES_IN_DAY = 24 * 60 + +/** Length given to an occurrence that carries no end, in minutes. */ +export const DEFAULT_DURATION = 45 + +const pad2 = value => String(value).padStart(2, '0') + +/** Local midnight of a `YYYY-MM-DD` date. */ +function parseDay(date) { + const [year, month, day] = date.split('-').map(Number) + return new Date(year, month - 1, day) +} + +/** Whole days from one local date to another. */ +function dayGap(from, to) { + return Math.round((parseDay(to) - parseDay(from)) / 86400000) +} + +/** The local `YYYY-MM-DD` date `days` away. */ +function shiftDay(date, days) { + const value = parseDay(date) + value.setDate(value.getDate() + days) + return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}` +} + +/** + * Minutes from local midnight of a local time string. + * @param value - `YYYY-MM-DDTHH:mm`, or anything else. + * @returns minutes in `[0, 1440)`, or `undefined` when the value carries no clock. + */ +export function minutesOf(value) { + if (typeof value !== 'string' || value.length < 16) return undefined + const hours = Number(value.slice(11, 13)) + const minutes = Number(value.slice(14, 16)) + if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return undefined + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return undefined + return hours * 60 + minutes +} + +/** + * The end that keeps an entry's length when only its start moved. + * + * An edit form that opens with both fields filled hands back an end that never + * moved, so saving a new start on its own would keep the old clock time and can + * finish before it begins. Carrying the span instead keeps a 90-minute entry 90 + * minutes long, and follows a start that crosses into another day. + * @param start - New local start, `YYYY-MM-DDTHH:mm`. + * @param from - Start the entry had. + * @param to - End the entry had. + * @returns Local end that keeps `to - from`, or `undefined` when a value has no clock. + */ +export function shiftedEnd(start, from, to) { + const startMinutes = minutesOf(start) + const fromMinutes = minutesOf(from) + const toMinutes = minutesOf(to) + if (startMinutes === undefined || fromMinutes === undefined || toMinutes === undefined) return undefined + const length = dayGap(from.slice(0, 10), to.slice(0, 10)) * MINUTES_IN_DAY + (toMinutes - fromMinutes) + const at = startMinutes + length + const date = shiftDay(start.slice(0, 10), Math.floor(at / MINUTES_IN_DAY)) + const clock = ((at % MINUTES_IN_DAY) + MINUTES_IN_DAY) % MINUTES_IN_DAY + return `${date}T${pad2(Math.floor(clock / 60))}:${pad2(clock % 60)}` +} + +/** + * Vertical bounds of the axis, in minutes. + * + * The default working day is always visible and the data may widen it, padded by + * half an hour and rounded out to whole hours. Padding only applies to a side the + * data actually leaves, so a week whose first lesson is at 08:00 still starts at + * 08:00 instead of drifting to 07:00 to make room for padding it does not need. + * Keeping a floor means the axis does not jump between weeks while the reader is + * stepping through them. + * @param occurrences - Occurrences of the visible days; whole-day ones carry no clock to place. + * @param options - `from`/`to` are the guaranteed bounds, `pad` the slack, `step` the rounding. + * @returns `{ start, end }` in minutes, `start < end`. + */ +export function dayWindow(occurrences, options = {}) { + const from = options.from ?? 8 * 60 + const to = options.to ?? 20 * 60 + const pad = options.pad ?? 30 + const step = options.step ?? 60 + let first = Infinity + let last = -Infinity + for (const occurrence of occurrences ?? []) { + const start = minutesOf(occurrence?.start) + if (start === undefined) continue + const end = minutesOf(occurrence?.end) ?? start + DEFAULT_DURATION + first = Math.min(first, start) + last = Math.max(last, end) + } + if (!Number.isFinite(first)) return { start: from, end: to } + const start = first < from ? Math.max(0, floorTo(first - pad, step)) : from + const end = last > to ? Math.min(MINUTES_IN_DAY, ceilTo(last + pad, step)) : to + return { start, end } +} + +/** Round down to a `step` grid, counting from midnight. */ +function floorTo(minutes, step) { + return Math.floor(minutes / step) * step +} + +/** Round up to a `step` grid, counting from midnight. */ +function ceilTo(minutes, step) { + return Math.ceil(minutes / step) * step +} + +/** + * Place one day's timed occurrences. + * + * Blocks that overlap form a cluster and split the day's width between them; + * a block starting no earlier than the last end of the current cluster opens a + * new one and gets the full width, which is what keeps back-to-back lessons + * readable. Lanes inside a cluster are assigned greedily: a block takes the + * first lane free at its start. + * @param occurrences - Occurrences of one day; entries without a clock are skipped. + * @param options - `minHeight` keeps a very short block clickable, in minutes. + * @returns one entry per timed occurrence, in start order, as + * `{ occurrence, start, end, top, height, lane, lanes }` in minutes. + */ +export function layoutDay(occurrences, options = {}) { + const minHeight = options.minHeight ?? 22 + const items = [] + for (const occurrence of occurrences ?? []) { + // A whole-day record (`DTSTART;VALUE=DATE`) has no clock, so the axis has no + // slot for it: the week draws those in the day header instead, where they are + // visible whatever the axis is scrolled to and claim no time of their own. + const start = minutesOf(occurrence?.start) + if (start === undefined) continue + const rawEnd = minutesOf(occurrence?.end) + // An end that is not after the start either crosses midnight or is a + // zero-length record; the first runs to the end of the day, the second keeps + // the minimum height without moving the clock. + const end = rawEnd === undefined + ? start + DEFAULT_DURATION + : rawEnd > start + ? rawEnd + : rawEnd < start + ? MINUTES_IN_DAY + : start + 1 + items.push({ occurrence, start, end, lane: 0 }) + } + items.sort((left, right) => + left.start - right.start + || right.end - left.end + || String(left.occurrence?.occurrenceId ?? '').localeCompare(String(right.occurrence?.occurrenceId ?? ''))) + + const placed = [] + let cluster = [] + let clusterEnd = -Infinity + const close = () => { + if (cluster.length === 0) return + const laneEnds = [] + for (const item of cluster) { + let lane = laneEnds.findIndex(end => end <= item.start) + if (lane === -1) { + lane = laneEnds.length + laneEnds.push(item.end) + } else { + laneEnds[lane] = item.end + } + item.lane = lane + } + for (const item of cluster) { + placed.push({ + occurrence: item.occurrence, + start: item.start, + end: item.end, + top: item.start, + height: Math.max(minHeight, item.end - item.start), + lane: item.lane, + lanes: laneEnds.length, + }) + } + cluster = [] + } + for (const item of items) { + if (cluster.length > 0 && item.start >= clusterEnd) close() + cluster.push(item) + clusterEnd = cluster.length === 1 ? item.end : Math.max(clusterEnd, item.end) + } + close() + return placed +} + +/** + * Place a whole week, one day at a time. + * + * The date is part of a block's identity: two lessons that both start at 08:00 + * but fall on different weekdays are not in conflict and must not share lanes. + * Callers hand in whatever the range query returned, so the grouping happens + * here instead of being something every caller has to remember to do. + * @param occurrences - Occurrences of the visible days. + * @param options - forwarded to `layoutDay`. + * @returns a `Map` from `YYYY-MM-DD` to that day's placed entries. + */ +export function layoutWeek(occurrences, options = {}) { + const days = new Map() + for (const occurrence of occurrences ?? []) { + const date = String(occurrence?.start ?? '').slice(0, 10) + if (date.length !== 10) continue + const list = days.get(date) + if (list) list.push(occurrence) + else days.set(date, [occurrence]) + } + const placed = new Map() + for (const [date, list] of days) placed.set(date, layoutDay(list, options)) + return placed +} diff --git a/dsh-plugins/calendar/test/ics.test.mjs b/dsh-plugins/calendar/test/ics.test.mjs new file mode 100644 index 00000000..ec969baf --- /dev/null +++ b/dsh-plugins/calendar/test/ics.test.mjs @@ -0,0 +1,490 @@ +// ICS import and export: the field mapping, folding and escaping rules of +// RFC 5545 that this module implements, and the round trip that must not lose +// anything it did not understand. Pure text in, records out: no storage, no Host. +import test from 'node:test' +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const runtime = process.env.EDUWORK_TEST_RUNTIME +const runtimeParent = runtime ? pathToFileURL(join(runtime, 'package.json')).href : undefined + +let hooks +function bindRuntime() { + hooks ??= registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'zod' || specifier.startsWith('@deepseek-ai/')) return nextResolve(specifier, { ...context, parentURL: runtimeParent }) + return nextResolve(specifier, context) + }, + }) +} + +let modules +async function load() { + bindRuntime() + modules ??= { + ics: await import('../lib/ics.js'), + model: await import('../lib/model.js'), + } + return modules +} + +test.after(() => hooks?.deregister()) + +/** Join content lines with CRLF, the way a real calendar file is written. */ +const calendar = (...lines) => lines.join('\r\n') + '\r\n' + +const WRAPPER = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//fixture//fixture//EN'] + +test('a VEVENT maps onto the record fields', { skip: !runtime }, async () => { + const { ics, model } = await load() + const { events, skipped, degraded } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-1', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'DTEND:20260302T093500', + 'LOCATION:理科楼 305', + 'DESCRIPTION:第 1 周', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.deepEqual(skipped, []) + assert.deepEqual(degraded, []) + assert.equal(events.length, 1) + const [event] = events + assert.equal(event.uid, 'fixture-1') + assert.equal(event.title, '数据结构') + assert.equal(event.start, '2026-03-02T08:00') + assert.equal(event.end, '2026-03-02T09:35') + assert.equal(event.location, '理科楼 305') + assert.equal(event.description, '第 1 周') + assert.equal(event.source, 'ics') + // What the importer produces has to survive the durable record contract. + assert.doesNotThrow(() => model.EventSchema.parse(event)) +}) + +test('an all-day VEVENT keeps our inclusive end', { skip: !runtime }, async () => { + const { ics } = await load() + const { events } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-allday', + 'SUMMARY:校运动会', + 'DTSTART;VALUE=DATE:20260320', + 'DTEND;VALUE=DATE:20260323', + 'END:VEVENT', + 'END:VCALENDAR', + )) + // ICS ends on the next day, exclusively: the 20th to the 22nd are covered. + assert.equal(events[0].start, '2026-03-20') + assert.equal(events[0].end, '2026-03-22') +}) + +test('a UTC stamp becomes local time and a TZID is remembered', { skip: !runtime }, async () => { + const { ics } = await load() + const { events } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-utc', + 'SUMMARY:线上课', + 'DTSTART:20260302T000000Z', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:fixture-tzid', + 'SUMMARY:另一节线上课', + 'DTSTART;TZID=Asia/Shanghai:20260303T080000', + 'END:VEVENT', + 'END:VCALENDAR', + )) + const utc = new Date(Date.UTC(2026, 2, 2, 0, 0)) + assert.equal(events[0].start, `${utc.getFullYear()}-${String(utc.getMonth() + 1).padStart(2, '0')}-${String(utc.getDate()).padStart(2, '0')}T${String(utc.getHours()).padStart(2, '0')}:00`) + // No timezone database in this version: the wall clock is kept and the zone is + // recorded rather than silently dropped. + assert.equal(events[1].start, '2026-03-03T08:00') + assert.equal(events[1].extensions['X-EDUWORK-TZID'], 'Asia/Shanghai') +}) + +test('folded lines are rejoined and a bare LF file is accepted', { skip: !runtime }, async () => { + const { ics } = await load() + const folded = calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-folded', + 'SUMMARY:很长的日程名称用来触发折行处理', + 'DTSTART:20260', + ' 302T080000', + 'END:VEVENT', + 'END:VCALENDAR', + ) + assert.equal(ics.parseIcs(folded).events[0].start, '2026-03-02T08:00') + const bare = ['BEGIN:VCALENDAR', 'BEGIN:VEVENT', 'UID:fixture-lf', 'SUMMARY:换行', 'DTSTART:20260304T100000', 'END:VEVENT', 'END:VCALENDAR', ''].join('\n') + const parsed = ics.parseIcs(bare) + assert.equal(parsed.events.length, 1) + assert.equal(parsed.events[0].title, '换行') + assert.equal(parsed.events[0].start, '2026-03-04T10:00') +}) + +test('escaped text is unescaped, and escaped again on the way out', { skip: !runtime }, async () => { + const { ics } = await load() + const title = '数据结构, 上机; 与实验\\练习' + const { events } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-escaped', + 'SUMMARY:数据结构\\, 上机\\; 与实验\\\\练习', + 'DESCRIPTION:第一行\\n第二行', + 'DTSTART:20260302T080000', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.equal(events[0].title, title) + assert.equal(events[0].description, '第一行\n第二行') + const text = ics.buildIcs({ events: [{ ...events[0], source: undefined }] }) + assert.match(text, /SUMMARY:数据结构\\, 上机\\; 与实验\\\\练习/) + assert.match(text, /DESCRIPTION:第一行\\n第二行/) + assert.equal(ics.parseIcs(text).events[0].title, title) +}) + +test('a weekly RRULE inside the subset becomes a recurrence', { skip: !runtime }, async () => { + const { ics, model } = await load() + const { events, degraded } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-rule', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;BYDAY=MO,WE', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.deepEqual(degraded, []) + assert.deepEqual(events[0].recurrence, { freq: 'weekly', interval: 2, count: 8, byDay: ['mo', 'we'] }) + assert.doesNotThrow(() => model.EventSchema.parse(events[0])) + + const until = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-until', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'RRULE:FREQ=WEEKLY;UNTIL=20260630', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.deepEqual(until.events[0].recurrence, { freq: 'weekly', until: '2026-06-30' }) +}) + +test('a rule outside the subset is kept as text, not dropped', { skip: !runtime }, async () => { + const { ics, model } = await load() + const { events, degraded } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-monthly', + 'SUMMARY:每月例会', + 'DTSTART:20260302T080000', + 'RRULE:FREQ=MONTHLY;BYMONTHDAY=1', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.equal(events[0].recurrence, undefined) + assert.equal(events[0].extensions.RRULE, 'FREQ=MONTHLY;BYMONTHDAY=1') + assert.equal(degraded.length, 1) + assert.match(degraded[0].reason, /unsupported frequency MONTHLY/) + assert.equal(degraded[0].uid, 'fixture-monthly') + assert.doesNotThrow(() => model.EventSchema.parse(events[0])) +}) + +test('EXDATE becomes exceptions, in every form a file may use', { skip: !runtime }, async () => { + const { ics } = await load() + const { events } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-exdate', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'RRULE:FREQ=WEEKLY;COUNT=16', + 'EXDATE:20260316T080000,20260323T080000', + 'EXDATE;VALUE=DATE:20260406', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.deepEqual(events[0].exceptions, ['2026-03-16', '2026-03-23', '2026-04-06']) +}) + +test('RECURRENCE-ID keeps only the fields that changed', { skip: !runtime }, async () => { + const { ics, model } = await load() + const { events } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-moved', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'DTEND:20260302T093500', + 'LOCATION:理科楼 305', + 'RRULE:FREQ=WEEKLY;COUNT=16', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:fixture-moved', + 'RECURRENCE-ID:20260309T080000', + 'SUMMARY:数据结构', + 'DTSTART:20260313T100000', + 'DTEND:20260313T113500', + 'LOCATION:文史楼 202', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.equal(events.length, 1) + assert.deepEqual(events[0].overrides, [ + { date: '2026-03-09', start: '2026-03-13T10:00', end: '2026-03-13T11:35', location: '文史楼 202' }, + ]) + // The title was unchanged, so it is inherited rather than pinned. + assert.equal(events[0].overrides[0].title, undefined) + assert.doesNotThrow(() => model.EventSchema.parse(events[0])) +}) + +test('a cancellation is an exception, not an event', { skip: !runtime }, async () => { + const { ics } = await load() + const { events } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-cancelled', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'RRULE:FREQ=WEEKLY;COUNT=16', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:fixture-cancelled', + 'RECURRENCE-ID:20260316T080000', + 'STATUS:CANCELLED', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.equal(events.length, 1) + assert.deepEqual(events[0].exceptions, ['2026-03-16']) + assert.equal(events[0].overrides, undefined) +}) + +test('an override without its series is still an entry', { skip: !runtime }, async () => { + const { ics } = await load() + const { events, skipped } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-orphan', + 'RECURRENCE-ID:20260309T080000', + 'SUMMARY:补课 · 高等数学', + 'DTSTART:20260312T133000', + 'LOCATION:文史楼 201', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.deepEqual(skipped, []) + assert.equal(events.length, 1) + assert.equal(events[0].start, '2026-03-12T13:30') + assert.equal(events[0].title, '补课 · 高等数学') + assert.equal(events[0].overrides, undefined) +}) + +test('course properties of an earlier shape are read and dropped', { skip: !runtime }, async () => { + const { ics, model } = await load() + const { events } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-course', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'X-EDUWORK-COURSE-ID:course-cs-1', + 'X-EDUWORK-COURSE-NAME:数据结构', + 'X-EDUWORK-COURSE-TEACHER:张老师', + 'X-EDUWORK-COURSE-DEFAULT-LOCATION:理科楼 305', + 'X-EDUWORK-NOTE:实验课在 305', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.equal(events.length, 1) + assert.equal('courseId' in events[0], false) + // Not parked in the preserved area either: a round trip should not carry an + // entity this module no longer has. + assert.deepEqual(events[0].extensions, {}) + assert.doesNotThrow(() => model.EventSchema.parse(events[0])) + const text = ics.buildIcs({ events }) + for (const name of ['X-EDUWORK-COURSE-ID', 'X-EDUWORK-COURSE-NAME', 'X-EDUWORK-COURSE-TEACHER', 'X-EDUWORK-COURSE-DEFAULT-LOCATION', 'X-EDUWORK-NOTE']) { + assert.equal(text.includes(name), false, `${name} should not be written back`) + } +}) + +test('a calendar-level catalogue is ignored rather than carried on', { skip: !runtime }, async () => { + const { ics } = await load() + const source = calendar( + ...WRAPPER, + `X-EDUWORK-COURSES:${ics.escapeText(JSON.stringify([{ id: 'course-cs-1', name: '数据结构' }]))}`, + 'BEGIN:VEVENT', + 'UID:fixture-course', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'X-EDUWORK-COURSE-ID:course-cs-1', + 'END:VEVENT', + 'END:VCALENDAR', + ) + const { events, skipped } = ics.parseIcs(source) + assert.deepEqual(skipped, []) + assert.equal(events.length, 1) + assert.equal(ics.buildIcs({ events }).includes('X-EDUWORK-COURSES'), false) +}) + +test('unknown properties and unplaceable events are reported, never dropped silently', { skip: !runtime }, async () => { + const { ics, model } = await load() + const { events, skipped } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-extra', + 'SUMMARY:带扩展的事件', + 'DTSTART:20260302T080000', + 'X-FOO-BAR:kept as it came', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:fixture-nowhere', + 'SUMMARY:没有开始时间', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:fixture-dropped', + 'SUMMARY:整条取消', + 'DTSTART:20260305T080000', + 'STATUS:CANCELLED', + 'END:VEVENT', + 'END:VCALENDAR', + )) + assert.equal(events.length, 1) + assert.equal(events[0].extensions['X-FOO-BAR'], 'kept as it came') + assert.doesNotThrow(() => model.EventSchema.parse(events[0])) + assert.deepEqual(skipped, [ + { uid: 'fixture-nowhere', reason: 'a VEVENT without a readable DTSTART cannot be placed' }, + { uid: 'fixture-dropped', reason: 'the event is cancelled' }, + ]) +}) + +test('exported text is well formed: CRLF, folded at 75 octets, escaped', { skip: !runtime }, async () => { + const { ics } = await load() + const title = '很长的日程名称,用来确认折行不会把一个汉字切成两半,也不会超过七十五个八位组' + const text = ics.buildIcs({ + events: [{ + uid: 'fixture-1', + title, + start: '2026-03-02T08:00', + end: '2026-03-02T09:35', + location: '理科楼 305, 三楼', + recurrence: { freq: 'weekly', count: 16, byDay: ['mo', 'we'] }, + exceptions: ['2026-03-16'], + source: 'manual', + extensions: {}, + }], + }) + assert.match(text, /\r\n/) + assert.equal(/\n(?!\r)/.test(text) === false || !text.includes('\n\n'), true) + const physical = text.split('\r\n') + assert.equal(physical.at(-1), '') + assert.ok(physical.some(line => line.startsWith(' ')), 'a long line should have been folded') + for (const line of physical) { + assert.ok(Buffer.byteLength(line, 'utf8') <= 75, `line over 75 octets: ${JSON.stringify(line)}`) + // A continuation line is content of the property above it, so it is the + // lines that begin a property that have to look like one. + if (line && !line.startsWith(' ')) assert.match(line, /^[A-Za-z0-9-]+(;[^:]*)?:/) + } + const lines = ics.unfoldLines(text) + assert.ok(lines.includes('VERSION:2.0')) + assert.match(lines.find(line => line.startsWith('PRODID:')), /EduWork/) + assert.equal(lines.find(line => line.startsWith('SUMMARY:')), `SUMMARY:${ics.escapeText(title)}`) + assert.equal(lines.find(line => line.startsWith('DTSTART')), 'DTSTART:20260302T080000') + assert.equal(lines.find(line => line.startsWith('EXDATE')), 'EXDATE:20260316T080000') + assert.equal(lines.find(line => line.startsWith('RRULE')), 'RRULE:FREQ=WEEKLY;COUNT=16;BYDAY=MO,WE') + assert.equal(lines.find(line => line.startsWith('LOCATION')), `LOCATION:${ics.escapeText('理科楼 305, 三楼')}`) +}) + +test('an empty calendar is valid in both directions', { skip: !runtime }, async () => { + const { ics } = await load() + const text = ics.buildIcs({ events: [] }) + assert.deepEqual(ics.parseIcs(text), { events: [], skipped: [], degraded: [] }) +}) + +test('a round trip through ICS changes nothing it understood', { skip: !runtime }, async () => { + const { ics, model } = await load() + const events = [ + { + uid: 'fixture-series', + title: '数据结构', + start: '2026-03-02T08:00', + end: '2026-03-02T09:35', + location: '理科楼 305', + description: '第 1 周\n带上电脑', + recurrence: { freq: 'weekly', interval: 1, count: 16, byDay: ['mo', 'we'] }, + exceptions: ['2026-03-16', '2026-03-23'], + overrides: [{ date: '2026-03-09', start: '2026-03-13T10:00', location: '文史楼 202' }], + source: 'manual', + extensions: { 'X-FOO-BAR': 'kept', RRULE: undefined }, + }, + { + uid: 'fixture-monthly', + title: '每月例会', + start: '2026-03-02T10:00', + source: 'manual', + extensions: { RRULE: 'FREQ=MONTHLY;BYMONTHDAY=1' }, + }, + { + uid: 'fixture-allday', + title: '校运动会', + start: '2026-03-20', + end: '2026-03-22', + source: 'manual', + extensions: {}, + }, + ].map(event => { + const clean = { ...event } + if (clean.extensions?.RRULE === undefined) delete clean.extensions.RRULE + return model.normalizeEvent(clean) + }) + + const text = ics.buildIcs({ events }) + const parsed = ics.parseIcs(text) + assert.deepEqual(parsed.skipped, []) + assert.deepEqual(parsed.degraded.map(entry => entry.uid), ['fixture-monthly']) + const before = events.map(event => model.normalizeEvent(event)) + const after = parsed.events + .map(event => model.normalizeEvent({ ...event, source: 'manual' })) + .sort((left, right) => left.uid.localeCompare(right.uid)) + assert.deepEqual(after, [...before].sort((left, right) => left.uid.localeCompare(right.uid))) + + // And the expansion agrees with what the original records produce, which is + // what a re-import has to preserve in the end. + const range = { from: '2026-03-01', to: '2026-04-30' } + for (const event of before) { + const match = after.find(candidate => candidate.uid === event.uid) + assert.deepEqual( + model.occurrencesOf(match, range).map(occurrence => [occurrence.occurrenceDate, occurrence.start, occurrence.end, occurrence.location]), + model.occurrencesOf(event, range).map(occurrence => [occurrence.occurrenceDate, occurrence.start, occurrence.end, occurrence.location]), + `occurrences of ${event.uid} differ after a round trip`, + ) + } +}) + +test('a date-only UNTIL keeps meaning the end of that day', { skip: !runtime }, async () => { + const { ics, model } = await load() + const { events } = ics.parseIcs(calendar( + ...WRAPPER, + 'BEGIN:VEVENT', + 'UID:fixture-until', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'RRULE:FREQ=WEEKLY;UNTIL=20260316', + 'END:VEVENT', + 'END:VCALENDAR', + )) + const range = { from: '2026-03-01', to: '2026-04-30' } + const text = ics.buildIcs({ events }) + const [again] = ics.parseIcs(text).events + assert.deepEqual( + model.occurrencesOf(again, range).map(occurrence => occurrence.occurrenceDate), + model.occurrencesOf(events[0], range).map(occurrence => occurrence.occurrenceDate), + ) +}) diff --git a/dsh-plugins/calendar/test/layout.test.mjs b/dsh-plugins/calendar/test/layout.test.mjs new file mode 100644 index 00000000..cf48b9c9 --- /dev/null +++ b/dsh-plugins/calendar/test/layout.test.mjs @@ -0,0 +1,182 @@ +// Geometry of the week grid: the axis window and the block placement. +// +// These are the decisions a reader sees but cannot check from the outside, so +// each one is pinned: the axis never moves while stepping weeks, side-by-side +// blocks are the overlap case, back-to-back lessons are not, and moving a start +// in the edit form carries the entry's length along with it. +import test from 'node:test' +import assert from 'node:assert/strict' +import { DEFAULT_DURATION, MINUTES_IN_DAY, dayWindow, layoutDay, layoutWeek, minutesOf, shiftedEnd } from '../src/layout.js' + +const LESSON = (start, end, occurrenceId = start) => ({ occurrenceId, start, end }) + +test('shiftedEnd keeps the length of an entry whose start moved', () => { + assert.equal(shiftedEnd('2026-10-05T19:00', '2026-10-05T08:00', '2026-10-05T09:35'), '2026-10-05T20:35') + assert.equal(shiftedEnd('2026-10-05T08:30', '2026-10-05T08:00', '2026-10-05T08:45'), '2026-10-05T09:15', 'a quarter of an hour stays a quarter of an hour') + assert.equal(shiftedEnd('2026-10-05T23:30', '2026-10-05T22:00', '2026-10-05T23:00'), '2026-10-06T00:30', 'a start near midnight carries into the next day') + assert.equal(shiftedEnd('2026-10-08T22:00', '2026-10-05T22:00', '2026-10-06T01:00'), '2026-10-09T01:00', 'an entry that already crossed midnight keeps its three hours') + assert.equal(shiftedEnd('2026-10-05T08:00', '2026-10-05T08:00', '2026-10-05T09:35'), '2026-10-05T09:35', 'an unchanged start is left alone') + assert.equal(shiftedEnd('2026-10-06', '2026-10-05', '2026-10-07'), undefined, 'whole-day records have no clock to move') + assert.equal(shiftedEnd('2026-10-05T08:00', '2026-10-05T08:00', ''), undefined) +}) + +test('minutesOf reads a clock and rejects everything else', () => { + assert.equal(minutesOf('2026-10-05T08:30'), 8 * 60 + 30) + assert.equal(minutesOf('2026-10-05T00:00'), 0) + assert.equal(minutesOf('2026-10-05T23:59'), 23 * 60 + 59) + assert.equal(minutesOf('2026-10-05'), undefined, 'a whole-day value has no clock') + assert.equal(minutesOf('2026-10-05T8:30'), undefined, 'the clock is fixed-width') + assert.equal(minutesOf('2026-10-05T25:00'), undefined, 'hours outside a day are not a clock') + assert.equal(minutesOf('2026-10-05T08:75'), undefined) + assert.equal(minutesOf(undefined), undefined) + assert.equal(minutesOf(830), undefined) +}) + +test('dayWindow keeps the working day visible and widens for the data', () => { + assert.deepEqual(dayWindow([]), { start: 8 * 60, end: 20 * 60 }, 'an empty week keeps the default axis') + assert.deepEqual( + dayWindow([LESSON('2026-10-05T10:00', '2026-10-05T11:35')]), + { start: 8 * 60, end: 20 * 60 }, + 'lessons inside the working day do not move the axis', + ) + assert.deepEqual( + dayWindow([LESSON('2026-10-05T07:00', '2026-10-05T07:45')]), + { start: 6 * 60, end: 20 * 60 }, + 'an early lesson widens the axis and is rounded out to the hour', + ) + assert.deepEqual( + dayWindow([LESSON('2026-10-05T19:00', '2026-10-05T21:20')]), + { start: 8 * 60, end: 22 * 60 }, + 'a late lesson widens it the other way', + ) +}) + +test('dayWindow ignores all-day entries and counts a missing end', () => { + const window = dayWindow([ + LESSON('2026-10-05', '2026-10-06'), + LESSON('2026-10-06T21:00', undefined), + ]) + assert.deepEqual( + window, + { start: 8 * 60, end: 23 * 60 }, + `the all-day entry has no clock to place, the ${DEFAULT_DURATION}-minute assumption widens the axis`, + ) +}) + +test('dayWindow pads a side only when the data leaves it', () => { + assert.deepEqual( + dayWindow([LESSON('2026-10-05T08:00', '2026-10-05T09:35')]), + { start: 8 * 60, end: 20 * 60 }, + 'padding alone must not push the axis to 07:00, or every week would start half an hour early for nothing', + ) + assert.deepEqual( + dayWindow([LESSON('2026-10-05T19:30', '2026-10-05T20:00')]), + { start: 8 * 60, end: 20 * 60 }, + 'an end exactly on the default bound does not widen it either', + ) +}) + +test('layoutDay places one block at its clock', () => { + const placed = layoutDay([LESSON('2026-10-05T10:00', '2026-10-05T11:35')]) + assert.equal(placed.length, 1) + assert.equal(placed[0].top, 10 * 60) + assert.equal(placed[0].height, 95) + assert.equal(placed[0].lane, 0) + assert.equal(placed[0].lanes, 1) +}) + +test('layoutDay keeps back-to-back lessons full width', () => { + const placed = layoutDay([ + LESSON('2026-10-05T08:00', '2026-10-05T09:35', 'a'), + LESSON('2026-10-05T09:35', '2026-10-05T11:10', 'b'), + ]) + assert.deepEqual(placed.map(entry => entry.lanes), [1, 1], 'touching blocks do not overlap') + assert.deepEqual(placed.map(entry => entry.lane), [0, 0]) +}) + +test('layoutDay splits the width of overlapping blocks', () => { + const placed = layoutDay([ + LESSON('2026-10-05T08:00', '2026-10-05T09:35', 'a'), + LESSON('2026-10-05T08:30', '2026-10-05T10:05', 'b'), + ]) + assert.deepEqual(placed.map(entry => entry.lanes), [2, 2]) + assert.deepEqual(placed.map(entry => entry.lane), [0, 1]) +}) + +test('layoutDay opens a new cluster when the overlap ends', () => { + const placed = layoutDay([ + LESSON('2026-10-05T08:00', '2026-10-05T09:00', 'a'), + LESSON('2026-10-05T08:30', '2026-10-05T09:30', 'b'), + LESSON('2026-10-05T09:30', '2026-10-05T10:30', 'c'), + ]) + assert.deepEqual(placed.map(entry => entry.lane), [0, 1, 0]) + assert.deepEqual(placed.map(entry => entry.lanes), [2, 2, 1], 'the third lesson starts when the cluster ends') +}) + +test('layoutDay reuses a lane freed inside one cluster', () => { + const placed = layoutDay([ + LESSON('2026-10-05T08:00', '2026-10-05T09:00', 'a'), + LESSON('2026-10-05T08:30', '2026-10-05T10:30', 'b'), + LESSON('2026-10-05T09:00', '2026-10-05T09:30', 'c'), + ]) + assert.deepEqual(placed.map(entry => entry.lane), [0, 1, 0], 'the short lesson takes the lane the first one freed') + assert.deepEqual(placed.map(entry => entry.lanes), [2, 2, 2]) +}) + +test('layoutDay keeps a three-way overlap readable', () => { + const placed = layoutDay([ + LESSON('2026-10-05T10:00', '2026-10-05T12:00', 'a'), + LESSON('2026-10-05T10:15', '2026-10-05T11:00', 'b'), + LESSON('2026-10-05T10:30', '2026-10-05T11:15', 'c'), + ]) + assert.deepEqual(placed.map(entry => entry.lanes), [3, 3, 3]) + assert.deepEqual(placed.map(entry => entry.lane), [0, 1, 2]) +}) + +test('layoutDay gives a short block a clickable height and clamps to the day', () => { + const placed = layoutDay([ + LESSON('2026-10-05T10:00', '2026-10-05T10:05', 'a'), + LESSON('2026-10-05T23:30', '2026-10-06T01:00', 'b'), + ]) + assert.equal(placed[0].height, 22, 'the minimum height applies, the clock does not move') + assert.equal(placed[0].top, 10 * 60) + assert.equal(placed[1].end, MINUTES_IN_DAY, 'a block crossing midnight ends with the day') +}) + +test('layoutDay sorts by start and skips entries without a clock', () => { + const placed = layoutDay([ + LESSON('2026-10-05', '2026-10-06', 'whole-day'), + LESSON('2026-10-05T14:00', '2026-10-05T15:00', 'late'), + LESSON('2026-10-05T09:00', '2026-10-05T10:00', 'early'), + ]) + assert.deepEqual(placed.map(entry => entry.occurrence.occurrenceId), ['early', 'late'], 'a value without a clock is not on the axis') +}) + +test('layoutDay assumes a duration for a missing end', () => { + const placed = layoutDay([LESSON('2026-10-05T18:00', undefined)]) + assert.equal(placed[0].height, DEFAULT_DURATION) +}) + +test('layoutWeek keeps the same clock on different days out of each other way', () => { + const week = layoutWeek([ + LESSON('2026-10-05T08:00', '2026-10-05T09:35', 'monday'), + LESSON('2026-10-07T08:00', '2026-10-07T09:35', 'wednesday'), + ]) + assert.deepEqual([...week.keys()], ['2026-10-05', '2026-10-07']) + for (const day of week.values()) { + assert.equal(day.length, 1) + assert.equal(day[0].lane, 0, 'a lesson on another day is not a conflict') + assert.equal(day[0].lanes, 1, 'so it keeps the full width of its column') + } +}) + +test('layoutWeek still splits a same-day overlap and skips entries without a date', () => { + const week = layoutWeek([ + LESSON('2026-10-05T10:00', '2026-10-05T11:35', 'a'), + LESSON('2026-10-05T10:30', '2026-10-05T12:00', 'b'), + { occurrenceId: 'no-date', start: undefined, end: undefined }, + ]) + assert.deepEqual([...week.keys()], ['2026-10-05']) + assert.deepEqual(week.get('2026-10-05').map(entry => entry.lanes), [2, 2]) + assert.deepEqual(week.get('2026-10-05').map(entry => entry.lane), [0, 1]) +}) diff --git a/dsh-plugins/calendar/test/model.test.mjs b/dsh-plugins/calendar/test/model.test.mjs new file mode 100644 index 00000000..2682d1db --- /dev/null +++ b/dsh-plugins/calendar/test/model.test.mjs @@ -0,0 +1,120 @@ +// Record shape of the public calendar contract. Pure model tests: no storage, +// no Host, no browser. +import test from 'node:test' +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +// `dsh-plugins/*` ships no node_modules: zod comes from the Runtime pinned by +// the assembly. Without one these tests skip, like the other plugin tests. +const runtime = process.env.EDUWORK_TEST_RUNTIME +const runtimeParent = runtime ? pathToFileURL(join(runtime, 'package.json')).href : undefined + +let hooks +function bindRuntime() { + hooks ??= registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'zod' || specifier.startsWith('@deepseek-ai/')) return nextResolve(specifier, { ...context, parentURL: runtimeParent }) + return nextResolve(specifier, context) + }, + }) +} + +let model +async function loadModel() { + bindRuntime() + model ??= await import('../lib/model.js') + return model +} + +test.after(() => hooks?.deregister()) + +test('an event is defaulted, not rewritten', { skip: !runtime }, async () => { + const { normalizeEvent } = await loadModel() + assert.deepEqual(normalizeEvent({ uid: 'event-fixture-1', title: '示例日程', start: '2026-03-02T08:00' }), { + uid: 'event-fixture-1', + title: '示例日程', + start: '2026-03-02T08:00', + source: 'manual', + extensions: {}, + }) +}) + +test('absent optional fields stay absent instead of becoming null', { skip: !runtime }, async () => { + const { normalizeEvent } = await loadModel() + const event = normalizeEvent({ uid: 'event-fixture-2', title: '示例讲座', start: '2026-03-04', end: null, location: null, recurrence: null }) + assert.equal('end' in event, false) + assert.equal('location' in event, false) + assert.equal('recurrence' in event, false) +}) + +test('the supported recurrence subset is stored, unsupported rules are not', { skip: !runtime }, async () => { + const { normalizeEvent } = await loadModel() + const event = normalizeEvent({ + uid: 'event-fixture-3', + title: '示例日程', + start: '2026-03-02T08:00', + recurrence: { freq: 'weekly', interval: 2, count: 8, until: '2026-06-20', byDay: ['mo', 'we'] }, + }) + assert.deepEqual(event.recurrence, { freq: 'weekly', interval: 2, count: 8, until: '2026-06-20', byDay: ['mo', 'we'] }) + // Monthly/yearly rules and BYSETPOS are out of scope; the importer keeps the + // original RRULE text in `extensions` and degrades the event to one occurrence. + assert.throws(() => normalizeEvent({ uid: 'event-fixture-4', title: '示例', start: '2026-03-02', recurrence: { freq: 'monthly' } })) + const degraded = normalizeEvent({ + uid: 'event-fixture-5', + title: '示例', + start: '2026-03-02', + extensions: { rrule: 'FREQ=MONTHLY;BYMONTHDAY=15' }, + }) + assert.deepEqual(degraded.extensions, { rrule: 'FREQ=MONTHLY;BYMONTHDAY=15' }) +}) + +test('local time strings are the only accepted time format', { skip: !runtime }, async () => { + const { normalizeEvent } = await loadModel() + assert.equal(normalizeEvent({ uid: 'e', title: 't', start: '2026-03-02' }).start, '2026-03-02') + assert.throws(() => normalizeEvent({ uid: 'e', title: 't', start: '2026-03-02T08:00:00Z' }), /expected YYYY-MM-DD/) + assert.throws(() => normalizeEvent({ uid: 'e', title: 't', start: '2026/03/02' }), /expected YYYY-MM-DD/) + assert.throws(() => normalizeEvent({ uid: 'e', title: 't', start: '2026-03-02T8:00' }), /expected YYYY-MM-DD/) +}) + +test('unrecognized fields belong in extensions, not beside the known ones', { skip: !runtime }, async () => { + const { normalizeEvent } = await loadModel() + assert.throws(() => normalizeEvent({ uid: 'e', title: 't', start: '2026-03-02', classroom: 'A' })) + // A calendar holds events and nothing above them: an event still carrying the + // retired `courseId` is a shape this contract does not have. + assert.throws(() => normalizeEvent({ uid: 'e', title: 't', start: '2026-03-02', courseId: 'course-fixture-1' })) + const kept = normalizeEvent({ uid: 'e', title: 't', start: '2026-03-02', extensions: { 'X-TEACHER': '示例教师' } }) + assert.equal(kept.extensions['X-TEACHER'], '示例教师') +}) + +test('required fields are enforced', { skip: !runtime }, async () => { + const { normalizeEvent } = await loadModel() + assert.throws(() => normalizeEvent({ title: 't', start: '2026-03-02' })) + assert.throws(() => normalizeEvent({ uid: '', title: 't', start: '2026-03-02' })) + assert.throws(() => normalizeEvent({ uid: 'e', title: '', start: '2026-03-02' })) +}) + +test('snapshots are versioned and ordered', { skip: !runtime }, async () => { + const { SCHEMA_VERSION, buildSnapshot } = await loadModel() + const snapshot = buildSnapshot([ + { uid: 'late', title: '后', start: '2026-03-04T09:00', source: 'manual', extensions: {} }, + { uid: 'early', title: '先', start: '2026-03-02T09:00', source: 'manual', extensions: {} }, + ]) + assert.equal(snapshot.schemaVersion, SCHEMA_VERSION) + assert.equal(SCHEMA_VERSION, 2) + assert.deepEqual(Object.keys(snapshot), ['schemaVersion', 'events']) + assert.deepEqual(snapshot.events.map(row => row.uid), ['early', 'late']) +}) + +test('range overlap treats a date-only end as the whole day', { skip: !runtime }, async () => { + const { overlaps } = await loadModel() + const allDay = { start: '2026-03-02', end: '2026-03-02' } + assert.equal(overlaps(allDay, { from: '2026-03-02T20:00', to: '2026-03-02T21:00' }), true) + assert.equal(overlaps(allDay, { from: '2026-03-03' }), false) + assert.equal(overlaps(allDay, { to: '2026-03-01' }), false) + assert.equal(overlaps({ start: '2026-03-02T08:00', end: '2026-03-02T09:35' }, { from: '2026-03-02T09:00', to: '2026-03-02T10:00' }), true) + assert.equal(overlaps({ start: '2026-03-02T08:00', end: '2026-03-02T09:35' }, { from: '2026-03-02T09:35', to: '2026-03-02T10:00' }), true) + assert.equal(overlaps({ start: '2026-03-02T08:00', end: '2026-03-02T09:35' }, { from: '2026-03-03' }), false) + assert.equal(overlaps({ start: '2026-03-03T08:00' }, {}), true) +}) diff --git a/dsh-plugins/calendar/test/occurrence.test.mjs b/dsh-plugins/calendar/test/occurrence.test.mjs new file mode 100644 index 00000000..fe13b682 --- /dev/null +++ b/dsh-plugins/calendar/test/occurrence.test.mjs @@ -0,0 +1,244 @@ +// Occurrence expansion of the public contract: how a weekly series, its +// cancellations and its per-date overrides become the occurrences a week view +// renders. Pure model tests: no storage, no Host, no browser. +import test from 'node:test' +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +// `dsh-plugins/*` ships no node_modules: zod comes from the Runtime pinned by +// the assembly. Without one these tests skip, like the other plugin tests. +const runtime = process.env.EDUWORK_TEST_RUNTIME +const runtimeParent = runtime ? pathToFileURL(join(runtime, 'package.json')).href : undefined + +let hooks +function bindRuntime() { + hooks ??= registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'zod' || specifier.startsWith('@deepseek-ai/')) return nextResolve(specifier, { ...context, parentURL: runtimeParent }) + return nextResolve(specifier, context) + }, + }) +} + +let model +async function loadModel() { + bindRuntime() + model ??= await import('../lib/model.js') + return model +} + +test.after(() => hooks?.deregister()) + +// 2026-03-02 is a Monday; every week below is written as Monday..Sunday. +const week = monday => ({ from: monday, to: shift(monday, 6) }) +const shift = (date, days) => { + const value = new Date(`${date}T00:00`) + value.setDate(value.getDate() + days) + const pad = number => String(number).padStart(2, '0') + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` +} + +/** The recurring fixture: one entry every Monday, 16 weeks from 2026-03-02. */ +async function weeklyEvent(extra = {}) { + const { normalizeEvent } = await loadModel() + return normalizeEvent({ + uid: 'event-data-structure', + title: '数据结构', + start: '2026-03-02T08:00', + end: '2026-03-02T09:35', + location: '理科楼 305', + recurrence: { freq: 'weekly', count: 16 }, + ...extra, + }) +} + +test('an occurrence keeps the days its record spans', { skip: !runtime }, async () => { + const { normalizeEvent, occurrencesOf } = await loadModel() + // A whole-day record covering three days, repeating: each occurrence carries the + // same span rather than collapsing its end onto its own start date. + const holiday = normalizeEvent({ + uid: 'event-holiday', + title: '假期', + start: '2026-03-02', + end: '2026-03-04', + recurrence: { freq: 'weekly', count: 3 }, + }) + assert.deepEqual( + occurrencesOf(holiday, week('2026-03-16')).map(occurrence => [occurrence.start, occurrence.end]), + [['2026-03-16', '2026-03-18']], + ) + // A session that runs past midnight ends on the next day, not at one in the + // morning of the day it started. + const night = normalizeEvent({ uid: 'event-night', title: '夜间整理', start: '2026-03-02T22:00', end: '2026-03-03T01:00' }) + assert.deepEqual( + occurrencesOf(night, week('2026-03-02')).map(occurrence => occurrence.end), + ['2026-03-03T01:00'], + ) +}) + +test('a weekly series repeats in every week of the window', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent() + const first = occurrencesOf(event, week('2026-03-02')) + const third = occurrencesOf(event, week('2026-03-16')) + assert.deepEqual(first.map(occurrence => occurrence.start), ['2026-03-02T08:00']) + assert.deepEqual(third.map(occurrence => occurrence.start), ['2026-03-16T08:00']) + assert.equal(third[0].end, '2026-03-16T09:35') + assert.equal(third[0].occurrenceDate, '2026-03-16') + assert.equal(third[0].occurrenceId, 'event-data-structure#2026-03-16') + assert.equal(third[0].location, '理科楼 305') + assert.equal('overridden' in third[0], false) +}) + +test('an occurrence carries no series bookkeeping', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent({ exceptions: ['2026-03-09'], overrides: [{ date: '2026-03-16', location: '文史楼 201' }] }) + const [occurrence] = occurrencesOf(event, week('2026-03-16')) + assert.equal('overrides' in occurrence, false) + assert.equal('exceptions' in occurrence, false) + assert.equal(occurrence.recurrence.freq, 'weekly') +}) + +test('interval skips whole weeks', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent({ recurrence: { freq: 'weekly', interval: 2, count: 8 } }) + assert.deepEqual(occurrencesOf(event, week('2026-03-02')).map(o => o.start), ['2026-03-02T08:00']) + assert.deepEqual(occurrencesOf(event, week('2026-03-09')), []) + assert.deepEqual(occurrencesOf(event, week('2026-03-16')).map(o => o.start), ['2026-03-16T08:00']) +}) + +test('byDay produces every listed weekday and count stops the series', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent({ recurrence: { freq: 'weekly', count: 4, byDay: ['mo', 'we'] } }) + const span = { from: '2026-03-02', to: '2026-03-15' } + assert.deepEqual(occurrencesOf(event, span).map(o => o.start), [ + '2026-03-02T08:00', + '2026-03-04T08:00', + '2026-03-09T08:00', + '2026-03-11T08:00', + ]) + assert.deepEqual(occurrencesOf(event, week('2026-03-16')), []) +}) + +test('until ends the series, with or without a time', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const dated = await weeklyEvent({ recurrence: { freq: 'weekly', until: '2026-03-16' } }) + assert.equal(occurrencesOf(dated, week('2026-03-16')).length, 1) + assert.deepEqual(occurrencesOf(dated, week('2026-03-23')), []) + const beforeStart = await weeklyEvent({ recurrence: { freq: 'weekly', until: '2026-03-16T07:00' } }) + assert.deepEqual(occurrencesOf(beforeStart, week('2026-03-16')), []) + const afterStart = await weeklyEvent({ recurrence: { freq: 'weekly', until: '2026-03-16T09:00' } }) + assert.equal(occurrencesOf(afterStart, week('2026-03-16')).length, 1) +}) + +test('exceptions cancel one date only', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent({ exceptions: ['2026-03-09'] }) + assert.deepEqual(occurrencesOf(event, week('2026-03-09')), []) + assert.equal(occurrencesOf(event, week('2026-03-02')).length, 1) + assert.equal(occurrencesOf(event, week('2026-03-16')).length, 1) + const withTime = await weeklyEvent({ exceptions: ['2026-03-09T08:00'] }) + assert.deepEqual(occurrencesOf(withTime, week('2026-03-09')), []) +}) + +test('a single event yields one occurrence keyed by its own date', { skip: !runtime }, async () => { + const { normalizeEvent, occurrencesOf } = await loadModel() + const event = normalizeEvent({ uid: 'event-fixture-1', title: '示例讲座', start: '2026-03-04T14:00' }) + const [occurrence] = occurrencesOf(event, week('2026-03-02')) + assert.equal(occurrence.occurrenceId, 'event-fixture-1#2026-03-04') + assert.equal(occurrence.occurrenceDate, '2026-03-04') + assert.equal('end' in occurrence, false) + assert.deepEqual(occurrencesOf(event, week('2026-03-09')), []) +}) + +test('a whole-day series repeats without a clock', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent({ + uid: 'event-fixture-allday', + title: '校园跑', + start: '2026-03-06', + end: undefined, + location: undefined, + recurrence: { freq: 'weekly', count: 3 }, + }) + assert.deepEqual(occurrencesOf(event, { from: '2026-03-06', to: '2026-03-20' }).map(o => o.start), ['2026-03-06', '2026-03-13', '2026-03-20']) +}) + +test('an override moves and relocates exactly one occurrence', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent({ + overrides: [{ date: '2026-03-09', start: '2026-03-13T10:00', end: '2026-03-13T11:35', location: '文史楼 201' }], + }) + const target = occurrencesOf(event, week('2026-03-09')) + assert.deepEqual(target.map(o => o.start), ['2026-03-13T10:00']) + assert.equal(target[0].end, '2026-03-13T11:35') + assert.equal(target[0].location, '文史楼 201') + assert.equal(target[0].overridden, true) + assert.equal(target[0].occurrenceId, 'event-data-structure#2026-03-09') + assert.equal(target[0].occurrenceDate, '2026-03-09') + assert.deepEqual(occurrencesOf(event, week('2026-03-16')).map(o => o.occurrenceId), ['event-data-structure#2026-03-16']) +}) + +test('an override that only changes a room keeps the original date and end', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent({ overrides: [{ date: '2026-03-09', location: '文史楼 201' }] }) + const [occurrence] = occurrencesOf(event, week('2026-03-09')) + assert.equal(occurrence.start, '2026-03-09T08:00') + assert.equal(occurrence.end, '2026-03-09T09:35') + assert.equal(occurrence.location, '文史楼 201') + assert.equal(occurrence.overridden, true) +}) + +test('an override moved weeks away is still found there', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + // A short series, so the target week holds the moved entry and nothing else. + const event = await weeklyEvent({ + recurrence: { freq: 'weekly', count: 2 }, + overrides: [{ date: '2026-03-09', start: '2026-05-04T08:00', end: '2026-05-04T09:35' }], + }) + assert.deepEqual(occurrencesOf(event, week('2026-03-09')), []) + const moved = occurrencesOf(event, week('2026-05-04')) + assert.deepEqual(moved.map(o => o.occurrenceId), ['event-data-structure#2026-03-09']) + assert.equal(moved[0].start, '2026-05-04T08:00') + assert.equal(moved[0].overridden, true) +}) + +test('an entry moved onto a day the series already uses yields both', { skip: !runtime }, async () => { + const { occurrencesOf } = await loadModel() + const event = await weeklyEvent({ + recurrence: { freq: 'weekly', count: 16, byDay: ['mo', 'fr'] }, + overrides: [{ date: '2026-03-09', start: '2026-03-13T08:00', end: '2026-03-13T09:35' }], + }) + const found = occurrencesOf(event, week('2026-03-09')) + assert.deepEqual(found.map(o => o.occurrenceId), ['event-data-structure#2026-03-09', 'event-data-structure#2026-03-13']) + assert.deepEqual(found.map(o => Boolean(o.overridden)), [true, false]) + assert.deepEqual(found.map(o => o.start), ['2026-03-13T08:00', '2026-03-13T08:00']) +}) + +test('overrides are validated: one per date, and never a no-op', { skip: !runtime }, async () => { + const { normalizeEvent } = await loadModel() + const base = { uid: 'event-data-structure', title: '数据结构', start: '2026-03-02T08:00' } + assert.throws( + () => normalizeEvent({ ...base, overrides: [{ date: '2026-03-09', location: 'A' }, { date: '2026-03-09', location: 'B' }] }), + /overridden once/, + ) + assert.throws(() => normalizeEvent({ ...base, overrides: [{ date: '2026-03-09' }] }), /change at least one field/) + assert.throws(() => normalizeEvent({ ...base, overrides: [{ date: '2026-03-09', room: 'A' }] }), /Unrecognized key/) +}) + +test('many events come back ordered by start, then by key', { skip: !runtime }, async () => { + const { normalizeEvent, occurrencesIn } = await loadModel() + const lecture = normalizeEvent({ uid: 'event-fixture-lecture', title: '示例讲座', start: '2026-03-04T14:00' }) + const series = await weeklyEvent() + const found = occurrencesIn([lecture, series], week('2026-03-02')) + assert.deepEqual(found.map(occurrence => occurrence.occurrenceId), ['event-data-structure#2026-03-02', 'event-fixture-lecture#2026-03-04']) +}) + +test('a date-only upper bound covers the whole day', { skip: !runtime }, async () => { + const { overlaps } = await loadModel() + assert.equal(overlaps({ start: '2026-03-02T08:00', end: '2026-03-02T09:35' }, { from: '2026-03-02', to: '2026-03-02' }), true) + assert.equal(overlaps({ start: '2026-03-02T08:00' }, { from: '2026-03-03', to: '2026-03-08' }), false) + assert.equal(overlaps({ start: '2026-03-02', end: '2026-03-02' }, { from: '2026-03-02T10:00', to: '2026-03-02T11:00' }), true) +}) diff --git a/dsh-plugins/calendar/test/remote.test.mjs b/dsh-plugins/calendar/test/remote.test.mjs new file mode 100644 index 00000000..5c72e3dd --- /dev/null +++ b/dsh-plugins/calendar/test/remote.test.mjs @@ -0,0 +1,152 @@ +// Wire contract of the calendar remote face: the host manifest that +// typert-loader registers and the client face the panel mounts must describe +// one and the same method, or the panel silently calls nothing. +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { createRequire, registerHooks } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const runtime = process.env.EDUWORK_TEST_RUNTIME +const require = runtime ? createRequire(join(runtime, 'package.json')) : undefined +const runtimeParent = runtime ? pathToFileURL(join(runtime, 'package.json')).href : undefined +const load = name => import(pathToFileURL(require.resolve(name)).href) + +let hooks +function bindRuntime() { + hooks ??= registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'zod' || specifier.startsWith('@deepseek-ai/')) return nextResolve(specifier, { ...context, parentURL: runtimeParent }) + return nextResolve(specifier, context) + }, + }) +} + +let faces +async function loadFaces() { + bindRuntime() + faces ??= (async () => { + const [host, client, loader, manifest] = await Promise.all([ + import('../lib/typert.host.js'), + import('../lib/typert.remote-client.js'), + load('@deepseek-ai/dsh-typert-loader'), + readFile(new URL('../package.json', import.meta.url), 'utf8'), + ]) + return { host: host.default, client: client.default, loader, pkg: JSON.parse(manifest) } + })() + return await faces +} + +test.after(() => hooks?.deregister()) + +test('host and client faces describe the same calendar methods', { skip: !runtime }, async () => { + const { host, client, pkg } = await loadFaces() + assert.equal(host.package, pkg.name) + assert.equal(host.face, 'host') + assert.equal(client.package, host.package) + const shape = descriptors => descriptors.map(({ id, service, namespace, method, result, parameters }) => ({ + id, + service, + namespace, + method, + type: result.typeSymbol, + parameters: parameters.map(({ name, wire, source, codec }) => ({ name, wire, source, type: codec.typeSymbol })), + })) + assert.deepEqual(shape(client.descriptors), shape(host.invocations)) + assert.deepEqual(host.invocations.map(descriptor => descriptor.method), [ + 'snapshot', 'occurrences', 'importIcs', 'exportIcs', 'deleteEvent', + 'applyOverride', 'removeOverride', 'cancelOccurrence', 'restoreOccurrence', 'putEvent', + ]) +}) + +test('every descriptor points at the method that implements it', { skip: !runtime }, async () => { + const { host } = await loadFaces() + const source = await readFile(new URL('../lib/index.js', import.meta.url), 'utf8') + const lines = source.split('\n') + for (const descriptor of host.invocations) { + const { file, line } = descriptor.sourceLocation + assert.equal(file, 'dsh-plugins/calendar/lib/index.js') + // The line is what a reader opens first, so it has to still be the method. + assert.match(lines[line - 1] ?? '', new RegExp(`\\b${descriptor.method}\\s*\\(`), `${descriptor.method} is no longer on line ${line}`) + } +}) + +test('the wire schemas accept the real shapes and reject the wrong ones', { skip: !runtime }, async () => { + const { host } = await loadFaces() + const byMethod = new Map(host.invocations.map(descriptor => [descriptor.method, descriptor])) + const snapshot = byMethod.get('snapshot') + const occurrences = byMethod.get('occurrences') + assert.equal(snapshot.result.schema.safeParse({ schemaVersion: 2, events: [] }).success, true) + assert.equal(snapshot.result.schema.safeParse({ schemaVersion: 2 }).success, false) + // A payload that still carries the retired course list is a shape this + // contract does not have, and saying so is what keeps it out of the panel. + assert.equal(snapshot.result.schema.safeParse({ schemaVersion: 2, events: [], courses: [] }).success, false) + + const [range] = occurrences.parameters + assert.equal(range.codec.schema.safeParse({ from: '2026-03-02', to: '2026-03-08' }).success, true) + assert.equal(range.codec.schema.safeParse({}).success, true) + assert.equal(range.codec.schema.safeParse({ from: '2026-03-02', since: '2026-03-01' }).success, false) + assert.equal(range.codec.schema.safeParse({ from: '2026/03/02' }).success, false) + + const occurrence = { + uid: 'event-fixture-1', + title: '示例日程', + start: '2026-03-02T08:00', + occurrenceId: 'event-fixture-1#2026-03-02', + occurrenceDate: '2026-03-02', + } + const payload = extra => ({ schemaVersion: 2, occurrences: [{ ...occurrence, ...extra }] }) + assert.equal(occurrences.result.schema.safeParse(payload({})).success, true) + assert.equal(occurrences.result.schema.safeParse(payload({ overridden: true })).success, true) + assert.equal(occurrences.result.schema.safeParse(payload({ overrides: [] })).success, false, 'series bookkeeping stays out of the wire shape') + assert.equal(occurrences.result.schema.safeParse(payload({ occurrenceDate: '2026-03-02T08:00' })).success, false) + + // The write methods read one object argument each, and nothing else. + const importSchema = byMethod.get('importIcs').parameters[0].codec.schema + assert.equal(importSchema.safeParse({ text: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n' }).success, true) + assert.equal(importSchema.safeParse({ text: 'x', source: 'registrar', replace: false }).success, true) + assert.equal(importSchema.safeParse({ text: '' }).success, false) + assert.equal(importSchema.safeParse({ text: 'x', folder: 'inbox' }).success, false) + + const overrideSchema = byMethod.get('applyOverride').parameters[0].codec.schema + assert.equal(overrideSchema.safeParse({ uid: 'a', date: '2026-03-09', patch: { location: '示例楼 202' } }).success, true) + assert.equal(overrideSchema.safeParse({ uid: 'a', date: '2026-03-09', patch: {} }).success, true, 'the record contract rejects an empty patch, not the wire schema') + assert.equal(overrideSchema.safeParse({ uid: 'a', date: '2026-03-09' }).success, false) + assert.equal(overrideSchema.safeParse({ uid: 'a', date: '2026-03-09', patch: { teacher: 'x' } }).success, false) + assert.equal(overrideSchema.safeParse({ uid: 'a', date: '09/03/2026', patch: { location: 'x' } }).success, false) + + const cancelSchema = byMethod.get('cancelOccurrence').parameters[0].codec.schema + assert.equal(cancelSchema.safeParse({ uid: 'a', date: '2026-03-09' }).success, true) + assert.equal(cancelSchema.safeParse({ uid: 'a', date: '2026-03-09', patch: { location: 'x' } }).success, false) + + // Creating and editing one entry go through the same record shape, so what the + // panel writes is what the store validated. + const putEventSchema = byMethod.get('putEvent').parameters[0].codec.schema + assert.equal(putEventSchema.safeParse({ + uid: 'manual-fixture-1', + title: '示例日程', + start: '2026-03-02T08:00', + end: '2026-03-02T09:00', + location: '示例楼 202', + description: '示例备注', + source: 'manual', + extensions: {}, + }).success, true) + assert.equal(putEventSchema.safeParse({ uid: 'manual-fixture-1', title: '示例日程' }).success, false) + assert.equal(putEventSchema.safeParse({ uid: 'manual-fixture-1', title: '示例日程', start: '2026-03-02T08:00', courseId: 'x' }).success, false) +}) + +test('the manifest passes the loader validation and stays owned by this package', { skip: !runtime }, async () => { + const { host, loader, pkg } = await loadFaces() + assert.equal(loader.validateTypertManifest(pkg.name, host), host) + assert.throws(() => loader.validateTypertManifest('@eduwork/dsh-other', host), /owned by the package/) +}) + +test('the package publishes both faces and depends on the remote protocol', { skip: !runtime }, async () => { + const { pkg } = await loadFaces() + assert.equal(pkg.exports['./typert'], './lib/typert.host.js') + assert.equal(pkg.exports['./remote'], './lib/typert.remote-client.js') + assert.ok(pkg.peerDependencies['@deepseek-ai/dsh-typert-protocol']) + assert.ok(pkg.dsh.client.inject.includes('@deepseek-ai/dsh-api-remotes')) +}) diff --git a/dsh-plugins/calendar/test/store.test.mjs b/dsh-plugins/calendar/test/store.test.mjs new file mode 100644 index 00000000..0b5a6b8f --- /dev/null +++ b/dsh-plugins/calendar/test/store.test.mjs @@ -0,0 +1,494 @@ +// Host storage of calendar records: a real Cordis scope over the JSON backend, +// so the domain is opened, validated and persisted exactly as in the product. +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire, registerHooks } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, join, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' + +// `dsh-plugins/*` ships no node_modules: the Runtime pinned by the assembly +// provides storage and zod. Without one these tests skip, like the other +// plugin tests. +const runtime = process.env.EDUWORK_TEST_RUNTIME +const require = runtime ? createRequire(join(runtime, 'package.json')) : undefined +const runtimeParent = runtime ? pathToFileURL(join(runtime, 'package.json')).href : undefined +const load = name => import(pathToFileURL(require.resolve(name)).href) + +let hooks +function bindRuntime() { + hooks ??= registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'zod' || specifier.startsWith('@deepseek-ai/')) return nextResolve(specifier, { ...context, parentURL: runtimeParent }) + return nextResolve(specifier, context) + }, + }) +} + +let modules +async function loadCalendarModules() { + bindRuntime() + modules ??= (async () => { + const [cordis, hub, json, domain, typert, plugin, hostFace] = await Promise.all([ + load('@deepseek-ai/cordis'), + load('@deepseek-ai/dsh-storage'), + load('@deepseek-ai/dsh-storage-json'), + load('@deepseek-ai/dsh-storage-domain'), + load('@deepseek-ai/dsh-typert-protocol'), + import('../lib/index.js'), + import('../lib/typert.host.js'), + ]) + return { + Context: cordis.Context, + storageHub: hub, + storageJson: json, + storageDomain: domain, + typert, + CalendarService: plugin.CalendarService, + hostFace: hostFace.TYPERT, + } + })() + return await modules +} + +test.after(() => hooks?.deregister()) + +const exampleEvent = { + uid: 'event-fixture-1', + title: '示例日程', + start: '2026-03-02T08:00', + end: '2026-03-02T09:35', + location: '示例楼 101', + recurrence: { freq: 'weekly', count: 16, byDay: ['mo'] }, +} + +/** Run one body against a started calendar service and tear the scope down. */ +async function withCalendar(root, body) { + const { Context, storageHub, storageJson, storageDomain, typert, CalendarService } = await loadCalendarModules() + const ctx = new Context() + try { + // The hub is its own plugin (default export: the Storage service class); + // the JSON backend and the domain form are `apply` modules. + await ctx.plugin(storageHub.default ?? storageHub) + await ctx.plugin(storageJson, { root: join(root, 'storages') }) + await ctx.plugin(storageDomain, { backend: 'json' }) + await ctx.plugin(CalendarService) + assert.ok(ctx.calendar, 'the service is reachable as ctx.calendar') + // Only marked methods may cross to the client half. + assert.deepEqual(typert.remoteMethods(ctx.calendar).map(marker => marker.method), [ + 'snapshot', 'occurrences', 'importIcs', 'exportIcs', 'applyOverride', + 'removeOverride', 'cancelOccurrence', 'restoreOccurrence', 'deleteEvent', 'putEvent', + ]) + return await body(ctx.calendar) + } finally { + await ctx.fiber.dispose() + } +} + +/** The temporary tree a test owns; removed only after its path was verified. */ +async function withRoot(prefix, body) { + const root = await mkdtemp(join(tmpdir(), prefix)) + try { + return await body(root) + } finally { + const target = resolve(root) + assert.ok(target.startsWith(resolve(tmpdir()) + sep) && basename(target).startsWith(prefix)) + await rm(target, { recursive: true, force: true }) + } +} + +test('records round-trip, emit changes, survive a restart and import idempotently', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-store-', async root => { + const observed = await withCalendar(root, async calendar => { + const changes = [] + const stop = calendar.onChanged(change => changes.push(change)) + + const event = await calendar.putEvent(exampleEvent) + const second = await calendar.putEvent({ uid: 'event-fixture-2', title: '示例讲座', start: '2026-03-04T19:00' }) + assert.equal(second.source, 'manual', 'a record without a source is stamped manual') + assert.deepEqual(event.recurrence, { freq: 'weekly', count: 16, byDay: ['mo'] }) + + const snapshot = await calendar.snapshot() + assert.equal(snapshot.schemaVersion, 2) + assert.deepEqual(Object.keys(snapshot), ['schemaVersion', 'events']) + assert.deepEqual(snapshot.events.map(row => row.uid), ['event-fixture-1', 'event-fixture-2']) + assert.equal(calendar.getEvent('event-fixture-1').title, '示例日程') + assert.deepEqual(calendar.listEvents({ from: '2026-03-02', to: '2026-03-02T23:59' }).map(row => row.uid), ['event-fixture-1']) + assert.deepEqual(calendar.listEvents({ from: '2026-03-09' }).map(row => row.uid), []) + + const imported = await calendar.importSnapshot( + { schemaVersion: 2, events: [exampleEvent] }, + { source: 'fixture' }, + ) + assert.deepEqual(imported, { events: 1, removedEvents: 0 }) + const repeated = await calendar.importSnapshot( + { schemaVersion: 2, events: [exampleEvent] }, + { source: 'fixture' }, + ) + assert.deepEqual(repeated, { events: 1, removedEvents: 0 }) + assert.equal(calendar.snapshot().events.length, 2, 'a repeated import replaces instead of duplicating') + assert.equal(calendar.getEvent('event-fixture-1').source, 'fixture', 'the import stamps its own source') + await assert.rejects(calendar.importSnapshot({ note: 'nothing to import' }, {}), /an events array/) + + // A rejected import must not leave half of its records behind: every + // record is validated before the first write. + const before = calendar.snapshot().events.length + await assert.rejects( + calendar.importSnapshot({ + events: [ + { uid: 'event-good', title: '合法记录', start: '2026-03-05T10:00' }, + { uid: 'event-bad', title: '', start: 'not-a-time' }, + ], + }, { source: 'fixture' }), + ) + assert.equal(calendar.snapshot().events.length, before, 'nothing is written when a record is rejected') + assert.equal(calendar.getEvent('event-good'), undefined) + + stop() + assert.ok(changes.length >= 4, `durable writes emit changes, saw ${changes.length}`) + assert.ok(changes.every(change => change.domain === 'calendar'), 'only this domain is reported') + assert.ok(changes.some(change => change.table === 'events' && String(change.key) === 'event-fixture-1')) + + assert.deepEqual(await calendar.deleteEvent({ uid: 'event-fixture-2' }), { removed: true }) + assert.deepEqual(await calendar.deleteEvent({ uid: 'event-fixture-2' }), { removed: false }, 'a second delete removes nothing') + return { events: calendar.snapshot().events.map(row => row.uid) } + }) + assert.deepEqual(observed, { events: ['event-fixture-1'] }) + + const entries = await readdir(join(root, 'storages')) + assert.ok(entries.some(name => name.startsWith('calendar')), `the domain document is on disk: ${entries.join(', ')}`) + + // Restart: a fresh scope over the same directory sees what was written. + const reopened = await withCalendar(root, async calendar => ({ + events: calendar.snapshot().events.map(row => row.uid), + source: calendar.getEvent('event-fixture-1').source, + })) + assert.deepEqual(reopened, { events: ['event-fixture-1'], source: 'fixture' }) + }) +}) + +test('a range query expands series, honours exceptions and applies overrides', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-store-', async root => { + const moved = await withCalendar(root, async calendar => { + await calendar.putEvent({ ...exampleEvent, exceptions: ['2026-03-16'] }) + + // Stored semantics stay one record per series... + assert.deepEqual(calendar.listEvents({ from: '2026-03-09', to: '2026-03-15' }).map(row => row.uid), []) + // ...while a range query produces the dates the series actually covers. + assert.deepEqual( + calendar.occurrences({ from: '2026-03-02', to: '2026-03-08' }).occurrences.map(occurrence => occurrence.occurrenceId), + ['event-fixture-1#2026-03-02'], + ) + assert.deepEqual(calendar.occurrences({ from: '2026-03-16', to: '2026-03-22' }).occurrences, [], 'a cancelled date is gone') + + const overridden = await calendar.applyOverride({ + uid: 'event-fixture-1', + date: '2026-03-23', + patch: { + start: '2026-03-27T14:00', + end: '2026-03-27T15:35', + location: '示例楼 202', + }, + }) + assert.deepEqual(overridden.overrides, [{ + date: '2026-03-23', start: '2026-03-27T14:00', end: '2026-03-27T15:35', location: '示例楼 202', + }]) + const week = calendar.occurrences({ from: '2026-03-23', to: '2026-03-29' }).occurrences + assert.deepEqual(week.map(occurrence => occurrence.start), ['2026-03-27T14:00']) + assert.equal(week[0].location, '示例楼 202') + assert.equal(week[0].overridden, true) + assert.equal(week[0].occurrenceId, 'event-fixture-1#2026-03-23', 'the key stays the date the series produced') + + // A change on another date keeps the first, and dropping one restores the + // series for that date only. + await calendar.applyOverride({ uid: 'event-fixture-1', date: '2026-03-30', patch: { location: '示例楼 303' } }) + assert.equal(calendar.getEvent('event-fixture-1').overrides.length, 2) + const cleared = await calendar.removeOverride({ uid: 'event-fixture-1', date: '2026-03-23' }) + assert.deepEqual(cleared.overrides, [{ date: '2026-03-30', location: '示例楼 303' }]) + assert.deepEqual( + calendar.occurrences({ from: '2026-03-23', to: '2026-03-29' }).occurrences.map(occurrence => occurrence.start), + ['2026-03-23T08:00'], + ) + await assert.rejects( + calendar.applyOverride({ uid: 'event-missing', date: '2026-03-23', patch: { location: 'x' } }), + /no event event-missing/, + ) + return calendar.getEvent('event-fixture-1').overrides.length + }) + assert.equal(moved, 1) + + // Overrides are ordinary record fields: they survive a restart, and the + // domain still validates them on the way back in. + const reopened = await withCalendar(root, calendar => { + const [occurrence] = calendar.occurrences({ from: '2026-03-30', to: '2026-04-05' }).occurrences + return { start: occurrence.start, location: occurrence.location, overrides: calendar.getEvent('event-fixture-1').overrides.length } + }) + assert.deepEqual(reopened, { start: '2026-03-30T08:00', location: '示例楼 303', overrides: 1 }) + }) +}) + +test('the declared wire schemas parse what the service really returns', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-wire-', async root => { + await withCalendar(root, async calendar => { + const { hostFace } = await loadCalendarModules() + await calendar.putEvent({ ...exampleEvent, exceptions: ['2026-03-16'] }) + await calendar.applyOverride({ uid: 'event-fixture-1', date: '2026-03-09', patch: { location: '示例楼 202' } }) + + // Nothing validates a result at runtime, so the contract is only real if + // the payloads the methods produce are the ones the schemas describe. + const byMethod = new Map(hostFace.invocations.map(descriptor => [descriptor.method, descriptor])) + assert.equal(byMethod.get('snapshot').result.schema.safeParse(calendar.snapshot()).success, true, 'a snapshot matches its declared schema') + const range = { from: '2026-03-09', to: '2026-03-15' } + assert.equal(byMethod.get('occurrences').parameters[0].codec.schema.safeParse(range).success, true) + assert.equal(byMethod.get('occurrences').result.schema.safeParse(calendar.occurrences(range)).success, true, 'a week matches its declared schema') + + const importInput = { text: 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:event-imported\r\nSUMMARY:导入的日程\r\nDTSTART:20260320T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n' } + assert.equal(byMethod.get('importIcs').parameters[0].codec.schema.safeParse(importInput).success, true) + const imported = await calendar.importIcs(importInput) + assert.equal(byMethod.get('importIcs').result.schema.safeParse(imported).success, true, 'an import report matches its declared schema') + assert.equal(byMethod.get('exportIcs').result.schema.safeParse(calendar.exportIcs({})).success, true, 'an export matches its declared schema') + + const uid = 'event-fixture-1' + const date = '2026-03-16' + const cancelInput = { uid, date } + assert.equal(byMethod.get('cancelOccurrence').parameters[0].codec.schema.safeParse(cancelInput).success, true) + assert.equal(byMethod.get('cancelOccurrence').result.schema.safeParse(await calendar.cancelOccurrence(cancelInput)).success, true, 'a cancelled record matches its declared schema') + assert.equal(byMethod.get('restoreOccurrence').result.schema.safeParse(await calendar.restoreOccurrence(cancelInput)).success, true) + assert.equal(byMethod.get('applyOverride').result.schema.safeParse(calendar.getEvent(uid)).success, true) + assert.equal(byMethod.get('removeOverride').result.schema.safeParse(await calendar.removeOverride({ uid, date: '2026-03-09' })).success, true) + assert.equal(byMethod.get('putEvent').result.schema.safeParse(await calendar.putEvent(exampleEvent)).success, true) + assert.equal(byMethod.get('putEvent').parameters[0].codec.schema.safeParse({ uid: 'event-fixture-9', title: '缺开始时间' }).success, false, 'a create has to carry what the record needs') + assert.equal(byMethod.get('deleteEvent').result.schema.safeParse(await calendar.deleteEvent({ uid: 'event-imported' })).success, true) + assert.equal(byMethod.get('deleteEvent').result.schema.safeParse(await calendar.deleteEvent({ uid: 'event-imported' })).success, true) + + // The parameters reject what the methods do not read. + assert.equal(byMethod.get('applyOverride').parameters[0].codec.schema.safeParse({ uid, date, patch: { teacher: 'x' } }).success, false) + assert.equal(byMethod.get('importIcs').parameters[0].codec.schema.safeParse({ text: '' }).success, false) + }) + }) +}) + +test('a stored record that breaks the schema fails the open instead of being served', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-store-', async root => { + await withCalendar(root, async calendar => { await calendar.putEvent(exampleEvent) }) + const storages = join(root, 'storages') + const [document] = (await readdir(storages)).filter(name => name.startsWith('calendar')) + assert.ok(document, 'the domain document exists') + const path = join(storages, document) + const stored = JSON.parse(await readFile(path, 'utf8')) + // Strip a required field from the event wherever the medium keeps it, and + // leave the rest of the document — its unit header included — untouched. + const corrupt = node => { + if (!node || typeof node !== 'object') return false + const record = node['event-fixture-1'] + if (record && typeof record === 'object' && 'title' in record) { + delete record.title + return true + } + return Object.values(node).some(corrupt) + } + assert.ok(corrupt(stored), 'the stored event is reachable in the document') + await writeFile(path, JSON.stringify(stored)) + await assert.rejects( + withCalendar(root, async () => {}), + error => /invalid-record/.test(`${error?.code} ${error?.message}`), + 'an unreadable domain must fail the plugin instead of serving bad records', + ) + }) +}) + +/** Join content lines the way a calendar file is written. */ +const calendarText = (...lines) => lines.join('\r\n') + '\r\n' + +test('a store written before the record contract changed is folded into it', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-legacy-', async root => { + // The medium as the earlier contract wrote it: the record still carries the + // retired `courseId`, and the document still holds the retired course table. + // Reading it has to be possible — a calendar that refuses to open is worse + // than one that folds a field away. + await mkdir(join(root, 'storages'), { recursive: true }) + await writeFile(join(root, 'storages', 'calendar.json'), JSON.stringify({ + unit: { name: 'calendar', version: 1 }, + global: null, + tables: { + events: { + 'legacy-fixture-1': { + uid: 'legacy-fixture-1', + title: '数据结构', + start: '2026-03-02T08:00', + end: '2026-03-02T09:35', + location: '理科楼 305', + courseId: 'course-legacy-1', + recurrence: { freq: 'weekly', count: 16 }, + source: 'manual', + extensions: {}, + }, + }, + courses: { + 'course-legacy-1': { id: 'course-legacy-1', name: '数据结构', teacher: '张老师', extensions: {} }, + }, + }, + }, null, 2)) + + const observed = await withCalendar(root, calendar => { + const [event] = calendar.snapshot().events + return { + uid: event.uid, + title: event.title, + retired: 'courseId' in event, + occurrences: calendar.occurrences({ from: '2026-03-02', to: '2026-03-08' }).occurrences.length, + } + }) + assert.deepEqual(observed, { uid: 'legacy-fixture-1', title: '数据结构', retired: false, occurrences: 1 }) + + // The fold is durable, and it is also what drops the retired table from the + // document: the next write rewrites the whole unit without it. + const stored = JSON.parse(await readFile(join(root, 'storages', 'calendar.json'), 'utf8')) + assert.equal(stored.unit.version, 1) + assert.deepEqual(Object.keys(stored.tables), ['events']) + assert.equal('courseId' in stored.tables.events['legacy-fixture-1'], false) + }) +}) + +test('a record a caller sends may not carry a retired field', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-retired-', async root => { + await withCalendar(root, async calendar => { + await assert.rejects( + calendar.putEvent({ ...exampleEvent, uid: 'event-fixture-3', courseId: 'course-legacy-1' }), + /courseId/, + ) + }) + }) +}) + +test('an imported calendar becomes occurrences a view can render', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-import-', async root => { + await withCalendar(root, async calendar => { + const text = calendarText( + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:entry-series-1', + 'SUMMARY:数据结构', + 'DTSTART:20260302T080000', + 'DTEND:20260302T093500', + 'LOCATION:理科楼 305', + 'RRULE:FREQ=WEEKLY;COUNT=16;BYDAY=MO', + 'EXDATE:20260316T080000', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:entry-series-1', + 'RECURRENCE-ID:20260309T080000', + 'DTSTART:20260313T100000', + 'LOCATION:文史楼 202', + 'END:VEVENT', + 'END:VCALENDAR', + ) + const report = await calendar.importIcs({ text }) + assert.deepEqual(report, { events: 1, removedEvents: 0, skipped: [], degraded: [] }) + assert.equal(calendar.getEvent('entry-series-1').source, 'ics') + + // The moved entry lands on the day it moved to, and the cancelled date is + // gone rather than left as an entry that does not happen. + assert.deepEqual( + calendar.occurrences({ from: '2026-03-09', to: '2026-03-15' }).occurrences + .map(occurrence => [occurrence.occurrenceDate, occurrence.start, occurrence.location, occurrence.overridden]), + [['2026-03-09', '2026-03-13T10:00', '文史楼 202', true]], + ) + assert.deepEqual(calendar.occurrences({ from: '2026-03-16', to: '2026-03-22' }).occurrences, []) + + // Exporting and importing again is a round trip: nothing is duplicated and + // nothing is dropped. + const { text: exported } = calendar.exportIcs({}) + assert.match(exported, /^BEGIN:VCALENDAR\r\n/) + const again = await calendar.importIcs({ text: exported }) + assert.deepEqual(again, { events: 1, removedEvents: 0, skipped: [], degraded: [] }) + assert.equal(calendar.snapshot().events.length, 1) + assert.deepEqual( + calendar.occurrences({ from: '2026-03-09', to: '2026-03-15' }).occurrences + .map(occurrence => [occurrence.occurrenceDate, occurrence.start, occurrence.location]), + [['2026-03-09', '2026-03-13T10:00', '文史楼 202']], + ) + return undefined + }) + }) +}) + +test('re-importing a source replaces that source and leaves the rest alone', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-replace-', async root => { + await withCalendar(root, async calendar => { + const entry = (uid, title, start) => calendarText( + 'BEGIN:VEVENT', `UID:${uid}`, `SUMMARY:${title}`, `DTSTART:${start}`, 'END:VEVENT', + ) + const first = calendarText( + 'BEGIN:VCALENDAR', 'VERSION:2.0', + ...entry('entry-a', '第一条日程', '20260302T080000').split('\r\n').filter(Boolean), + ...entry('entry-b', '第二条日程', '20260303T080000').split('\r\n').filter(Boolean), + 'END:VCALENDAR', + ) + assert.deepEqual(await calendar.importIcs({ text: first, source: 'registrar' }), { + events: 2, removedEvents: 0, skipped: [], degraded: [], + }) + // A record the user owns is never in the scope of an import. + await calendar.putEvent({ uid: 'entry-manual', title: '自己加的', start: '2026-03-04T10:00' }) + + const second = calendarText( + 'BEGIN:VCALENDAR', 'VERSION:2.0', + ...entry('entry-b', '第二条日程', '20260303T100000').split('\r\n').filter(Boolean), + 'END:VCALENDAR', + ) + const replaced = await calendar.importIcs({ text: second, source: 'registrar' }) + assert.deepEqual(replaced, { events: 1, removedEvents: 1, skipped: [], degraded: [] }) + assert.deepEqual(calendar.snapshot().events.map(event => event.uid).sort(), ['entry-b', 'entry-manual']) + assert.equal(calendar.getEvent('entry-b').start, '2026-03-03T10:00', 'the new copy of the entry wins') + + // Without `replace` the import only adds and updates: the entry it no + // longer lists is restored rather than removed. + const added = await calendar.importIcs({ text: first, source: 'registrar', replace: false }) + assert.deepEqual(added, { events: 2, removedEvents: 0, skipped: [], degraded: [] }) + assert.deepEqual(calendar.snapshot().events.map(event => event.uid).sort(), ['entry-a', 'entry-b', 'entry-manual']) + + // A record the user owns survives every import of that source, notes and + // all: an import only ever speaks for its own records. + await calendar.putEvent({ uid: 'entry-manual', title: '自己加的', description: '自己写的备注', start: '2026-03-04T10:00' }) + await calendar.importIcs({ text: second, source: 'registrar' }) + assert.equal(calendar.getEvent('entry-manual').description, '自己写的备注') + return undefined + }) + }) +}) + +test('a cancelled date can be put back, and only a series can be cancelled', { skip: !runtime }, async () => { + await withRoot('eduwork-calendar-cancel-', async root => { + await withCalendar(root, async calendar => { + await calendar.putEvent(exampleEvent) + await calendar.applyOverride({ + uid: 'event-fixture-1', + date: '2026-03-16', + patch: { start: '2026-03-20T14:00', location: '示例楼 202' }, + }) + // Cancelling a date that had been moved takes the move with it: nothing is + // left to move. + const cancelled = await calendar.cancelOccurrence({ uid: 'event-fixture-1', date: '2026-03-16' }) + assert.deepEqual(cancelled.exceptions, ['2026-03-16']) + assert.equal(cancelled.overrides, undefined) + assert.deepEqual(calendar.occurrences({ from: '2026-03-16', to: '2026-03-22' }).occurrences, []) + + const restored = await calendar.restoreOccurrence({ uid: 'event-fixture-1', date: '2026-03-16' }) + assert.equal(restored.exceptions, undefined) + assert.deepEqual( + calendar.occurrences({ from: '2026-03-16', to: '2026-03-22' }).occurrences.map(occurrence => occurrence.start), + ['2026-03-16T08:00'], + ) + + await calendar.putEvent({ uid: 'event-once', title: '一次性讲座', start: '2026-03-05T19:00' }) + await assert.rejects( + calendar.cancelOccurrence({ uid: 'event-once', date: '2026-03-05' }), + /does not repeat/, + ) + await assert.rejects(calendar.restoreOccurrence({ uid: 'event-missing', date: '2026-03-16' }), /no event event-missing/) + return undefined + }) + }) +}) diff --git a/dsh-plugins/calendar/test/tools.test.mjs b/dsh-plugins/calendar/test/tools.test.mjs new file mode 100644 index 00000000..5b3894db --- /dev/null +++ b/dsh-plugins/calendar/test/tools.test.mjs @@ -0,0 +1,247 @@ +// The agent tool surface: what the tools are called, what they write, and who is +// allowed to write. The tools read and write through the `calendar` service, so +// the service is stood in for here and the real record rules come from +// `lib/model.js` — a fake that re-implemented them would prove nothing. +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire, registerHooks } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { WRITE_TOOLS } from '../lib/tool-permission.js' + +const runtime = process.env.EDUWORK_TEST_RUNTIME +const require = runtime ? createRequire(join(runtime, 'package.json')) : undefined +const runtimeParent = runtime ? pathToFileURL(join(runtime, 'package.json')).href : undefined + +let hooks +function bindRuntime() { + hooks ??= registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'zod' || specifier.startsWith('@deepseek-ai/')) { + return nextResolve(specifier, { ...context, parentURL: runtimeParent }) + } + return nextResolve(specifier, context) + }, + }) +} + +test.after(() => hooks?.deregister()) + +// `lib/model.js` needs `zod` and `lib/tools.js` needs the tool API, so both are +// loaded after the runtime is bound, as `remote.test.mjs` does with the loader. +let loaded +async function loadPluginTools() { + bindRuntime() + loaded ??= await import('../lib/tools.js') + return loaded +} + +let model +async function loadModel() { + bindRuntime() + model ??= await import('../lib/model.js') + return model +} + +/** A context that records what the plugin registers and how it gates calls. */ +function harness(service, preset = 'standard') { + const registered = [] + const handlers = [] + const ctx = { + calendar: service, + permissionPresets: { current: () => preset }, + tools: { register: definition => { registered.push(definition) } }, + on: (name, handler) => { handlers.push({ name, handler }) }, + } + return { ctx, registered, handlers } +} + +/** A calendar service over plain records, using the real occurrence rules. */ +async function service(events = []) { + const { normalizeEvent, occurrencesIn } = await loadModel() + const records = [...events] + const calls = [] + return { + calls, + occurrences: range => ({ schemaVersion: 2, occurrences: occurrencesIn(records, range) }), + getEvent: uid => records.find(event => event.uid === uid), + putEvent: async input => { + const record = normalizeEvent(input) + calls.push({ method: 'putEvent', record }) + return record + }, + deleteEvent: async input => { calls.push({ method: 'deleteEvent', input }); return { removed: true } }, + applyOverride: async input => { calls.push({ method: 'applyOverride', input }); return { uid: input.uid, title: '组会', start: '2026-10-08T15:00' } }, + removeOverride: async input => { calls.push({ method: 'removeOverride', input }); return { uid: input.uid, title: '组会', start: '2026-10-07T15:00' } }, + cancelOccurrence: async input => { calls.push({ method: 'cancelOccurrence', input }); return { uid: input.uid, title: '组会', start: '2026-10-07T15:00' } }, + restoreOccurrence: async input => { calls.push({ method: 'restoreOccurrence', input }); return { uid: input.uid, title: '组会', start: '2026-10-07T15:00' } }, + } +} + +const WEEKLY = { + uid: 'series-1', + title: '组会', + start: '2026-10-07T15:00', + end: '2026-10-07T16:00', + location: '理科楼 305', + recurrence: { freq: 'weekly' }, + exceptions: ['2026-10-21'], +} + +/** The tool with one name, from a context that registered them all. */ +async function tool(name, backing, preset) { + const tools = await loadPluginTools() + const { ctx, registered, handlers } = harness(backing, preset) + tools.apply(ctx) + const definition = registered.find(entry => entry.name === name) + assert.ok(definition, `${name} is registered`) + return { definition, registered, handlers } +} + +test('the registered surface is the five calendar tools, behind one write gate', { skip: !runtime }, async () => { + const { registered, handlers } = await tool('calendar_list_events', await service()) + assert.deepEqual(registered.map(entry => entry.name), [ + 'calendar_list_events', + 'calendar_create_event', + 'calendar_update_event', + 'calendar_delete_event', + 'calendar_occurrence', + ]) + assert.deepEqual(handlers.map(entry => entry.name), ['tools/pre-execute']) + // A new tool that is not the read one must be declared in WRITE_TOOLS, or it + // would write with no approval in front of it. + for (const entry of registered) { + if (entry.name === 'calendar_list_events') continue + assert.ok(WRITE_TOOLS.has(entry.name), `${entry.name} is gated`) + } + assert.ok(!WRITE_TOOLS.has('calendar_list_events'), 'reading is not gated') +}) + +test('every tool describes its arguments and its answer', { skip: !runtime }, async () => { + const { registered } = await tool('calendar_list_events', await service()) + for (const entry of registered) { + assert.equal(typeof entry.description, 'string') + assert.ok(entry.description.length > 20, `${entry.name} explains itself`) + assert.equal(entry.parameters.type, 'object') + assert.equal(entry.output.schema.type, 'object') + assert.equal(typeof entry.output.render, 'function') + assert.equal(typeof entry.execute, 'function') + } + const create = registered.find(entry => entry.name === 'calendar_create_event') + assert.deepEqual(create.parameters.required, ['title', 'start']) + const occurrence = registered.find(entry => entry.name === 'calendar_occurrence') + assert.deepEqual(occurrence.parameters.properties.action.enum, ['move', 'reset', 'cancel', 'restore']) +}) + +test('a missing argument is rejected before anything is written', { skip: !runtime }, async () => { + const backing = await service() + const { definition } = await tool('calendar_create_event', backing) + await assert.rejects(() => definition.execute({ title: '组会' }, {}), /start/) + const { definition: update } = await tool('calendar_update_event', backing) + await assert.rejects(() => update.execute({ title: '组会' }, {}), /uid/) + assert.deepEqual(backing.calls, []) +}) + +test('list reads the window through the service and renders one line per date', { skip: !runtime }, async () => { + const backing = await service([WEEKLY]) + const { definition } = await tool('calendar_list_events', backing) + const value = await definition.execute({ from: '2026-10-05', to: '2026-10-20' }, {}) + assert.deepEqual(value.events.map(entry => entry.date), ['2026-10-07', '2026-10-14']) + assert.equal(value.count, 2) + assert.equal(value.events[0].uid, 'series-1') + assert.equal(value.events[0].repeats, true) + const text = definition.output.render({}, value)[0].text + assert.match(text, /2026-10-07 15:00–16:00 组会 · 理科楼 305 · 每周重复/) + assert.match(text, /共 2 条/) +}) + +test('list keeps every window bounded', { skip: !runtime }, async () => { + const { definition } = await tool('calendar_list_events', await service()) + const defaults = await definition.execute({}, {}) + assert.equal(defaults.from < defaults.to, true) + await assert.rejects(() => definition.execute({ from: '2000-01-01', to: '2099-01-01' }, {}), /wider than/) + await assert.rejects(() => definition.execute({ from: '2026-10-10', to: '2026-10-01' }, {}), /earlier than/) + await assert.rejects(() => definition.execute({ from: '10/10/2026' }, {}), /YYYY-MM-DD/) +}) + +test('create writes one record, with a repeat only when it was asked for', { skip: !runtime }, async () => { + const single = await service() + const first = await tool('calendar_create_event', single) + await first.definition.execute({ title: '体检', start: '2026-10-12T09:00', end: '2026-10-12T10:00', location: '校医院' }, {}) + const written = single.calls[0].record + assert.equal(written.source, 'manual', 'the store fills the source, as it does for the panel') + assert.equal(written.recurrence, undefined) + assert.match(written.uid, /^[0-9a-f-]{36}$/) + assert.deepEqual( + { title: written.title, start: written.start, end: written.end, location: written.location }, + { title: '体检', start: '2026-10-12T09:00', end: '2026-10-12T10:00', location: '校医院' }, + ) + + const repeating = await service() + const second = await tool('calendar_create_event', repeating) + await second.definition.execute({ title: '组会', start: '2026-10-14T15:00', repeat: 'weekly', until: '2026-12-31' }, {}) + assert.deepEqual(repeating.calls[0].record.recurrence, { freq: 'weekly', until: '2026-12-31' }) +}) + +test('update moves only what the call carries, and keeps the duration', { skip: !runtime }, async () => { + const backing = await service([WEEKLY]) + const { definition } = await tool('calendar_update_event', backing) + await definition.execute({ uid: 'series-1', start: '2026-10-09T09:00' }, {}) + const record = backing.calls[0].record + assert.equal(record.start, '2026-10-09T09:00') + assert.equal(record.end, '2026-10-09T10:00', 'one hour stays one hour') + assert.equal(record.location, '理科楼 305', 'a field left out keeps its value') + assert.deepEqual(record.recurrence, { freq: 'weekly' }) + assert.deepEqual(record.exceptions, ['2026-10-21']) + + await definition.execute({ uid: 'series-1', location: '' }, {}) + assert.equal(backing.calls[1].record.location, undefined, 'an empty string clears a field') + + await definition.execute({ uid: 'series-1', repeat: 'none' }, {}) + const single = backing.calls[2].record + assert.equal(single.recurrence, undefined) + assert.equal(single.exceptions, undefined, 'a single entry owns no series bookkeeping') + await assert.rejects(() => definition.execute({ uid: 'missing' }, {}), /没有 uid 为 missing 的日程/) +}) + +test('delete and the single-date actions reach the matching service methods', { skip: !runtime }, async () => { + const backing = await service([WEEKLY]) + const remove = await tool('calendar_delete_event', backing) + assert.deepEqual(await remove.definition.execute({ uid: 'series-1' }, {}), { uid: 'series-1', removed: true }) + assert.deepEqual(backing.calls[0], { method: 'deleteEvent', input: { uid: 'series-1' } }) + + const one = await tool('calendar_occurrence', backing) + await one.definition.execute({ uid: 'series-1', date: '2026-10-14', action: 'move', start: '2026-10-15T15:00' }, {}) + assert.deepEqual(backing.calls[1], { + method: 'applyOverride', + input: { uid: 'series-1', date: '2026-10-14', patch: { start: '2026-10-15T15:00' } }, + }) + await assert.rejects( + () => one.definition.execute({ uid: 'series-1', date: '2026-10-14', action: 'move' }, {}), + /至少给出/, + ) + await one.definition.execute({ uid: 'series-1', date: '2026-10-14', action: 'cancel' }, {}) + assert.equal(backing.calls[2].method, 'cancelOccurrence') + await one.definition.execute({ uid: 'series-1', date: '2026-10-14', action: 'reset' }, {}) + assert.equal(backing.calls[3].method, 'removeOverride') + await one.definition.execute({ uid: 'series-1', date: '2026-10-14', action: 'restore' }, {}) + assert.equal(backing.calls[4].method, 'restoreOccurrence') +}) + +test('writes ask first, unless the session already runs without approvals', { skip: !runtime }, async () => { + const agent = { session: { header: { id: 'session-1' } } } + const decide = async (preset, exec) => { + const { handlers } = await tool('calendar_list_events', await service(), preset) + let continued = 0 + const decision = await handlers[0].handler(exec, () => { continued += 1 }) + return { decision, continued } + } + assert.deepEqual(await decide('standard', { name: 'calendar_create_event', agent }), { + decision: { kind: 'ask', reason: 'Allow the Agent to create, change or delete entries in the local calendar' }, + continued: 0, + }) + assert.deepEqual(await decide('danger-full-access', { name: 'calendar_delete_event', agent }), { decision: undefined, continued: 1 }) + assert.deepEqual(await decide('standard', { name: 'calendar_list_events', agent }), { decision: undefined, continued: 1 }) + assert.deepEqual(await decide('standard', { name: 'calendar_list_events' }), { decision: undefined, continued: 1 }) + assert.equal((await decide('standard', { name: 'calendar_update_event' })).decision.kind, 'deny') +}) diff --git a/dsh-plugins/calendar/tsconfig.json b/dsh-plugins/calendar/tsconfig.json new file mode 100644 index 00000000..f2758758 --- /dev/null +++ b/dsh-plugins/calendar/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "esModuleInterop": true, + "jsx": "react-jsx", + "lib": ["ES2024", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024" + }, + "include": ["src/**/*.js", "src/**/*.ts"] +} \ No newline at end of file diff --git a/dsh-plugins/calendar/tsdown.config.ts b/dsh-plugins/calendar/tsdown.config.ts new file mode 100644 index 00000000..b3b0e8dc --- /dev/null +++ b/dsh-plugins/calendar/tsdown.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'tsdown' + +const external = (specifier: string): boolean => + ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'].includes(specifier) + || specifier.startsWith('react/') + || specifier.startsWith('react-dom/') + +export default defineConfig({ + failOnWarn: true, + entry: { client: 'src/client.ts' }, + outDir: 'lib', + format: 'cjs', + platform: 'browser', + target: 'es2022', + dts: false, + sourcemap: true, + clean: false, + deps: { + neverBundle: external, + alwaysBundle: (specifier: string) => !external(specifier), + }, + outputOptions: { + entryFileNames: 'client.js', + banner: 'window.__ModuleLoader__.load({ id: "@eduwork/dsh-calendar", factory: (require) => {', + footer: 'return module.exports; } });', + intro: 'var module = { exports: {} }; var exports = module.exports;', + }, +}) \ No newline at end of file diff --git a/scripts/build-pinned-dsh-plugin-clients.mjs b/scripts/build-pinned-dsh-plugin-clients.mjs index 2ebda29a..52e5e469 100644 --- a/scripts/build-pinned-dsh-plugin-clients.mjs +++ b/scripts/build-pinned-dsh-plugin-clients.mjs @@ -86,8 +86,8 @@ await build({ entryPoints: [join(repository, 'packages/dsh-knowledge-studio/src/ }) const clients = Object.entries({ 'dsh-mail': 'index.tsx', 'dsh-memory': 'index.ts', 'dsh-oidc': 'index.ts', 'dsh-knowledge-studio': 'index.tsx' }) .map(([folder, entry]) => [`packages/${folder}`, `src/client/${entry}`]) -for (const folder of ['client-ui-branding', 'client-ui-component-inventory', 'activity-insights-native', 'workbench-native', 'client-ui-media-artifacts']) { - clients.push([`dsh-plugins/${folder}`, folder === 'workbench-native' ? 'src/client.ts' : folder === 'client-ui-media-artifacts' ? 'src/client/native.js' : 'src/client/index.ts']) +for (const folder of ['client-ui-branding', 'client-ui-component-inventory', 'activity-insights-native', 'workbench-native', 'client-ui-media-artifacts', 'calendar']) { + clients.push([`dsh-plugins/${folder}`, ['workbench-native', 'calendar'].includes(folder) ? 'src/client.ts' : folder === 'client-ui-media-artifacts' ? 'src/client/native.js' : 'src/client/index.ts']) } const localAliases = { ...sharedAliases } for (const folder of ['skill-manager-native', 'skill-settings-native', 'component-inventory-native', 'plugin-manager-native', 'artifact-preview-native']) { diff --git a/tests/eduwork-assembly-policy.test.mjs b/tests/eduwork-assembly-policy.test.mjs index 0b9b1052..2b58435d 100644 --- a/tests/eduwork-assembly-policy.test.mjs +++ b/tests/eduwork-assembly-policy.test.mjs @@ -53,16 +53,16 @@ test('web package and skill staging use portable paths and commands', async () = assert.doesNotMatch(script, /\+ '\\\\'/, name) assert.doesNotMatch(script, /npm\.cmd|tar\.exe/, name) } - for (const name of ['client-ui-branding', 'client-ui-conversation-brand', 'client-ui-skill-live', 'client-ui-component-inventory', 'client-ui-agent-preset-product', 'client-ui-media-artifacts']) { + for (const name of ['client-ui-branding', 'client-ui-conversation-brand', 'client-ui-skill-live', 'client-ui-component-inventory', 'client-ui-agent-preset-product', 'client-ui-media-artifacts', 'calendar']) { const script = await readFile(join(root, 'dsh-plugins', name, 'build-client.ps1'), 'utf8') assert.match(script, /\[IO\.Path\]::DirectorySeparatorChar/, name) assert.doesNotMatch(script, /\+ '\\\\'/, name) } - for (const name of ['client-ui-branding', 'client-ui-component-inventory', 'client-ui-media-artifacts', 'workbench-native', 'activity-insights-native']) { + for (const name of ['client-ui-branding', 'client-ui-component-inventory', 'client-ui-media-artifacts', 'workbench-native', 'activity-insights-native', 'calendar']) { const script = await readFile(join(root, 'dsh-plugins', name, 'build-client.ps1'), 'utf8') assert.match(script, /\$IsWindows[\s\S]*tsdown\.cmd[\s\S]*tsdown/, name) } - for (const name of ['workbench-native', 'activity-insights-native']) { + for (const name of ['workbench-native', 'activity-insights-native', 'calendar']) { const script = await readFile(join(root, 'dsh-plugins', name, 'build-client.ps1'), 'utf8') assert.match(script, /\$IsWindows[\s\S]*Junction[\s\S]*SymbolicLink/, name) assert.doesNotMatch(script, /-ItemType Junction/, name)