Merge feat/calendar-schedule: calendar & schedule feature (U1-U12 + code review fixes)
Deploy to Production / deploy (push) Waiting to run
Details
Deploy to Production / deploy (push) Waiting to run
Details
12 implementation units, 104 tests, 23 code review fixes (2 critical, 15 major, 6 minor). See docs/plans/2026-06-23-003-feat-calendar-schedule-plan.md for details.
This commit is contained in:
commit
91352d910e
|
|
@ -0,0 +1,196 @@
|
||||||
|
---
|
||||||
|
date: 2026-06-23
|
||||||
|
topic: calendar-schedule
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
在 AgentKit 客户端右侧面板嵌入行事历(可展开为大抽屉),通过混合模式(ReAct 工具调用 + 时间关键词触发的后处理提取)自动捕获对话中的日程,同时支持完整的手动管理——循环事件、自定义类型、标签、三种视图(日历/卡片/列表),UI 对标 Notion Calendar。日程双向同步 Apple Calendar (CalDAV)、Outlook (Graph API) 和 iCal/ICS,配套独立的多渠道提醒子系统(客户端 + 邮件 + Webhook),个人为主 + 共享邀请。
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
Agent 对话中频繁产生可执行的时间安排——会议时间、截止日期、跟进任务——但这些信息当前无处记录。用户必须手动将这些内容转移到外部日历中,这个过程容易遗漏,尤其在长对话中多个时间点被讨论时。痛点不在"缺少一个日历应用",而在"Agent 产生的日程与日历之间没有桥梁":对话结束后,时间承诺就消失了。
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
**混合 Agent 识别模式。** ReAct 工具调用捕获实时意图(Agent 在对话中主动调用 `create_event`),后处理提取作为兜底——但仅在对话包含时间关键词时触发 LLM 提取,避免每轮对话都产生额外 LLM 调用。两层机制互补:工具调用覆盖 Agent 已识别的明确日程,后处理覆盖 Agent 未主动识别但对话中隐含的时间安排。
|
||||||
|
|
||||||
|
**右侧面板 + 大抽屉而非独立页面。** 日历紧贴对话上下文,用户不用切换页面就能看到即将到来的日程。需要全屏管理时展开为大抽屉。这牺牲了独立页面的全屏日历体验,但换来了日程与对话的紧密耦合——这是核心痛点的解法。
|
||||||
|
|
||||||
|
**提醒系统作为独立子系统。** 多渠道提醒(客户端推送 + 邮件 + Webhook)需要后台调度器、通知规则、渠道适配器、投递追踪——这不仅是日历的一个功能,而是 AgentKit 目前不存在的全新基础设施。作为独立子系统设计,未来可被其他功能复用。
|
||||||
|
|
||||||
|
**个人日历 + 共享邀请,不做团队日历。** 用户有自己的私人日历,可以邀请其他用户参加事件(类似 Google Calendar 的邀请),但不支持多用户共同编辑同一日历。团队日历是不同的产品形态,不在此次范围内。
|
||||||
|
|
||||||
|
## Actors
|
||||||
|
|
||||||
|
- A1. **终端用户** — 管理个人日历,手动创建/查看/编辑/删除事件,管理事件类型和标签,配置提醒规则
|
||||||
|
- A2. **Agent** — 在 ReAct 循环中自主识别日程意图并调用日历工具创建事件;后处理提取层在时间关键词触发时分析对话内容
|
||||||
|
- A3. **外部日历服务** — Apple Calendar (CalDAV)、Outlook (Microsoft Graph API)、iCal/ICS 通用格式,作为双向同步目标
|
||||||
|
- A4. **提醒子系统** — 后台调度器定期扫描即将到期的事件,根据提醒规则通过配置的渠道分发通知
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### 事件数据模型
|
||||||
|
|
||||||
|
- R1. 事件包含标题、描述、开始时间、结束时间、全天标记、地点字段
|
||||||
|
- R2. 事件支持循环模式:每日、每周、每月、自定义 RRULE 规则(至少支持 INTERVAL、COUNT、UNTIL、BYDAY)
|
||||||
|
- R3. 事件支持自定义事件类型——用户可创建带名称和颜色的分类(如"会议"蓝色、"截止"红色),事件创建时选择类型
|
||||||
|
- R4. 事件支持多标签——用户可自定义标签,单个事件可挂多个标签,支持按标签筛选
|
||||||
|
- R5. 事件支持时间段——开始时间 + 结束时间,不限于时间点;全天事件和跨天事件均支持
|
||||||
|
|
||||||
|
### 视图模式
|
||||||
|
|
||||||
|
- R6. 日历视图——月/周/日网格布局,支持拖拽创建(拖选时间段直接建事件)、拖拽移动(拖动事件改变时间)
|
||||||
|
- R7. 卡片视图——看板风格,按日期或事件类型分组,卡片可拖拽调整分组
|
||||||
|
- R8. 列表视图——按时间排序的列表,支持行内编辑和批量操作
|
||||||
|
- R9. 三种视图在右侧面板和大抽屉中均可切换使用
|
||||||
|
|
||||||
|
### Agent 自动识别
|
||||||
|
|
||||||
|
- R10. Agent 在 ReAct 循环中可调用日历工具集:`create_event`、`query_events`、`update_event`、`delete_event`
|
||||||
|
- R11. 后处理提取在对话轮次结束后运行,但仅当对话内容包含时间关键词时触发——关键词列表包括但不限于:明天、下周、今天下午、X点、X月X日、开会、截止、deadline、schedule、reminder、提醒、预约
|
||||||
|
- R12. 时间关键词检测使用正则/关键词匹配(零 LLM 调用),命中后才发起 LLM 提取请求
|
||||||
|
- R13. Agent 创建的事件标记 `source="agent"`,并关联到 originating 对话 ID,可在日历中追溯来源
|
||||||
|
- R14. 后处理提取创建的事件标记 `source="post_extract"`,同样关联对话 ID
|
||||||
|
- R15. 用户可在 UI 中区分手动创建、Agent 创建、后处理提取的事件(通过来源标记或颜色区分)
|
||||||
|
|
||||||
|
### 手动管理
|
||||||
|
|
||||||
|
- R16. 用户可通过 UI 手动创建、编辑、删除事件——三种视图均支持创建入口
|
||||||
|
- R17. 日历视图支持拖拽改期——拖动事件到新时间位置即可调整开始/结束时间
|
||||||
|
- R18. 事件类型和标签可在事件编辑界面中设置和修改
|
||||||
|
- R19. 支持批量操作:多选事件后批量删除、批量修改类型/标签
|
||||||
|
|
||||||
|
### 外部日历同步
|
||||||
|
|
||||||
|
- R20. 与 Apple Calendar 通过 CalDAV 协议双向同步——在 AgentKit 创建/修改/删除的事件同步到 Apple Calendar,反之亦然
|
||||||
|
- R21. 与 Outlook Calendar 通过 Microsoft Graph API 双向同步——同上双向
|
||||||
|
- R22. 支持 iCal/ICS 格式的导入和导出——可从 .ics 文件导入事件,可将日历导出为 .ics
|
||||||
|
- R23. 同步冲突采用 last-write-wins 策略,冲突时保留最后修改的版本,并向用户发送冲突通知
|
||||||
|
- R24. 用户可在设置中配置每个外部日历的同步频率和同步范围(哪些事件类型参与同步)
|
||||||
|
|
||||||
|
### 提醒子系统
|
||||||
|
|
||||||
|
- R25. 事件支持提醒规则——可配置多个提醒(如"提前15分钟"、"提前1天"),每个提醒指定渠道
|
||||||
|
- R26. 提醒渠道支持:客户端内通知(WebSocket 推送 / Tauri 系统通知)、邮件、Webhook
|
||||||
|
- R27. 后台调度器定期扫描即将到期的事件,匹配提醒规则,通过配置的渠道分发通知
|
||||||
|
- R28. 用户可为每种事件类型配置默认提醒规则——新建事件时自动继承
|
||||||
|
- R29. 提醒投递状态可追踪(已发送/已读/失败),失败的提醒有重试机制
|
||||||
|
|
||||||
|
### 共享与邀请
|
||||||
|
|
||||||
|
- R30. 用户可邀请其他用户参加事件——通过用户名或邮箱搜索并邀请
|
||||||
|
- R31. 被邀请用户收到通知,可接受/拒绝/暂定
|
||||||
|
- R32. 事件创建者可查看邀请回复状态(谁接受、谁拒绝、谁未回复)
|
||||||
|
- R33. 被邀请用户的事件在其日历中以特殊样式标记(如半透明或边框区分)
|
||||||
|
|
||||||
|
### UI/UX
|
||||||
|
|
||||||
|
- R34. 日历嵌入右侧面板作为标签页(与 Workflow/Monitor 并列),展示今日/近期日程摘要
|
||||||
|
- R35. 右侧面板日历可展开为大抽屉——覆盖大部分屏幕,提供完整的三视图管理体验
|
||||||
|
- R36. UI 遵循 Notion Calendar 设计语言——简洁排版、充足留白、流畅拖拽、柔和配色
|
||||||
|
- R37. Agent 创建事件时,右侧面板实时更新(无需手动刷新),并有视觉提示(如高亮动画)
|
||||||
|
|
||||||
|
## Key Flows
|
||||||
|
|
||||||
|
- F1. Agent 工具调用创建日程
|
||||||
|
- **Trigger:** Agent 在 ReAct 循环中识别到对话内容包含明确的时间安排
|
||||||
|
- **Actors:** A2 (Agent), A1 (用户)
|
||||||
|
- **Steps:** Agent 调用 `create_event` 工具,传入标题/时间/类型等参数 → 工具执行写入 → 返回创建结果 → Agent 在回复中告知用户已创建日程 → 右侧面板实时更新显示新事件
|
||||||
|
- **Outcome:** 事件被创建并标记 `source="agent"`,用户在对话中收到确认
|
||||||
|
- **Covers:** R10, R13, R37
|
||||||
|
|
||||||
|
- F2. 后处理提取创建日程
|
||||||
|
- **Trigger:** 对话轮次结束,对话内容命中时间关键词正则
|
||||||
|
- **Actors:** A2 (Agent 后处理层), A1 (用户)
|
||||||
|
- **Steps:** 关键词检测命中 → 发起 LLM 提取请求,分析对话中的时间安排 → LLM 返回提取结果(0 或多个事件) → 写入日历,标记 `source="post_extract"` → 在对话中插入轻量提示"检测到 N 条日程,已添加到日历"
|
||||||
|
- **Outcome:** 隐含的日程被捕获,用户收到提示可查看/修改
|
||||||
|
- **Covers:** R11, R12, R14
|
||||||
|
|
||||||
|
- F3. 外部日历双向同步
|
||||||
|
- **Trigger:** 定时同步任务触发,或用户手动触发同步
|
||||||
|
- **Actors:** A3 (外部日历服务), A1 (用户)
|
||||||
|
- **Steps:** 拉取外部日历变更 → 与本地事件比对 → 检测冲突 → last-write-wins 解决冲突 → 推送本地变更到外部日历 → 冲突时向用户发送通知
|
||||||
|
- **Outcome:** 本地与外部日历保持一致,冲突被记录和通知
|
||||||
|
- **Covers:** R20, R21, R23
|
||||||
|
|
||||||
|
- F4. 提醒分发
|
||||||
|
- **Trigger:** 后台调度器扫描到事件即将到达提醒时间点
|
||||||
|
- **Actors:** A4 (提醒子系统), A1 (用户)
|
||||||
|
- **Steps:** 调度器匹配提醒规则 → 按规则配置的渠道分发通知(客户端推送 / 邮件 / Webhook) → 记录投递状态 → 失败时重试
|
||||||
|
- **Outcome:** 用户在事件前通过配置的渠道收到提醒
|
||||||
|
- **Covers:** R25, R26, R27, R29
|
||||||
|
|
||||||
|
- F5. 手动创建事件
|
||||||
|
- **Trigger:** 用户在任意视图中触发创建操作(点击新建、拖选时间段、列表中添加)
|
||||||
|
- **Actors:** A1 (用户)
|
||||||
|
- **Steps:** 打开事件编辑表单 → 填写标题/时间/类型/标签/提醒/地点 → 保存 → 事件出现在日历中 → 如配置了外部同步,异步推送到外部日历
|
||||||
|
- **Outcome:** 事件被创建并可管理
|
||||||
|
- **Covers:** R16, R18
|
||||||
|
|
||||||
|
## Acceptance Examples
|
||||||
|
|
||||||
|
- AE1. Agent 在对话中识别日程
|
||||||
|
- **Covers:** R10, R13, R15
|
||||||
|
- **Given:** 用户与 Agent 对话,用户说"下周三下午3点开个产品评审会"
|
||||||
|
- **When:** Agent 在 ReAct 循环中处理该输入
|
||||||
|
- **Then:** Agent 调用 `create_event` 创建事件(标题="产品评审会", 开始时间=下周三15:00, source="agent"),并在回复中告知"已为您创建下周三下午3点的产品评审会日程"
|
||||||
|
|
||||||
|
- AE2. 后处理提取被关键词门控
|
||||||
|
- **Covers:** R11, R12, R14
|
||||||
|
- **Given:** 用户与 Agent 对话,对话内容为"这个方案不错,我们继续优化吧"
|
||||||
|
- **When:** 对话轮次结束,后处理层检查时间关键词
|
||||||
|
- **Then:** 关键词检测未命中(无时间相关词),不发起 LLM 提取请求,不创建任何事件
|
||||||
|
|
||||||
|
- AE3. 后处理提取在关键词命中时运行
|
||||||
|
- **Covers:** R11, R12, R14
|
||||||
|
- **Given:** 用户与 Agent 对话,对话内容为"好的,那下周二之前把文档发给我"
|
||||||
|
- **When:** 对话轮次结束,后处理层检查时间关键词
|
||||||
|
- **Then:** 关键词"下周二"命中 → 发起 LLM 提取 → 创建事件(标题="发送文档", 截止时间=下周二, source="post_extract")→ 对话中插入提示"检测到 1 条日程,已添加到日历"
|
||||||
|
|
||||||
|
- AE4. 同步冲突解决
|
||||||
|
- **Covers:** R23
|
||||||
|
- **Given:** 用户在 AgentKit 修改了事件 A(改到周三),同时在 Outlook 也修改了事件 A(改到周四),同步时检测到冲突
|
||||||
|
- **When:** 同步任务运行
|
||||||
|
- **Then:** 保留最后修改的版本(last-write-wins),向用户发送冲突通知"事件 A 存在同步冲突,已保留最新修改"
|
||||||
|
|
||||||
|
- AE5. 提醒按渠道分发
|
||||||
|
- **Covers:** R25, R26, R27
|
||||||
|
- **Given:** 事件 B 配置了两个提醒:提前30分钟客户端通知 + 提前1天邮件通知
|
||||||
|
- **When:** 调度器扫描到事件 B 的提前1天提醒时间点到达
|
||||||
|
- **Then:** 通过邮件渠道发送提醒 → 记录投递状态为"已发送" → 继续等待提前30分钟的客户端通知时间点
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### Deferred for later
|
||||||
|
|
||||||
|
- Google Calendar 集成——用户未选择,可在后续版本按需添加
|
||||||
|
- 团队共享日历——多用户共同编辑同一日历,当前仅支持个人 + 邀请
|
||||||
|
- 日历分析报表——事件统计、时间分布分析等
|
||||||
|
- 资源预订——会议室、设备等资源预约
|
||||||
|
- 移动端独立 App——当前仅 Web/Tauri 客户端
|
||||||
|
|
||||||
|
### Outside this product's identity
|
||||||
|
|
||||||
|
- 替代专业日历应用——AgentKit 行事历的核心差异化是 Agent 自动识别,不是与 Google Calendar/Notion Calendar 全功能竞争
|
||||||
|
- 项目管理——行事历不承担任务跟踪、甘特图、依赖管理等项目管理职能
|
||||||
|
|
||||||
|
## Dependencies / Assumptions
|
||||||
|
|
||||||
|
- 复用现有认证系统(`src/agentkit/server/auth/models.py` 的 `UserModel`)作为用户身份基础
|
||||||
|
- 复用现有 WebSocket 基础设施实现客户端实时通知推送
|
||||||
|
- 新增依赖:后台调度器(如 APScheduler)用于提醒子系统
|
||||||
|
- 新增依赖:CalDAV 客户端库用于 Apple Calendar 同步
|
||||||
|
- 新增依赖:Microsoft Graph SDK 或直接 HTTP 客户端用于 Outlook 同步
|
||||||
|
- 新增依赖:邮件发送能力(SMTP 或第三方服务)用于邮件提醒渠道
|
||||||
|
- 假设:用户已拥有 Apple Calendar / Outlook 账号并可提供 OAuth/CalDAV 凭据
|
||||||
|
- 假设:AgentKit 服务端可持续运行后台调度器进程(提醒子系统依赖)
|
||||||
|
|
||||||
|
## Sources / Research
|
||||||
|
|
||||||
|
- 现有架构落位参考:路由注册在 `src/agentkit/server/app.py`(第 928-954 行),前端布局在 `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue`(第 146-156 行定义象限标签)
|
||||||
|
- 前端 Pinia stores 位于 `src/agentkit/server/frontend/src/stores/`,API 客户端位于 `src/agentkit/server/frontend/src/api/`
|
||||||
|
- 后端路由模块位于 `src/agentkit/server/routes/`(27 个 .py 文件),工具注册在 `src/agentkit/tools/registry.py`
|
||||||
|
- 用户模型位于 `src/agentkit/server/auth/models.py`(SQLAlchemy 2 + SQLite),可复用 `Base` 创建日历模型
|
||||||
|
- 消息总线 `src/agentkit/bus/message.py` 的 `AgentMessage` 可用于提醒子系统的内部通信
|
||||||
|
- DocumentTool(`src/agentkit/tools/document_tool.py`)可作为 Agent 工具集成的参考模式——工具注册、ReAct 循环调用、结果返回
|
||||||
|
|
@ -0,0 +1,728 @@
|
||||||
|
---
|
||||||
|
title: Calendar & Schedule Feature
|
||||||
|
status: completed
|
||||||
|
date: 2026-06-23
|
||||||
|
origin: docs/brainstorms/2026-06-23-calendar-schedule-requirements.md
|
||||||
|
type: feature
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
在 AgentKit 客户端嵌入行事历,通过混合模式(ReAct 工具调用 + 时间关键词触发的后处理提取)自动捕获对话中的日程,支持完整的手动管理——循环事件、自定义类型、标签、三种视图(日历/卡片/列表),双向同步 Apple Calendar (CalDAV)、Outlook (Graph API) 和 iCal/ICS,配套独立的多渠道提醒子系统。
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
Agent 对话中频繁产生可执行的时间安排,但这些信息当前无处记录。痛点不在"缺少一个日历应用",而在"Agent 产生的日程与日历之间没有桥梁"。本计划实现这架桥梁:Agent 工具调用 + 后处理提取 + 手动管理 + 外部同步 + 提醒分发,形成完整的日程闭环。
|
||||||
|
|
||||||
|
**Scope: Deep** — 跨前端 UI、后端 API、Agent 工具、外部同步、提醒子系统五大领域。
|
||||||
|
|
||||||
|
## Requirements Traceability
|
||||||
|
|
||||||
|
| Requirement Group | R-IDs | Covered by Units |
|
||||||
|
|---|---|---|
|
||||||
|
| 事件数据模型 | R1-R5 | U1, U2 |
|
||||||
|
| 视图模式 | R6-R9 | U10 (KTD-12: R9 = summary + drawer) |
|
||||||
|
| Agent 自动识别 | R10-R15 | U3, U4 (R15 source distinction: U10) |
|
||||||
|
| 手动管理 | R16-R19 | U10, U11 |
|
||||||
|
| 外部日历同步 | R20-R24 | U6, U7, U8 (SyncManager: U6) |
|
||||||
|
| 提醒子系统 | R25-R29 | U5, U12 |
|
||||||
|
| 共享与邀请 | R30-R33 | U2, U11 (R30 user search: U2, R33 invited styling: U10) |
|
||||||
|
| UI/UX | R34-R37 | U10, U11 |
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
**KTD-1. Mirror documents subsystem for backend structure.** 日历后端镜像 `src/agentkit/documents/` 的结构——`calendar/db.py` (aiosqlite bare-connection)、`calendar/models.py` (dataclass DTO)、`calendar/service.py` (business logic)、`calendar/tool.py` (agent tool)。这是代码库中最近验证过的模式,与现有架构一致,避免引入 SQLAlchemy engine/session 的复杂性。Rationale: aiosqlite bare-connection 已在 auth 和 documents 子系统中验证,轻量且与异步架构兼容。
|
||||||
|
|
||||||
|
**KTD-2. asyncio loop for reminder scheduler, not APScheduler.** 提醒调度器跟随 `src/agentkit/server/task_store.py` 的 `start()`/`stop()` + `asyncio.create_task` 循环模式,在 `app.py` lifespan 中启停。Rationale: 不引入新依赖,与现有后台任务模式一致(task_store cleanup loop、session writer loop)。`ponytail: asyncio.sleep 轮询的精度是秒级,如果未来需要亚秒级调度可升级到 APScheduler。` **Note:** 需求文档提到"新增依赖:后台调度器(如 APScheduler)",本决策选择不引入 APScheduler 而复用 asyncio loop 模式——这是有意识的偏离,因为 task_store 已验证此模式可行,且避免新依赖。
|
||||||
|
|
||||||
|
**KTD-3. python-dateutil.rrule for recurrence expansion.** 使用 `dateutil.rrule` 处理 RRULE 规则展开(RFC 5545 兼容),支持 FREQ/INTERVAL/COUNT/UNTIL/BYDAY。Rationale: 标准库 `datetime` 不支持循环规则,dateutil 是 Python 生态中唯一成熟的 RRULE 实现,且是 icalendar 库的依赖。
|
||||||
|
|
||||||
|
**KTD-4. caldav library for Apple Calendar, httpx for Outlook Graph API.** Apple Calendar 同步使用 `caldav` 库(CalDAV 协议),Outlook 同步直接用 `httpx` 调用 Microsoft Graph REST API(不引入 msgraph SDK)。Rationale: caldav 库是 Python 生态中唯一成熟的 CalDAV 客户端;msgraph SDK 过重(数百 MB),httpx 已在依赖中,Graph REST API 简单直接。
|
||||||
|
|
||||||
|
**KTD-5. icalendar library for ICS import/export.** 使用 `icalendar` 库生成和解析 .ics 文件。Rationale: 手写 iCalendar 格式容易出错(编码、转义、时区),icalendar 库是事实标准。
|
||||||
|
|
||||||
|
**KTD-6. FullCalendar Vue3 for calendar grid views.** 前端日历网格视图(月/周/日)使用 `@fullcalendar/vue3`,样式定制为 Notion Calendar 风格。Rationale: 从零实现带拖拽的月/周/日网格是数千行代码,FullCalendar 是成熟标准库,支持拖拽创建/移动、循环事件展开、视图切换。`ponytail: FullCalendar 是本计划中唯一的大型前端依赖,如果后续需要移除,日历网格组件是独立的可替换层。`
|
||||||
|
|
||||||
|
**KTD-7. Post-processing hook in chat.py after assistant reply persistence.** 后处理提取钩子插入 `src/agentkit/server/routes/chat.py` 的 `_handle_chat_message()` 中,在 assistant 回复持久化之后(REACT 路径约 line 1158,DIRECT_CHAT 路径约 line 971)触发。先做零 LLM 的正则关键词检测,命中后才调用 LLM 提取。Rationale: 这是对话轮次结束的自然插入点,不改变现有流程,仅在回复完成后追加一个异步后台任务。
|
||||||
|
|
||||||
|
**KTD-8. aiosmtplib for email reminder channel.** 邮件提醒渠道使用 `aiosmtplib`(异步 SMTP 客户端)。Rationale: smtplib 是同步的会阻塞事件循环,aiosmtplib 是标准异步替代品。
|
||||||
|
|
||||||
|
**KTD-9. Reuse existing LLM gateway for post-processing extraction.** 后处理提取的 LLM 调用复用 `agentkit.llm` 网关,不新建 LLM 客户端。Rationale: LLM 网关已有 fallback、缓存、用量追踪,直接复用。
|
||||||
|
|
||||||
|
**KTD-10. WebSocket piggyback for real-time calendar push.** 日历实时更新(Agent 创建事件、提醒触发、邀请通知、同步冲突)复用现有 chat WebSocket 连接,新增 `calendar_event_created`、`calendar_reminder`、`calendar_invitation`、`calendar_sync_conflict` 消息类型。Rationale: 避免新建 WS 连接,前端 chat store 已有消息分发机制。
|
||||||
|
|
||||||
|
**KTD-11. UTC storage + local display timezone strategy.** 所有时间字段(`start_time`、`end_time`、`scheduled_time`、`last_modified`、`created_at`)以 ISO 8601 UTC 存储在数据库中,前端显示时由浏览器自动转换为本地时区。RRULE 展开基于 UTC 计算,显示时再转本地。Rationale: 统一存储避免时区歧义,浏览器 `Intl.DateTimeFormat` 或 dayjs UTC 插件处理显示转换。`ponytail: 不支持用户手动选择时区——浏览器本地时区即显示时区,跨时区用户需自行调整设备时区。`
|
||||||
|
|
||||||
|
**KTD-12. Right panel = summary view, drawer = full management.** 右面板日历 tab 仅展示摘要视图(今日 + 未来 3 条事件),点击"展开"按钮打开 80% 宽度的大抽屉,抽屉内包含完整的三视图管理(日历网格/卡片/列表)+ 事件编辑器 + 邀请管理。Rationale: 右面板空间有限,摘要视图满足"快速查看"需求;完整管理功能需要大画布,抽屉模式不离开当前页面上下文。
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Module Map
|
||||||
|
|
||||||
|
```
|
||||||
|
src/agentkit/calendar/ # New subsystem (mirrors documents/)
|
||||||
|
__init__.py
|
||||||
|
models.py # @dataclass: CalendarEvent, EventType, Tag, Reminder, ReminderRule, ExternalCalendarConfig, Invitation
|
||||||
|
db.py # aiosqlite: init_calendar_db(), CRUD functions
|
||||||
|
service.py # CalendarService: business logic, event CRUD, type/tag management
|
||||||
|
recurrence.py # RRULE expansion wrapper (dateutil.rrule)
|
||||||
|
scheduler.py # ReminderScheduler: start/stop asyncio loop, scan + dispatch
|
||||||
|
reminders.py # ReminderDispatcher: client push, email, webhook channels
|
||||||
|
extraction.py # PostProcessingExtractor: keyword gate + LLM extraction
|
||||||
|
sync/
|
||||||
|
__init__.py
|
||||||
|
base.py # AbstractSyncProvider interface
|
||||||
|
caldav_provider.py # Apple Calendar sync via caldav library
|
||||||
|
outlook_provider.py # Outlook sync via httpx + Graph API
|
||||||
|
ics_provider.py # ICS import/export via icalendar library
|
||||||
|
manager.py # SyncManager: orchestrate providers, conflict resolution
|
||||||
|
|
||||||
|
src/agentkit/tools/calendar_tool.py # CalendarTool(Tool): create_event, query_events, update_event, delete_event
|
||||||
|
|
||||||
|
src/agentkit/server/routes/calendar.py # APIRouter(prefix="/calendar"): REST endpoints
|
||||||
|
|
||||||
|
src/agentkit/server/frontend/src/
|
||||||
|
api/calendar.ts # CalendarApiClient extends BaseApiClient
|
||||||
|
stores/calendar.ts # useCalendarStore (Pinia Composition API)
|
||||||
|
components/calendar/
|
||||||
|
CalendarPanel.vue # Right panel tab content (summary view only — KTD-12)
|
||||||
|
CalendarDrawer.vue # Large drawer wrapper (full management — KTD-12)
|
||||||
|
CalendarGrid.vue # FullCalendar wrapper (month/week/day)
|
||||||
|
CardView.vue # Kanban-style card view
|
||||||
|
ListView.vue # Sorted list view
|
||||||
|
EventBadge.vue # Source distinction badge (manual/agent/post_extract — G3)
|
||||||
|
EventEditor.vue # Event create/edit form (a-drawer)
|
||||||
|
ReminderConfig.vue # Reminder rule configuration
|
||||||
|
SyncSettings.vue # External calendar sync config
|
||||||
|
InvitationManager.vue # Invitation response UI
|
||||||
|
views/CalendarView.vue # Top-level route page (optional, for drawer mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wiring Points
|
||||||
|
|
||||||
|
| Integration | File | Location |
|
||||||
|
|---|---|---|
|
||||||
|
| DB init + service | `src/agentkit/server/app.py` | lifespan startup (~line 263, next to `init_documents_db()`) |
|
||||||
|
| Route registration | `src/agentkit/server/app.py` | ~line 954, `app.include_router(calendar.router, prefix="/api/v1")` |
|
||||||
|
| Tool registration | `src/agentkit/server/app.py` | ~line 268, `agent._tool_registry.register(CalendarTool(...))` |
|
||||||
|
| Scheduler start | `src/agentkit/server/app.py` | lifespan startup (~line 151, next to `task_store.start_cleanup()`) |
|
||||||
|
| Scheduler stop | `src/agentkit/server/app.py` | lifespan shutdown (~line 430) |
|
||||||
|
| SyncManager start | `src/agentkit/server/app.py` | lifespan startup (next to reminder scheduler) |
|
||||||
|
| SyncManager stop | `src/agentkit/server/app.py` | lifespan shutdown (next to reminder scheduler stop) |
|
||||||
|
| Post-processing hook | `src/agentkit/server/routes/chat.py` | ~line 1158 (REACT), ~line 971 (DIRECT_CHAT) |
|
||||||
|
| WebSocket push | `src/agentkit/server/routes/chat.py` | `chat_manager.send_json(session_id, {...})` |
|
||||||
|
| Frontend right panel tab | `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` | `bottomRightTabs` array (~line 153) |
|
||||||
|
| Frontend WS handler | `src/agentkit/server/frontend/src/stores/chat.ts` | `handleWsMessage()` switch (~line 586) |
|
||||||
|
| Frontend WS types | `src/agentkit/server/frontend/src/api/types.ts` | `WsServerMessage` union (~line 111) |
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/agentkit/calendar/models.py (dataclasses, mirrors documents/models.py)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EventType:
|
||||||
|
id: str # UUID
|
||||||
|
user_id: str # FK to users
|
||||||
|
name: str # "会议", "截止", "个人"
|
||||||
|
color: str # "#4A90D9"
|
||||||
|
is_default: bool # system-provided types
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tag:
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CalendarEvent:
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
start_time: str # ISO 8601 UTC (see KTD-11)
|
||||||
|
end_time: str # ISO 8601 UTC
|
||||||
|
is_all_day: bool
|
||||||
|
location: str
|
||||||
|
event_type_id: str | None
|
||||||
|
rrule: str | None # RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10"
|
||||||
|
source: str # "manual" | "agent" | "post_extract"
|
||||||
|
is_invited: bool # True if this event arrived via invitation (R33 — special styling)
|
||||||
|
conversation_id: str | None # origin traceability for agent/extracted events
|
||||||
|
external_id: str | None # ID in external calendar (for sync)
|
||||||
|
external_provider: str | None # "caldav" | "outlook"
|
||||||
|
last_modified: str # ISO 8601 UTC, for conflict resolution (last-write-wins)
|
||||||
|
created_at: str # ISO 8601 UTC
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EventTag: # many-to-many junction
|
||||||
|
event_id: str
|
||||||
|
tag_id: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReminderRule:
|
||||||
|
id: str
|
||||||
|
event_id: str # FK to events (nullable for type-level defaults)
|
||||||
|
event_type_id: str # FK to event_types (for default reminders)
|
||||||
|
offset_minutes: int # -15 = 15 min before, -1440 = 1 day before
|
||||||
|
channels: list[str] # ["client", "email", "webhook"]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReminderDelivery:
|
||||||
|
id: str
|
||||||
|
reminder_rule_id: str
|
||||||
|
event_id: str
|
||||||
|
scheduled_time: str
|
||||||
|
status: str # "pending" | "sent" | "failed" | "read"
|
||||||
|
channel: str
|
||||||
|
attempts: int
|
||||||
|
last_error: str | None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExternalCalendarConfig:
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
provider: str # "caldav" | "outlook"
|
||||||
|
credentials: str # encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token)
|
||||||
|
sync_frequency: int # minutes
|
||||||
|
sync_scope: list[str] # event type IDs to sync, empty = all
|
||||||
|
last_sync: str | None
|
||||||
|
sync_token: str | None # delta token for incremental sync
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Invitation:
|
||||||
|
id: str
|
||||||
|
event_id: str
|
||||||
|
inviter_user_id: str
|
||||||
|
invitee_email: str
|
||||||
|
status: str # "pending" | "accepted" | "declined" | "tentative"
|
||||||
|
responded_at: str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Backend data model & storage
|
||||||
|
|
||||||
|
- **Goal:** Define calendar dataclasses, aiosqlite DB schema with CRUD functions, and RRULE recurrence expansion wrapper.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/calendar/__init__.py` (new)
|
||||||
|
- `src/agentkit/calendar/models.py` (new — dataclasses above)
|
||||||
|
- `src/agentkit/calendar/db.py` (new — `init_calendar_db()`, `insert_event()`, `get_event()`, `list_events()`, `update_event()`, `delete_event()`, same for types/tags/reminders/configs/invitations)
|
||||||
|
- `src/agentkit/calendar/recurrence.py` (new — `expand_rrule(rrule_str, start_time, range_start, range_end) -> list[str]` wrapper around `dateutil.rrule`)
|
||||||
|
- **Patterns:** Mirror `src/agentkit/documents/db.py` (aiosqlite bare-connection, `PRAGMA journal_mode=WAL`, module-level async functions). Mirror `src/agentkit/documents/models.py` for dataclass DTO pattern. `recurrence.py` wraps `dateutil.rrule.rrulestr()` + `between()` for date-range expansion.
|
||||||
|
- **DB schema:** 7 tables — `calendar_events`, `calendar_event_types`, `calendar_tags`, `calendar_event_tags`, `calendar_reminder_rules`, `calendar_reminder_deliveries`, `calendar_external_configs`, `calendar_invitations`. All with `String(36)` UUIDs, ISO 8601 UTC timestamps (see KTD-11), indexes on `user_id` and `start_time`.
|
||||||
|
- **RRULE expansion (G1, G9):** `recurrence.py` provides `expand_rrule(rrule_str, dtstart, range_start, range_end)` returning a list of ISO 8601 UTC occurrence strings within the range. Used by: (a) `CalendarService.list_events()` to expand recurring events for display, (b) `ReminderScheduler` to find occurrences entering reminder window, (c) sync providers to push individual occurrences if external calendar requires expanded events. All times in UTC per KTD-11.
|
||||||
|
- **Test file:** `tests/unit/calendar/test_db.py`, `tests/unit/calendar/test_recurrence.py`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- `test_init_calendar_db_creates_all_tables` — call `init_calendar_db()`, verify tables exist
|
||||||
|
- `test_insert_and_get_event_roundtrip` — insert event, fetch by id, all fields preserved (including `is_invited`)
|
||||||
|
- `test_list_events_by_user_filtered_by_date_range` — insert 3 events, query range covering 2
|
||||||
|
- `test_update_event_modifies_fields` — insert, update title, verify
|
||||||
|
- `test_delete_event_removes_record` — insert, delete, verify gone
|
||||||
|
- `test_event_type_crud` — create/list/update/delete event types
|
||||||
|
- `test_tag_many_to_many` — event with 3 tags, query by tag returns event
|
||||||
|
- `test_reminder_rule_crud` — create rule for event, create default rule for type
|
||||||
|
- `test_external_config_stores_encrypted_credentials` — insert config, verify credentials field is opaque
|
||||||
|
- `test_expand_rrule_weekly_count` — `FREQ=WEEKLY;BYDAY=MO;COUNT=4` from Monday → 4 occurrences
|
||||||
|
- `test_expand_rrule_daily_range_filter` — `FREQ=DAILY` starting Jan 1, range Jan 3–Jan 5 → 3 occurrences
|
||||||
|
- `test_expand_rrule_until_clause` — `FREQ=DAILY;UNTIL=20260131` → occurrences stop at Jan 31
|
||||||
|
- `test_expand_rrule_no_rrule_returns_single` — `rrule=None` → returns `[start_time]` only
|
||||||
|
- `test_expand_rrule_all_day_event` — all-day event with RRULE, verify date (not datetime) expansion
|
||||||
|
- `test_expand_rrule_dst_boundary` — event crossing DST transition, verify UTC consistency
|
||||||
|
- **Dependencies:** None (foundation unit)
|
||||||
|
|
||||||
|
### U2. Backend calendar service & REST API
|
||||||
|
|
||||||
|
- **Goal:** Business logic layer + REST endpoints for full calendar CRUD, event types, tags, invitations, and non-admin user search for invitations.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/calendar/service.py` (new — `CalendarService` class)
|
||||||
|
- `src/agentkit/server/routes/calendar.py` (new — `APIRouter(prefix="/calendar", tags=["calendar"])`)
|
||||||
|
- **Patterns:** Mirror `src/agentkit/documents/service.py` (service class with injected dependencies) and `src/agentkit/server/routes/documents.py` (route module with `_verify_api_key` or `require_authenticated` dependency, Pydantic request/response models, `request.app.state.calendar_service` access).
|
||||||
|
- **Service methods:** `create_event()`, `get_event()`, `list_events()` (with RRULE expansion via `recurrence.expand_rrule`), `update_event()`, `delete_event()`, `list_event_types()`, `create_event_type()`, `update_event_type()`, `list_tags()`, `create_tag()`, `create_invitation()`, `respond_to_invitation()`, `list_invitations()`, `search_users(q)` (returns `[{username, email}]` for invitation picker — G5/A3).
|
||||||
|
- **REST endpoints:**
|
||||||
|
- `POST /api/v1/calendar/events` — create event
|
||||||
|
- `GET /api/v1/calendar/events?start=...&end=...&type_id=...&tag_id=...` — list with filters (tag_id filter covers G2)
|
||||||
|
- `GET /api/v1/calendar/events/{id}` — get single
|
||||||
|
- `PATCH /api/v1/calendar/events/{id}` — update
|
||||||
|
- `DELETE /api/v1/calendar/events/{id}` — delete
|
||||||
|
- `POST /api/v1/calendar/events/{id}/invitations` — invite user by email (creates Invitation + pushes `calendar_invitation` WS to invitee — G6)
|
||||||
|
- `POST /api/v1/calendar/invitations/{id}/respond` — accept/decline/tentative
|
||||||
|
- `GET /api/v1/calendar/invitations` — list pending invitations for current user
|
||||||
|
- `GET /api/v1/calendar/users/search?q=xxx` — non-admin user search by username/email, returns `[{username, email}]` only (no user_id leak) — G5/A3. Auth: any authenticated user.
|
||||||
|
- `GET /api/v1/calendar/event-types` — list types
|
||||||
|
- `POST /api/v1/calendar/event-types` — create type
|
||||||
|
- `PATCH /api/v1/calendar/event-types/{id}` — update type
|
||||||
|
- `GET /api/v1/calendar/tags` — list tags
|
||||||
|
- `POST /api/v1/calendar/tags` — create tag
|
||||||
|
- **User search implementation (G5/A3):** `search_users(q)` queries `users` table (from `src/agentkit/server/auth/models.py` `UserModel`) by `username ILIKE '%q%' OR email ILIKE '%q%'`, returns top 10 results as `[{username, email}]`. Does NOT require admin permission — uses standard authenticated user dependency. Existing `GET /api/v1/admin/users` requires admin and has no username/email search, so this new endpoint is necessary.
|
||||||
|
- **Invitation notification (G6):** When `create_invitation()` is called, after persisting the `Invitation` record, push `calendar_invitation` WS message to the invitee's active session(s) via `chat_manager.send_json()`. Invitee frontend shows a notification + updates invitation list.
|
||||||
|
- **Wiring:** `app.py` lifespan — `await init_calendar_db()`, `app.state.calendar_service = CalendarService()`, `app.include_router(calendar.router, prefix="/api/v1")`.
|
||||||
|
- **Test file:** `tests/unit/calendar/test_service.py`, `tests/unit/calendar/test_routes.py`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- `test_create_event_with_type_and_tags` — create event with type_id and 2 tags, verify all linked
|
||||||
|
- `test_list_events_filters_by_date_range` — 3 events across days, filter returns correct subset
|
||||||
|
- `test_list_events_filters_by_type_and_tag` — filter combination (G2)
|
||||||
|
- `test_list_events_filters_by_tag_only` — 5 events, 2 with tag X, filter tag_id=X returns only 2 (G2)
|
||||||
|
- `test_list_events_expands_recurring` — event with `FREQ=DAILY;COUNT=3`, list range covering 2 days → returns 2 expanded occurrences
|
||||||
|
- `test_update_event_partial_fields` — PATCH only title, other fields unchanged
|
||||||
|
- `test_delete_event_cascades_reminders_and_tags` — delete event, verify reminder rules and junction rows removed
|
||||||
|
- `test_create_invitation_pushes_ws_to_invitee` — create invitation, verify `chat_manager.send_json()` called with `calendar_invitation` type (G6)
|
||||||
|
- `test_respond_to_invitation_updates_status` — invite, respond "accepted", verify status + `responded_at` set
|
||||||
|
- `test_search_users_by_username` — seed 3 users, search "alice" returns matching user with username+email only (G5)
|
||||||
|
- `test_search_users_by_email` — search by email domain returns matches (G5)
|
||||||
|
- `test_search_users_no_match_returns_empty` — search "zzz" returns `[]`
|
||||||
|
- `test_search_users_requires_auth` — no auth header → 401
|
||||||
|
- `test_search_users_returns_max_10` — seed 15 matching users, verify only 10 returned
|
||||||
|
- `test_event_type_default_color_persistence` — create type with color, verify roundtrip
|
||||||
|
- `test_route_create_event_requires_auth` — no auth header → 401
|
||||||
|
- `test_route_list_events_returns_paginated` — large result set with limit/offset
|
||||||
|
- **Dependencies:** U1
|
||||||
|
|
||||||
|
### U3. Agent calendar tool (ReAct integration)
|
||||||
|
|
||||||
|
- **Goal:** CalendarTool for ReAct loop — Agent autonomously calls `create_event`, `query_events`, `update_event`, `delete_event`.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/tools/calendar_tool.py` (new — `CalendarTool(Tool)`)
|
||||||
|
- **Patterns:** Mirror `src/agentkit/tools/document_tool.py` — extend `Tool` ABC, define `input_schema` as JSON Schema dict, `execute(**kwargs)` dispatches by `action` parameter, returns `{"success": True/False, ...}` dict, errors caught not raised.
|
||||||
|
- **Tool actions:**
|
||||||
|
- `create_event` — params: title, start_time, end_time, description, location, event_type_name, tag_names, rrule, is_all_day
|
||||||
|
- `query_events` — params: start_date, end_date, limit
|
||||||
|
- `update_event` — params: event_id, fields to update
|
||||||
|
- `delete_event` — params: event_id
|
||||||
|
- **Source marking:** All events created via tool set `source="agent"` and `conversation_id` from execution context.
|
||||||
|
- **Wiring:** `app.py` ~line 268 — `agent._tool_registry.register(CalendarTool(service=calendar_service))`.
|
||||||
|
- **Test file:** `tests/unit/tools/test_calendar_tool.py`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- `test_create_event_action_returns_success` — call execute with create_event action, verify event in DB with source="agent"
|
||||||
|
- `test_create_event_with_recurrence_sets_rrule` — rrule param stored correctly
|
||||||
|
- `test_query_events_returns_list` — create 2 events, query, verify both returned
|
||||||
|
- `test_update_event_action_modifies_fields` — create then update title
|
||||||
|
- `test_delete_event_action_removes_record` — create then delete
|
||||||
|
- `test_invalid_action_returns_error` — unknown action → `{"success": False, "error": "..."}`
|
||||||
|
- `test_missing_required_field_returns_error` — create_event without title → error
|
||||||
|
- `test_created_event_has_conversation_id` — verify conversation_id is set from context
|
||||||
|
- **Dependencies:** U2
|
||||||
|
|
||||||
|
### U4. Post-processing extraction (keyword gating + LLM)
|
||||||
|
|
||||||
|
- **Goal:** After conversation turn, detect time keywords (zero-LLM regex), if hit then call LLM to extract schedule info, create events with `source="post_extract"`.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/calendar/extraction.py` (new — `PostProcessingExtractor` class)
|
||||||
|
- **Patterns:** Keyword regex follows the `_TOOL_CONTEXT_RE` / greeting regex pattern in `src/agentkit/chat/request_preprocessor.py`. LLM call via `agentkit.llm` gateway (reuse existing `LLMGateway.complete()` or `acomplete()`).
|
||||||
|
- **Keyword regex:** `明天|后天|下周|本周|今天下午|今天上午|上午|下午|晚上|\d+点|\d+月\d+日|\d+号|开会|截止|deadline|schedule|reminder|提醒|预约|约定|安排`
|
||||||
|
- **LLM extraction prompt:** System prompt instructs LLM to extract events as JSON array `[{title, start_time, end_time, description, event_type}]` from conversation text. Empty array if no events.
|
||||||
|
- **Flow:**
|
||||||
|
1. Hook fires after assistant reply persisted in `chat.py` (~line 1158 REACT, ~line 971 DIRECT_CHAT)
|
||||||
|
2. `asyncio.create_task(extractor.extract(conversation_text, conversation_id, user_id))`
|
||||||
|
3. Regex keyword scan — if no match, return immediately (zero LLM cost)
|
||||||
|
4. If match, call LLM gateway with extraction prompt
|
||||||
|
5. Parse JSON response, create events with `source="post_extract"`
|
||||||
|
6. Push `calendar_event_created` WS message to client
|
||||||
|
- **Wiring:** `chat.py` — after `sm.append_message()` for assistant reply, call `asyncio.create_task(post_extractor.extract(...))`. Extractor instance on `app.state.post_extractor`.
|
||||||
|
- **Test file:** `tests/unit/calendar/test_extraction.py`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- `test_keyword_regex_matches_chinese_time_words` — "明天下午3点" matches, "继续优化吧" doesn't
|
||||||
|
- `test_keyword_regex_matches_english_time_words` — "deadline tomorrow" matches
|
||||||
|
- `test_no_keyword_skips_llm_call` — mock LLM gateway, verify `.complete()` never called when no keyword
|
||||||
|
- `test_keyword_hit_triggers_llm_extraction` — mock LLM returns `[{title: "会议", start_time: "..."}]`, verify event created with source="post_extract"
|
||||||
|
- `test_llm_returns_empty_array_creates_nothing` — LLM returns `[]`, no events created
|
||||||
|
- `test_malformed_llm_response_handled_gracefully` — LLM returns invalid JSON, no crash, logged
|
||||||
|
- `test_extracted_events_have_conversation_id` — verify conversation_id set
|
||||||
|
- `test_extraction_does_not_block_chat_response` — verify extract is async, chat reply returns before extraction completes
|
||||||
|
- **Dependencies:** U2, U3 (for source marking pattern)
|
||||||
|
|
||||||
|
### U5. Reminder subsystem (scheduler + multi-channel dispatch)
|
||||||
|
|
||||||
|
- **Goal:** Background scheduler scans upcoming events, matches reminder rules, dispatches via client push / email / webhook, tracks delivery status with retry.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/calendar/scheduler.py` (new — `ReminderScheduler` class)
|
||||||
|
- `src/agentkit/calendar/reminders.py` (new — `ReminderDispatcher` class)
|
||||||
|
- **Patterns:** Scheduler follows `src/agentkit/server/task_store.py` `start()`/`stop()` + `asyncio.create_task` loop pattern. Dispatcher uses strategy pattern — one method per channel.
|
||||||
|
- **Scheduler loop:**
|
||||||
|
1. Every 60 seconds: query events where `start_time + offset_minutes` is within next 60s window
|
||||||
|
2. For each match, check if `ReminderDelivery` already exists (idempotency)
|
||||||
|
3. If not, create `ReminderDelivery` records (one per channel) and dispatch
|
||||||
|
4. Failed deliveries retried up to 3 times with exponential backoff
|
||||||
|
- **Dispatcher channels:**
|
||||||
|
- `client` — `chat_manager.send_json(session_id, {"type": "calendar_reminder", "data": {...}})`
|
||||||
|
- `email` — `aiosmtplib.send()` with SMTP config from `agentkit.yaml`
|
||||||
|
- `webhook` — `httpx.post()` to user-configured webhook URL
|
||||||
|
- **Default reminders:** When event created, if its `event_type_id` has default `ReminderRule`s, clone them to the event.
|
||||||
|
- **Wiring:** `app.py` lifespan startup — `reminder_scheduler = ReminderScheduler(calendar_service, dispatcher); app.state.reminder_scheduler = reminder_scheduler; await reminder_scheduler.start()`. Shutdown — `await reminder_scheduler.stop()`.
|
||||||
|
- **Test file:** `tests/unit/calendar/test_scheduler.py`, `tests/unit/calendar/test_reminders.py`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- `test_scheduler_finds_event_within_reminder_window` — event 10 min away, rule offset -15min, scheduler finds it
|
||||||
|
- `test_scheduler_skips_event_outside_window` — event 2 hours away, rule offset -15min, not found
|
||||||
|
- `test_idempotent_delivery_no_duplicate` — scheduler runs twice, only one delivery record created
|
||||||
|
- `test_client_channel_sends_ws_message` — mock chat_manager, verify send_json called
|
||||||
|
- `test_email_channel_sends_smtp` — mock aiosmtplib, verify send called with correct params
|
||||||
|
- `test_webhook_channel_posts_to_url` — mock httpx, verify POST called
|
||||||
|
- `test_failed_delivery_retries_up_to_3_times` — mock channel to fail, verify 3 attempts
|
||||||
|
- `test_default_reminders_inherited_from_event_type` — create event with type that has default rules, verify rules cloned
|
||||||
|
- `test_scheduler_start_stop_lifecycle` — start(), verify task running, stop(), verify cancelled
|
||||||
|
- **Dependencies:** U1, U2
|
||||||
|
|
||||||
|
### U6. External sync — CalDAV (Apple Calendar)
|
||||||
|
|
||||||
|
- **Goal:** Bidirectional sync with Apple Calendar via CalDAV protocol using `caldav` library, plus SyncManager orchestration.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/calendar/sync/base.py` (new — `AbstractSyncProvider` interface: `pull_changes()`, `push_changes()`, `test_connection()`)
|
||||||
|
- `src/agentkit/calendar/sync/caldav_provider.py` (new — `CalDAVSyncProvider`)
|
||||||
|
- `src/agentkit/calendar/sync/manager.py` (new — `SyncManager` class — G8)
|
||||||
|
- **Patterns:** Provider interface allows future providers (Google Calendar) without changing SyncManager. CalDAV connection uses `caldav.DAVClient(url)` with user Apple ID + app-specific password.
|
||||||
|
- **SyncManager (G8):** `SyncManager` orchestrates all providers. Methods: `sync_all(user_id)` (iterates user's `ExternalCalendarConfig`s, calls provider pull/push), `sync_provider(config_id)` (single provider), `resolve_conflict(local_event, remote_event)` (last-write-wins + WS notification). Called by: (a) scheduled sync (asyncio loop, same pattern as reminder scheduler), (b) manual "Sync Now" button, (c) after local event creation/update (push to external). `SyncManager` is registered on `app.state.sync_manager` and started/stopped in `app.py` lifespan.
|
||||||
|
- **Sync flow:**
|
||||||
|
1. `pull_changes()` — `calendar.date_search(start, end)` to fetch events in range, match by `external_id` (CalDAV UID), create/update local events
|
||||||
|
2. `push_changes()` — for local events modified since `last_sync`, `calendar.save_event(ical_data)` to push to CalDAV
|
||||||
|
3. Conflict: `last_modified` comparison, last-write-wins, log conflict + push `calendar_sync_conflict` WS notification to user (G4)
|
||||||
|
- **ICS generation:** Use `icalendar` library to convert `CalendarEvent` → iCalendar VEVENT (with RRULE if present).
|
||||||
|
- **Config:** `ExternalCalendarConfig` stores CalDAV URL, username, app-specific password (encrypted). User configures via settings UI.
|
||||||
|
- **Test file:** `tests/unit/calendar/test_sync_caldav.py`, `tests/unit/calendar/test_sync_manager.py`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- `test_caldav_provider_pull_creates_local_events` — mock caldav client returning 2 events, verify local events created with external_id set
|
||||||
|
- `test_caldav_provider_pull_updates_existing_event` — event already exists (matched by external_id), remote modified, local updated
|
||||||
|
- `test_caldav_provider_push_creates_remote_event` — local event with no external_id, push creates remote, external_id stored
|
||||||
|
- `test_caldav_provider_push_updates_remote_event` — local event modified, push updates remote
|
||||||
|
- `test_caldav_conflict_last_write_wins` — both sides modified, newer last_modified wins, conflict logged
|
||||||
|
- `test_caldav_conflict_sends_ws_notification` — conflict detected, verify `chat_manager.send_json()` called with `calendar_sync_conflict` type (G4)
|
||||||
|
- `test_caldav_rrule_roundtrip` — event with RRULE synced, verify RRULE preserved in both directions
|
||||||
|
- `test_caldav_test_connection_success` — mock returns calendars, test_connection() returns True
|
||||||
|
- `test_caldav_test_connection_failure` — mock raises, test_connection() returns False with error message
|
||||||
|
- `test_sync_manager_sync_all_iterates_providers` — 2 configs, verify both providers called (G8)
|
||||||
|
- `test_sync_manager_sync_provider_updates_last_sync` — sync completes, verify config.last_sync updated (G8)
|
||||||
|
- `test_sync_manager_resolve_conflict_notifies_user` — conflict, verify WS push (G8/G4)
|
||||||
|
- **Dependencies:** U2, U8 (ICS generation)
|
||||||
|
|
||||||
|
### U7. External sync — Outlook (Microsoft Graph API)
|
||||||
|
|
||||||
|
- **Goal:** Bidirectional sync with Outlook Calendar via Microsoft Graph REST API using `httpx`.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/calendar/sync/outlook_provider.py` (new — `OutlookSyncProvider`)
|
||||||
|
- **Patterns:** Same `AbstractSyncProvider` interface. OAuth2 flow — user authorizes via browser, app stores refresh token, auto-refreshes access token. Graph API endpoints: `GET /me/calendarView/delta` for incremental pull, `POST /me/events` for create, `PATCH /me/events/{id}` for update.
|
||||||
|
- **Delta sync:** Use Graph API delta query (`$deltaToken` / `$skipToken`) for incremental changes — first call is full sync, subsequent calls are incremental.
|
||||||
|
- **OAuth flow:**
|
||||||
|
1. User clicks "Connect Outlook" in settings UI
|
||||||
|
2. Redirect to Microsoft login with `Calendars.ReadWrite` scope
|
||||||
|
3. Callback receives authorization code, exchanges for access + refresh token
|
||||||
|
4. Tokens stored in `ExternalCalendarConfig.credentials` (encrypted JSON)
|
||||||
|
5. On token expiry (401), auto-refresh using refresh token
|
||||||
|
- **Config:** `ExternalCalendarConfig` stores OAuth client_id, refresh_token, access_token (with expiry). Client ID configured in `agentkit.yaml`.
|
||||||
|
- **Test file:** `tests/unit/calendar/test_sync_outlook.py`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- `test_outlook_provider_pull_delta_initial_sync` — mock httpx returning 3 events, verify local events created
|
||||||
|
- `test_outlook_provider_pull_delta_incremental` — mock returning 1 new + 1 updated, verify only those processed
|
||||||
|
- `test_outlook_provider_push_creates_remote_event` — local event pushed, remote ID stored
|
||||||
|
- `test_outlook_provider_push_updates_remote_event` — local modification pushed
|
||||||
|
- `test_outlook_token_refresh_on_401` — mock 401 response, verify refresh token used, request retried
|
||||||
|
- `test_outlook_conflict_last_write_wins` — both sides modified, newer wins
|
||||||
|
- `test_outlook_rrule_roundtrip` — recurring event synced, pattern preserved
|
||||||
|
- `test_outlook_test_connection_success` — mock Graph /me returns user, True
|
||||||
|
- **Dependencies:** U2, U8 (ICS for RRULE conversion)
|
||||||
|
|
||||||
|
### U8. iCal/ICS import/export
|
||||||
|
|
||||||
|
- **Goal:** Import .ics files to local calendar, export local calendar to .ics file, using `icalendar` library.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/calendar/sync/ics_provider.py` (new — `ICSProvider`)
|
||||||
|
- **Patterns:** `icalendar.Calendar.from_ical(ics_bytes)` for import, `icalendar.Calendar()` + `add_component()` for export. Map VEVENT fields to `CalendarEvent` dataclass.
|
||||||
|
- **Import flow:** Parse ICS → for each VEVENT, create `CalendarEvent` (map SUMMARY→title, DTSTART→start_time, RRULE→rrule, etc.) → skip events with duplicate UID already imported.
|
||||||
|
- **Export flow:** Query events in date range → for each, create VEVENT component → assemble into Calendar → serialize to .ics bytes → return as downloadable file.
|
||||||
|
- **REST endpoints** (in `routes/calendar.py`):
|
||||||
|
- `POST /api/v1/calendar/import-ics` — upload .ics file, parse, create events
|
||||||
|
- `GET /api/v1/calendar/export-ics?start=...&end=...` — download .ics file
|
||||||
|
- **Test file:** `tests/unit/calendar/test_ics_provider.py`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- `test_import_simple_ics_creates_event` — single VEVENT ICS, verify event created with correct fields
|
||||||
|
- `test_import_recurring_ics_preserves_rrule` — VEVENT with RRULE, verify rrule field set
|
||||||
|
- `test_import_all_day_event` — DTSTART is date not datetime, verify is_all_day=True
|
||||||
|
- `test_import_skips_duplicate_uid` — import same ICS twice, second import creates no new events
|
||||||
|
- `test_export_produces_valid_ics` — create event, export, parse result with icalendar, verify roundtrip
|
||||||
|
- `test_export_includes_recurrence` — event with rrule, export, verify RRULE in ICS
|
||||||
|
- `test_export_date_range_filter` — 3 events, export range covering 2, verify only 2 in ICS
|
||||||
|
- `test_import_malformed_ics_raises_error` — invalid ICS bytes, verify graceful error
|
||||||
|
- **Dependencies:** U2
|
||||||
|
|
||||||
|
### U9. Frontend store, API client & types
|
||||||
|
|
||||||
|
- **Goal:** Pinia store, API client, and TypeScript types for calendar feature.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/server/frontend/src/api/calendar.ts` (new — `CalendarApiClient extends BaseApiClient`, types co-located)
|
||||||
|
- `src/agentkit/server/frontend/src/stores/calendar.ts` (new — `useCalendarStore` Composition API)
|
||||||
|
- `src/agentkit/server/frontend/src/api/types.ts` (modify — add `calendar_event_created`, `calendar_reminder`, `calendar_invitation`, `calendar_sync_conflict` to `WsServerMessage` union)
|
||||||
|
- **Patterns:** API client mirrors `src/agentkit/server/frontend/src/api/documents.ts` (extend `BaseApiClient`, singleton export, types co-located, runtime type guard `isCalendarEvent`). Store mirrors `src/agentkit/server/frontend/src/stores/settings.ts` (Composition API, `ref()` state, `computed()` getters, async actions with try/catch + `isLoading`).
|
||||||
|
- **API client methods:** `listEvents()`, `createEvent()`, `updateEvent()`, `deleteEvent()`, `listEventTypes()`, `createEventType()`, `listTags()`, `createTag()`, `importIcs()`, `exportIcs()`, `createInvitation()`, `respondToInvitation()`, `listInvitations()`, `searchUsers(q)` (G5/A3), `listExternalConfigs()`, `createExternalConfig()`, `testExternalConnection()`, `syncNow(configId)`.
|
||||||
|
- **Store state:** `events`, `eventTypes`, `tags`, `isLoading`, `error`, `selectedEvent`, `viewMode` ('calendar'|'card'|'list'), `dateRange`, `pendingInvitations`, `syncConflicts`.
|
||||||
|
- **Store actions:** `loadEvents()`, `createEvent()`, `updateEvent()`, `deleteEvent()`, `loadEventTypes()`, `loadTags()`, `setViewMode()`, `loadInvitations()`, `respondToInvitation()`, `searchUsers(q)`, `handleWsEvent()` (dispatch for `calendar_event_created`, `calendar_reminder`, `calendar_invitation`, `calendar_sync_conflict` WS messages).
|
||||||
|
- **WS integration:** `stores/chat.ts` `handleWsMessage()` — add `case 'calendar_event_created'`, `case 'calendar_reminder'`, `case 'calendar_invitation'`, `case 'calendar_sync_conflict'` branches that call into calendar store (lazy import pattern to avoid circular deps).
|
||||||
|
- **Test file:** `tests/unit/frontend/calendar/store.test.ts` (if frontend test infra exists; otherwise manual verification via `npm run typecheck`)
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Type check: `npm run typecheck` passes with new types
|
||||||
|
- `test_loadEvents_populates_state` — mock API, call loadEvents, verify events ref populated
|
||||||
|
- `test_createEvent_adds_to_state` — call createEvent, verify events array updated
|
||||||
|
- `test_handleWsEvent_calendar_event_created` — call handleWsEvent with calendar_event_created payload, verify event added to state
|
||||||
|
- `test_handleWsEvent_calendar_reminder` — call handleWsEvent with reminder payload, verify notification shown
|
||||||
|
- `test_handleWsEvent_calendar_invitation` — call handleWsEvent with invitation payload, verify pendingInvitations updated + notification shown (G6)
|
||||||
|
- `test_handleWsEvent_calendar_sync_conflict` — call handleWsEvent with conflict payload, verify syncConflicts updated + alert shown (G4)
|
||||||
|
- `test_searchUsers_returns_results` — call searchUsers("ali"), verify results populated (G5)
|
||||||
|
- `test_viewMode_switch` — setViewMode('card'), verify state
|
||||||
|
- **Dependencies:** U2 (API contract must exist)
|
||||||
|
|
||||||
|
### U10. Frontend calendar views (3 views + right panel tab + drawer)
|
||||||
|
|
||||||
|
- **Goal:** Three view modes (calendar grid / card / list), right panel tab integration (summary only), expandable drawer (full management), with event source distinction and invited-event styling.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue` (new — right panel tab, summary view only per KTD-12)
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue` (new — large drawer wrapper, full management per KTD-12)
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue` (new — FullCalendar wrapper)
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/CardView.vue` (new — kanban card view)
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/ListView.vue` (new — sorted list view)
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/EventBadge.vue` (new — source distinction badge/icon component — G3)
|
||||||
|
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` (modify — add `calendar` to `bottomRightTabs`)
|
||||||
|
- `src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue` (new — tab content wrapper)
|
||||||
|
- `package.json` (modify — add `@fullcalendar/vue3`, `@fullcalendar/daygrid`, `@fullcalendar/timegrid`, `@fullcalendar/interaction` deps)
|
||||||
|
- **Patterns:** Right panel tab follows existing `QuadrantPanel` tab pattern (add to `bottomRightTabs`, add named slot). Drawer follows `a-drawer` pattern from `SkillDetail.vue` (but wider, ~80% viewport). FullCalendar initialized in `CalendarGrid.vue` with Vue3 wrapper.
|
||||||
|
- **Event source distinction (G3, R15):** Each event displays a visual indicator of its origin:
|
||||||
|
- `manual` — no badge (default)
|
||||||
|
- `agent` — small robot icon (🤖 or `RobotOutlined`) in event card/grid item
|
||||||
|
- `post_extract` — small sparkle icon (✨ or `ThunderboltOutlined`) in event card/grid item
|
||||||
|
- `EventBadge.vue` component renders the appropriate icon based on `event.source` field, used in CalendarGrid/CardView/ListView
|
||||||
|
- **Invited event styling (G7, R33):** Events with `is_invited=True` render with a distinct visual treatment:
|
||||||
|
- Dashed border instead of solid
|
||||||
|
- Subtle background tint (e.g., light blue overlay)
|
||||||
|
- "受邀" tag displayed next to title
|
||||||
|
- Clicking an invited event opens InvitationManager (respond UI) instead of EventEditor
|
||||||
|
- **Timezone display (KTD-11):** All times received from API are UTC ISO 8601. Frontend converts to local timezone for display using `dayjs(utcTime).local()` or native `Intl.DateTimeFormat`. No user-facing timezone selector — browser local timezone is used.
|
||||||
|
- **CalendarPanel (right panel tab — summary only per KTD-12):**
|
||||||
|
- Shows today's events + next 3 upcoming events as compact list
|
||||||
|
- Each item shows time + title + source badge (G3)
|
||||||
|
- "Expand" button opens `CalendarDrawer`
|
||||||
|
- Visual highlight animation when new event created (via WS event trigger)
|
||||||
|
- Does NOT include view mode switcher, event editor, or full calendar grid — those live in the drawer
|
||||||
|
- **CalendarDrawer (large drawer — full management per KTD-12):**
|
||||||
|
- `a-drawer` with `width="80%"` and `placement="right"`
|
||||||
|
- View mode switcher (calendar/card/list) at top
|
||||||
|
- Contains `CalendarGrid`, `CardView`, or `ListView` based on selected mode
|
||||||
|
- "New Event" button opens `EventEditor` (U11)
|
||||||
|
- Invitation management accessible via tab/section (U11)
|
||||||
|
- **CalendarGrid (FullCalendar):**
|
||||||
|
- Month/Week/Day views via FullCalendar
|
||||||
|
- Drag-to-create (select time range → open EventEditor)
|
||||||
|
- Drag-to-move (drag event to new time → call updateEvent)
|
||||||
|
- Events colored by `event_type.color`
|
||||||
|
- Recurring events expanded by backend (U2 `list_events` with RRULE expansion) — FullCalendar receives pre-expanded occurrences
|
||||||
|
- Invited events (is_invited=True) styled with dashed border class (G7)
|
||||||
|
- Styled with Notion Calendar aesthetic: clean borders, ample whitespace, soft colors
|
||||||
|
- **CardView:**
|
||||||
|
- Kanban-style, grouped by date or event type (toggle)
|
||||||
|
- Cards draggable between groups (changes date or type)
|
||||||
|
- Each card shows source badge (G3) and invited styling (G7)
|
||||||
|
- **ListView:**
|
||||||
|
- Sorted by start_time, supports inline edit of title, batch select with checkbox
|
||||||
|
- Source column with badge (G3)
|
||||||
|
- **Wiring:** `AgentLayout.vue` — add `{ key: 'calendar', label: '日历', icon: CalendarOutlined }` to `bottomRightTabs`, add `<template #calendar><CalendarTab /></template>` slot.
|
||||||
|
- **Test file:** Manual verification + `npm run typecheck`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Type check passes
|
||||||
|
- Right panel shows calendar tab with today's events (summary only — no grid)
|
||||||
|
- Each event in panel shows correct source badge (manual=none, agent=robot, post_extract=sparkle) (G3)
|
||||||
|
- Clicking "Expand" opens drawer with 80% width
|
||||||
|
- Drawer contains view mode switcher + full calendar grid
|
||||||
|
- Calendar grid shows month view by default, can switch to week/day
|
||||||
|
- Drag-select time range opens event editor
|
||||||
|
- Drag event to new time updates event
|
||||||
|
- Invited events show dashed border + "受邀" tag in all views (G7)
|
||||||
|
- Clicking invited event opens invitation response UI, not editor (G7)
|
||||||
|
- Card view groups by date, cards draggable, source badge visible (G3)
|
||||||
|
- List view sorted by time, inline edit works, source column visible (G3)
|
||||||
|
- New event from Agent triggers highlight animation in panel
|
||||||
|
- Times displayed in browser local timezone (KTD-11)
|
||||||
|
- **Dependencies:** U9
|
||||||
|
|
||||||
|
### U11. Frontend event editor & management UI
|
||||||
|
|
||||||
|
- **Goal:** Event create/edit form, drag-and-drop rescheduling, batch operations, invitation management.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/EventEditor.vue` (new — `a-drawer` width 560)
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue` (new — invitation list + respond buttons)
|
||||||
|
- **Patterns:** Event editor follows `a-drawer` + form pattern from `SkillDetail.vue`. Form uses Ant Design Vue form components (`a-form`, `a-input`, `a-date-picker`, `a-time-picker`, `a-select`, `a-tag`).
|
||||||
|
- **EventEditor fields:**
|
||||||
|
- Title (a-input, required)
|
||||||
|
- Description (a-textarea)
|
||||||
|
- Date range (a-range-picker) + time (a-time-picker, hidden if all-day)
|
||||||
|
- All-day toggle (a-switch)
|
||||||
|
- Location (a-input)
|
||||||
|
- Event type (a-select, colored options)
|
||||||
|
- Tags (a-select mode="tags", user can create new)
|
||||||
|
- Recurrence (a-select: none/daily/weekly/monthly/custom + custom RRULE builder)
|
||||||
|
- Reminders (list of offset + channel selectors, add/remove)
|
||||||
|
- **Batch operations:** In ListView, checkbox multi-select → toolbar with "Delete", "Change Type", "Add Tag" buttons.
|
||||||
|
- **InvitationManager:** Shows list of invitees with status icons, "Invite" button opens user search dialog (calls `GET /api/v1/calendar/users/search?q=xxx` from U2 — G5/A3), invitee view shows accept/decline/tentative buttons. Also displays incoming invitations (received via `calendar_invitation` WS push from U2 — G6) with response buttons.
|
||||||
|
- **Test file:** Manual verification + `npm run typecheck`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Type check passes
|
||||||
|
- Create event with all fields, verify saved
|
||||||
|
- Edit existing event, verify changes persisted
|
||||||
|
- Set recurrence weekly, verify RRULE generated
|
||||||
|
- Add 2 reminders (15min client + 1day email), verify saved
|
||||||
|
- Batch select 3 events, delete, verify removed
|
||||||
|
- Batch select, change type, verify all updated
|
||||||
|
- Invite user by email, verify invitation created (G5)
|
||||||
|
- User search dialog returns results from `/api/v1/calendar/users/search` (G5)
|
||||||
|
- Invitation response (accept) updates status
|
||||||
|
- Incoming invitation notification displays when `calendar_invitation` WS received (G6)
|
||||||
|
- **Dependencies:** U9, U10
|
||||||
|
|
||||||
|
### U12. Frontend reminder & external sync settings UI
|
||||||
|
|
||||||
|
- **Goal:** Reminder notification display, external calendar connection settings, sync status.
|
||||||
|
- **Files:**
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/ReminderConfig.vue` (new — default reminder rules per event type)
|
||||||
|
- `src/agentkit/server/frontend/src/components/calendar/SyncSettings.vue` (new — external calendar config)
|
||||||
|
- **Patterns:** Settings panels follow existing settings component patterns. Notification display uses Ant Design Vue `a-notification` or `a-message` component.
|
||||||
|
- **ReminderConfig:**
|
||||||
|
- Per event type: list of default reminder rules (offset + channels)
|
||||||
|
- Add/remove rules, save to backend
|
||||||
|
- When new event created with this type, rules auto-inherited
|
||||||
|
- **SyncSettings:**
|
||||||
|
- List of configured external calendars (provider, status, last_sync)
|
||||||
|
- "Add Apple Calendar" → form for CalDAV URL + Apple ID + app-specific password
|
||||||
|
- "Add Outlook" → OAuth redirect button
|
||||||
|
- Per-provider: sync frequency, sync scope (which event types), "Sync Now" button, "Test Connection" button
|
||||||
|
- Conflict notifications displayed as alerts when `calendar_sync_conflict` WS message received (G4) — shows event title, provider, conflict details, and resolution taken (last-write-wins)
|
||||||
|
- **Notification display:**
|
||||||
|
- `calendar_reminder` WS message → `a-notification.warning()` with event title + time
|
||||||
|
- `calendar_invitation` WS message → `a-notification.info()` with inviter name + event title, link to respond (G6)
|
||||||
|
- `calendar_sync_conflict` WS message → `a-alert` in SyncSettings panel showing conflict details (G4)
|
||||||
|
- Tauri mode: also trigger system notification via Tauri notification API
|
||||||
|
- **Test file:** Manual verification + `npm run typecheck`
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Type check passes
|
||||||
|
- Add default reminder rule to "会议" type, verify saved
|
||||||
|
- Create event of type "会议", verify reminder inherited
|
||||||
|
- Add Apple Calendar config, test connection (mock success)
|
||||||
|
- Add Outlook config via OAuth (mock redirect)
|
||||||
|
- "Sync Now" triggers sync, status updates
|
||||||
|
- Reminder WS message displays notification
|
||||||
|
- Invitation WS message displays notification with respond link (G6)
|
||||||
|
- Conflict WS message displays alert in SyncSettings (G4)
|
||||||
|
- **Dependencies:** U9, U10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Flows
|
||||||
|
|
||||||
|
- F1. Agent tool-call creates event
|
||||||
|
- **Trigger:** Agent identifies schedule in ReAct loop
|
||||||
|
- **Actors:** A2 (Agent), A1 (User)
|
||||||
|
- **Steps:** Agent calls `CalendarTool.execute(action="create_event", ...)` → service writes to DB with `source="agent"` → WS push `calendar_event_created` to client → right panel highlights new event → Agent tells user "已创建日程"
|
||||||
|
- **Covers:** R10, R13, R37
|
||||||
|
|
||||||
|
- F2. Post-processing extraction (keyword-gated)
|
||||||
|
- **Trigger:** Conversation turn ends, assistant reply persisted
|
||||||
|
- **Actors:** A2 (post-processing layer), A1 (User)
|
||||||
|
- **Steps:** `asyncio.create_task(extractor.extract(...))` → regex keyword scan → if no match, return (zero LLM) → if match, LLM extraction → parse JSON → create events with `source="post_extract"` → WS push → insert "检测到 N 条日程" hint in chat
|
||||||
|
- **Covers:** R11, R12, R14
|
||||||
|
|
||||||
|
- F3. External calendar sync
|
||||||
|
- **Trigger:** Scheduled sync task (every N minutes per provider config, run by `SyncManager`) or manual "Sync Now"
|
||||||
|
- **Actors:** A3 (external service), A1 (User)
|
||||||
|
- **Steps:** `SyncManager.sync(provider)` → `provider.pull_changes()` (fetch remote, match by external_id, create/update local) → `provider.push_changes()` (push local changes to remote) → conflict detection (last_modified comparison) → last-write-wins → `calendar_sync_conflict` WS notification to user (G4)
|
||||||
|
- **Covers:** R20, R21, R23
|
||||||
|
|
||||||
|
- F4. Reminder dispatch
|
||||||
|
- **Trigger:** Scheduler loop (every 60s) finds event entering reminder window
|
||||||
|
- **Actors:** A4 (reminder subsystem), A1 (User)
|
||||||
|
- **Steps:** Query events where `start_time + offset_minutes` in next 60s → check idempotency (delivery exists?) → create `ReminderDelivery` records → dispatch per channel (client WS / email SMTP / webhook HTTP) → update delivery status → retry on failure (up to 3x)
|
||||||
|
- **Covers:** R25, R26, R27, R29
|
||||||
|
|
||||||
|
- F5. Manual event creation
|
||||||
|
- **Trigger:** User clicks "New" or drag-selects time range in calendar view
|
||||||
|
- **Actors:** A1 (User)
|
||||||
|
- **Steps:** Open `EventEditor` drawer → fill form (title/time/type/tags/reminders) → save → `calendarApi.createEvent()` → store updates → event appears in view → if external sync configured, async push to external calendar
|
||||||
|
- **Covers:** R16, R18
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### New Python dependencies (add to `pyproject.toml`)
|
||||||
|
|
||||||
|
| Package | Version | Used by |
|
||||||
|
|---|---|---|
|
||||||
|
| `python-dateutil>=2.9` | latest | U1 (RRULE expansion) |
|
||||||
|
| `icalendar>=6.0` | latest | U6, U7, U8 (ICS format) |
|
||||||
|
| `caldav>=1.3` | latest | U6 (Apple Calendar sync) |
|
||||||
|
| `aiosmtplib>=3.0` | latest | U5 (email reminder channel) |
|
||||||
|
|
||||||
|
### New Node dependencies (add to `package.json`)
|
||||||
|
|
||||||
|
| Package | Used by |
|
||||||
|
|---|---|
|
||||||
|
| `@fullcalendar/vue3` | U10 (calendar grid) |
|
||||||
|
| `@fullcalendar/daygrid` | U10 (month view) |
|
||||||
|
| `@fullcalendar/timegrid` | U10 (week/day view) |
|
||||||
|
| `@fullcalendar/interaction` | U10 (drag-and-drop) |
|
||||||
|
|
||||||
|
### Existing dependencies reused
|
||||||
|
|
||||||
|
- `httpx>=0.27` — Outlook Graph API calls (U7), webhook reminders (U5)
|
||||||
|
- `aiosqlite>=0.20` — local storage (U1)
|
||||||
|
- `pydantic>=2.0` — request/response models (U2)
|
||||||
|
- `fastapi>=0.110` — REST routes (U2)
|
||||||
|
- `redis>=5.0` — optional cache for sync tokens (U6, U7)
|
||||||
|
|
||||||
|
### Sequencing
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Foundation): U1 → U2
|
||||||
|
Phase 2 (Agent): U3, U4 (parallel, both depend on U2)
|
||||||
|
Phase 3 (Reminders): U5 (depends on U1, U2)
|
||||||
|
Phase 4 (External sync): U8 → U6, U7 (U6/U7 parallel, both depend on U8)
|
||||||
|
Phase 5 (Frontend base): U9 (depends on U2 API contract)
|
||||||
|
Phase 6 (Frontend UI): U10 → U11, U12 (U11/U12 parallel, both depend on U10)
|
||||||
|
```
|
||||||
|
|
||||||
|
Units within the same phase can be developed in parallel. Phases are sequential — each phase's units depend on the prior phase's output.
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| FullCalendar bundle size impacts frontend load | Medium | Lazy-load calendar components (`defineAsyncComponent`), only load when tab activated |
|
||||||
|
| CalDAV iCloud requires app-specific password (user friction) | Medium | Document setup steps in UI, provide "How to get app-specific password" link |
|
||||||
|
| Outlook OAuth flow complexity in Tauri desktop mode | Medium | Use system browser for OAuth (not embedded webview), redirect to localhost callback |
|
||||||
|
| Post-processing LLM extraction adds latency/cost | Medium | Keyword gate ensures LLM only called when time keywords present; extraction is async, doesn't block chat |
|
||||||
|
| RRULE edge cases (DST, timezone, Feb 29) | Low | `dateutil.rrule` handles RFC 5545 edge cases; store all times in UTC, convert at display |
|
||||||
|
| Sync conflict data loss | High | Last-write-wins with conflict notification; never auto-delete, always log |
|
||||||
|
| Reminder scheduler misses events if server restarts | Medium | On startup, scan for events in reminder window that have no delivery records and dispatch them |
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### In scope (this plan)
|
||||||
|
|
||||||
|
- Personal calendar with manual + Agent + post-processing event creation
|
||||||
|
- Three view modes (calendar grid, card, list)
|
||||||
|
- Apple Calendar (CalDAV), Outlook (Graph API), iCal/ICS bidirectional sync
|
||||||
|
- Multi-channel reminder subsystem (client push, email, webhook)
|
||||||
|
- Event invitations with accept/decline/tentative
|
||||||
|
- Right panel tab + expandable drawer UX
|
||||||
|
|
||||||
|
### Deferred for later
|
||||||
|
|
||||||
|
- Google Calendar integration (not selected by user, add CalDAV/Google API provider later)
|
||||||
|
- Team shared calendars (multi-user co-editing same calendar)
|
||||||
|
- Calendar analytics/reports (event statistics, time distribution)
|
||||||
|
- Resource booking (meeting rooms, equipment)
|
||||||
|
- Mobile native app (current is Web/Tauri only)
|
||||||
|
- Calendar subscription (read-only iCal feed URL for others to subscribe)
|
||||||
|
|
||||||
|
### Outside this product's identity
|
||||||
|
|
||||||
|
- Replacing professional calendar applications (Google Calendar, Notion Calendar) — AgentKit calendar's differentiation is Agent auto-detection, not full-featured calendar competition
|
||||||
|
- Project management (task tracking, Gantt charts, dependency management)
|
||||||
|
|
||||||
|
## Implementation History
|
||||||
|
|
||||||
|
- **U1-U12 实现完成** (commits `2ea799f`..`394d734`): 12 个实现单元全部交付,104 个单元测试通过,ruff clean,frontend typecheck clean。
|
||||||
|
- **Intent router 测试修复** (commit `4ea7801`): 修复预存测试失败——关键词匹配平局应保持列表顺序而非字母序。
|
||||||
|
- **代码走查修复** (commit `3fdee65`): 使用 ce-code-review skill 多代理审查,发现 23 个问题(2 Critical / 15 Major / 6 Minor),全部修复。关键修复包括:RRULE 无限循环防护、邀请/事件类型授权校验、SQLite 外键 PRAGMA、DTSTART 时区 bug、同步循环 since 重置、CalDAV 超时、Outlook token 过期检测、Webhook SSRF 防护、ICS 导入 DoS 限制。
|
||||||
|
|
@ -24,6 +24,12 @@ dependencies = [
|
||||||
"pyjwt>=2.8",
|
"pyjwt>=2.8",
|
||||||
"bcrypt>=4.0",
|
"bcrypt>=4.0",
|
||||||
"aiosqlite>=0.20",
|
"aiosqlite>=0.20",
|
||||||
|
# Calendar & schedule (RRULE expansion)
|
||||||
|
"python-dateutil>=2.9",
|
||||||
|
# Calendar ICS import/export (U8)
|
||||||
|
"icalendar>=5.0",
|
||||||
|
# Calendar CalDAV sync — Apple Calendar (U6)
|
||||||
|
"caldav>=1.3",
|
||||||
# Document processing (U1-U9)
|
# Document processing (U1-U9)
|
||||||
"python-docx>=1.1",
|
"python-docx>=1.1",
|
||||||
"openpyxl>=3.1",
|
"openpyxl>=3.1",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Calendar & schedule subsystem.
|
||||||
|
|
||||||
|
Mirrors the ``documents/`` structure: ``models.py`` (dataclass DTOs),
|
||||||
|
``db.py`` (aiosqlite persistence), ``service.py`` (business logic),
|
||||||
|
``recurrence.py`` (RRULE expansion), ``scheduler.py`` (reminder loop),
|
||||||
|
``extraction.py`` (post-processing), and ``sync/`` (external calendars).
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,944 @@
|
||||||
|
"""SQLite persistence for calendar data.
|
||||||
|
|
||||||
|
Follows the aiosqlite bare-connection pattern from ``documents/db.py``:
|
||||||
|
no SQLAlchemy session injection, just ``async with aiosqlite.connect(...)``.
|
||||||
|
All timestamps are ISO 8601 UTC (see KTD-11).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from agentkit.calendar.models import (
|
||||||
|
CalendarEvent,
|
||||||
|
EventType,
|
||||||
|
ExternalCalendarConfig,
|
||||||
|
Invitation,
|
||||||
|
ReminderDelivery,
|
||||||
|
ReminderRule,
|
||||||
|
Tag,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PROJECT_ROOT = Path(__file__).parents[3]
|
||||||
|
DEFAULT_CALENDAR_DB_PATH = Path(
|
||||||
|
os.environ.get("AGENTKIT_CALENDAR_DB", _PROJECT_ROOT / "data" / "calendar.db")
|
||||||
|
)
|
||||||
|
|
||||||
|
_SCHEMA_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_event_types (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#4A90D9',
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_types_user
|
||||||
|
ON calendar_event_types(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_tags (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tags_user
|
||||||
|
ON calendar_tags(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
is_all_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
location TEXT NOT NULL DEFAULT '',
|
||||||
|
event_type_id TEXT,
|
||||||
|
rrule TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
is_invited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
conversation_id TEXT,
|
||||||
|
external_id TEXT,
|
||||||
|
external_provider TEXT,
|
||||||
|
last_modified TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_user
|
||||||
|
ON calendar_events(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_start_time
|
||||||
|
ON calendar_events(start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_external
|
||||||
|
ON calendar_events(external_id, external_provider);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_event_tags (
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
tag_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (event_id, tag_id),
|
||||||
|
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES calendar_tags(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_reminder_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT,
|
||||||
|
event_type_id TEXT,
|
||||||
|
offset_minutes INTEGER NOT NULL DEFAULT -15,
|
||||||
|
channels TEXT NOT NULL DEFAULT '["client"]',
|
||||||
|
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_reminder_deliveries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
reminder_rule_id TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
scheduled_time TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
channel TEXT NOT NULL DEFAULT 'client',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
-- ponytail: ON DELETE CASCADE ensures deliveries are removed when their
|
||||||
|
-- reminder_rule is cascade-deleted (e.g. event deletion). Existing DBs
|
||||||
|
-- created before this change need ALTER TABLE or DB recreation.
|
||||||
|
FOREIGN KEY (reminder_rule_id) REFERENCES calendar_reminder_rules(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deliveries_status
|
||||||
|
ON calendar_reminder_deliveries(status, scheduled_time);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_external_configs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
credentials TEXT NOT NULL DEFAULT '',
|
||||||
|
sync_frequency INTEGER NOT NULL DEFAULT 30,
|
||||||
|
sync_scope TEXT NOT NULL DEFAULT '[]',
|
||||||
|
last_sync TEXT,
|
||||||
|
sync_token TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_external_configs_user
|
||||||
|
ON calendar_external_configs(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_invitations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
inviter_user_id TEXT NOT NULL,
|
||||||
|
invitee_email TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
responded_at TEXT,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_email
|
||||||
|
ON calendar_invitations(invitee_email, status);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def init_calendar_db(db_path: str | Path | None = None) -> Path:
|
||||||
|
"""Create all calendar tables if they do not exist. Idempotent."""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.executescript(_SCHEMA_SQL)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Calendar DB initialized at {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Row → dataclass converters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_event_type(row: aiosqlite.Row | Mapping[str, object]) -> EventType:
|
||||||
|
return EventType(
|
||||||
|
id=row["id"],
|
||||||
|
user_id=row["user_id"],
|
||||||
|
name=row["name"],
|
||||||
|
color=row["color"],
|
||||||
|
is_default=bool(row["is_default"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_tag(row: aiosqlite.Row | Mapping[str, object]) -> Tag:
|
||||||
|
return Tag(id=row["id"], user_id=row["user_id"], name=row["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_event(row: aiosqlite.Row | Mapping[str, object]) -> CalendarEvent:
|
||||||
|
return CalendarEvent(
|
||||||
|
id=row["id"],
|
||||||
|
user_id=row["user_id"],
|
||||||
|
title=row["title"],
|
||||||
|
description=row["description"],
|
||||||
|
start_time=row["start_time"],
|
||||||
|
end_time=row["end_time"],
|
||||||
|
is_all_day=bool(row["is_all_day"]),
|
||||||
|
location=row["location"],
|
||||||
|
event_type_id=row["event_type_id"],
|
||||||
|
rrule=row["rrule"],
|
||||||
|
source=row["source"],
|
||||||
|
is_invited=bool(row["is_invited"]),
|
||||||
|
conversation_id=row["conversation_id"],
|
||||||
|
external_id=row["external_id"],
|
||||||
|
external_provider=row["external_provider"],
|
||||||
|
last_modified=row["last_modified"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_reminder_rule(row: aiosqlite.Row | Mapping[str, object]) -> ReminderRule:
|
||||||
|
return ReminderRule(
|
||||||
|
id=row["id"],
|
||||||
|
event_id=row["event_id"],
|
||||||
|
event_type_id=row["event_type_id"],
|
||||||
|
offset_minutes=row["offset_minutes"],
|
||||||
|
channels=json.loads(row["channels"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_reminder_delivery(row: aiosqlite.Row | Mapping[str, object]) -> ReminderDelivery:
|
||||||
|
return ReminderDelivery(
|
||||||
|
id=row["id"],
|
||||||
|
reminder_rule_id=row["reminder_rule_id"],
|
||||||
|
event_id=row["event_id"],
|
||||||
|
scheduled_time=row["scheduled_time"],
|
||||||
|
status=row["status"],
|
||||||
|
channel=row["channel"],
|
||||||
|
attempts=row["attempts"],
|
||||||
|
last_error=row["last_error"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_external_config(row: aiosqlite.Row | Mapping[str, object]) -> ExternalCalendarConfig:
|
||||||
|
return ExternalCalendarConfig(
|
||||||
|
id=row["id"],
|
||||||
|
user_id=row["user_id"],
|
||||||
|
provider=row["provider"],
|
||||||
|
credentials=row["credentials"],
|
||||||
|
sync_frequency=row["sync_frequency"],
|
||||||
|
sync_scope=json.loads(row["sync_scope"]),
|
||||||
|
last_sync=row["last_sync"],
|
||||||
|
sync_token=row["sync_token"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_invitation(row: aiosqlite.Row | Mapping[str, object]) -> Invitation:
|
||||||
|
return Invitation(
|
||||||
|
id=row["id"],
|
||||||
|
event_id=row["event_id"],
|
||||||
|
inviter_user_id=row["inviter_user_id"],
|
||||||
|
invitee_email=row["invitee_email"],
|
||||||
|
status=row["status"],
|
||||||
|
responded_at=row["responded_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_event(event: CalendarEvent, db_path: str | Path | None = None) -> None:
|
||||||
|
"""Insert a calendar event."""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO calendar_events (id, user_id, title, description, "
|
||||||
|
"start_time, end_time, is_all_day, location, event_type_id, rrule, "
|
||||||
|
"source, is_invited, conversation_id, external_id, external_provider, "
|
||||||
|
"last_modified, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
event.id,
|
||||||
|
event.user_id,
|
||||||
|
event.title,
|
||||||
|
event.description,
|
||||||
|
event.start_time,
|
||||||
|
event.end_time,
|
||||||
|
int(event.is_all_day),
|
||||||
|
event.location,
|
||||||
|
event.event_type_id,
|
||||||
|
event.rrule,
|
||||||
|
event.source,
|
||||||
|
int(event.is_invited),
|
||||||
|
event.conversation_id,
|
||||||
|
event.external_id,
|
||||||
|
event.external_provider,
|
||||||
|
event.last_modified,
|
||||||
|
event.created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event(event_id: str, db_path: str | Path | None = None) -> CalendarEvent | None:
|
||||||
|
"""Return a single event by id, or None."""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT * FROM calendar_events WHERE id = ?", (event_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return _row_to_event(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event_by_external_id(
|
||||||
|
external_id: str,
|
||||||
|
external_provider: str,
|
||||||
|
user_id: str,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
) -> CalendarEvent | None:
|
||||||
|
"""Return a single event by (external_id, provider, user_id), or None.
|
||||||
|
|
||||||
|
Used by ICS import (U8) to skip duplicate UIDs already imported.
|
||||||
|
"""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_events "
|
||||||
|
"WHERE external_id = ? AND external_provider = ? AND user_id = ?",
|
||||||
|
(external_id, external_provider, user_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return _row_to_event(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_events(
|
||||||
|
user_id: str,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
event_type_id: str | None = None,
|
||||||
|
tag_id: str | None = None,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""List events for a user with optional filters."""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
query = "SELECT DISTINCT e.* FROM calendar_events e"
|
||||||
|
params: list[str] = []
|
||||||
|
conditions: list[str] = ["e.user_id = ?"]
|
||||||
|
params.append(user_id)
|
||||||
|
|
||||||
|
if start is not None:
|
||||||
|
conditions.append("e.start_time >= ?")
|
||||||
|
params.append(start)
|
||||||
|
if end is not None:
|
||||||
|
conditions.append("e.start_time < ?")
|
||||||
|
params.append(end)
|
||||||
|
if event_type_id is not None:
|
||||||
|
conditions.append("e.event_type_id = ?")
|
||||||
|
params.append(event_type_id)
|
||||||
|
if tag_id is not None:
|
||||||
|
query += " JOIN calendar_event_tags et ON et.event_id = e.id"
|
||||||
|
conditions.append("et.tag_id = ?")
|
||||||
|
params.append(tag_id)
|
||||||
|
|
||||||
|
query += " WHERE " + " AND ".join(conditions) + " ORDER BY e.start_time"
|
||||||
|
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(query, tuple(params))
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_event(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all_events_in_time_range(
|
||||||
|
start: str, end: str, db_path: str | Path | None = None
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""List all events (across all users) with start_time in [start, end).
|
||||||
|
|
||||||
|
Used by ReminderScheduler to scan for events entering the reminder window.
|
||||||
|
"""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_events WHERE start_time >= ? AND start_time < ? "
|
||||||
|
"ORDER BY start_time",
|
||||||
|
(start, end),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_event(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_event(
|
||||||
|
event_id: str, fields: dict[str, object], db_path: str | Path | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Update specific fields of an event. Returns True if a row was updated."""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
# Map dataclass field names to column names, handle bool → int
|
||||||
|
column_map = {
|
||||||
|
"title": "title",
|
||||||
|
"description": "description",
|
||||||
|
"start_time": "start_time",
|
||||||
|
"end_time": "end_time",
|
||||||
|
"is_all_day": "is_all_day",
|
||||||
|
"location": "location",
|
||||||
|
"event_type_id": "event_type_id",
|
||||||
|
"rrule": "rrule",
|
||||||
|
"source": "source",
|
||||||
|
"is_invited": "is_invited",
|
||||||
|
"conversation_id": "conversation_id",
|
||||||
|
"external_id": "external_id",
|
||||||
|
"external_provider": "external_provider",
|
||||||
|
"last_modified": "last_modified",
|
||||||
|
}
|
||||||
|
set_clauses: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
for field_name, value in fields.items():
|
||||||
|
col = column_map.get(field_name)
|
||||||
|
if col is None:
|
||||||
|
continue
|
||||||
|
if field_name in ("is_all_day", "is_invited"):
|
||||||
|
value = int(bool(value))
|
||||||
|
set_clauses.append(f"{col} = ?")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
if not set_clauses:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params.append(event_id)
|
||||||
|
sql = f"UPDATE calendar_events SET {', '.join(set_clauses)} WHERE id = ?"
|
||||||
|
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = await db.execute(sql, tuple(params))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_event(event_id: str, db_path: str | Path | None = None) -> bool:
|
||||||
|
"""Delete an event and its dependent rows. Returns True if deleted."""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
# Manual cascade for event_tags (no ON DELETE on junction FK in some SQLite versions)
|
||||||
|
await db.execute("DELETE FROM calendar_event_tags WHERE event_id = ?", (event_id,))
|
||||||
|
cursor = await db.execute("DELETE FROM calendar_events WHERE id = ?", (event_id,))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event Type CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_event_type(et: EventType, db_path: str | Path | None = None) -> None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO calendar_event_types (id, user_id, name, color, is_default) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(et.id, et.user_id, et.name, et.color, int(et.is_default)),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_event_types(user_id: str, db_path: str | Path | None = None) -> list[EventType]:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_event_types WHERE user_id = ? ORDER BY name",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_event_type(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_event_type(
|
||||||
|
type_id: str, fields: dict[str, object], db_path: str | Path | None = None
|
||||||
|
) -> bool:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
set_clauses: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
for field_name, value in fields.items():
|
||||||
|
if field_name == "name":
|
||||||
|
set_clauses.append("name = ?")
|
||||||
|
params.append(value)
|
||||||
|
elif field_name == "color":
|
||||||
|
set_clauses.append("color = ?")
|
||||||
|
params.append(value)
|
||||||
|
elif field_name == "is_default":
|
||||||
|
set_clauses.append("is_default = ?")
|
||||||
|
params.append(int(bool(value)))
|
||||||
|
if not set_clauses:
|
||||||
|
return False
|
||||||
|
params.append(type_id)
|
||||||
|
sql = f"UPDATE calendar_event_types SET {', '.join(set_clauses)} WHERE id = ?"
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = await db.execute(sql, tuple(params))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_event_type(type_id: str, db_path: str | Path | None = None) -> bool:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = await db.execute("DELETE FROM calendar_event_types WHERE id = ?", (type_id,))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tag CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_tag(tag: Tag, db_path: str | Path | None = None) -> None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO calendar_tags (id, user_id, name) VALUES (?, ?, ?)",
|
||||||
|
(tag.id, tag.user_id, tag.name),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tags(user_id: str, db_path: str | Path | None = None) -> list[Tag]:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_tags WHERE user_id = ? ORDER BY name",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_tag(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_tag(tag_id: str, db_path: str | Path | None = None) -> bool:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute("DELETE FROM calendar_event_tags WHERE tag_id = ?", (tag_id,))
|
||||||
|
cursor = await db.execute("DELETE FROM calendar_tags WHERE id = ?", (tag_id,))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event-Tag junction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def add_tag_to_event(event_id: str, tag_id: str, db_path: str | Path | None = None) -> None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR IGNORE INTO calendar_event_tags (event_id, tag_id) VALUES (?, ?)",
|
||||||
|
(event_id, tag_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_tag_from_event(
|
||||||
|
event_id: str, tag_id: str, db_path: str | Path | None = None
|
||||||
|
) -> None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM calendar_event_tags WHERE event_id = ? AND tag_id = ?",
|
||||||
|
(event_id, tag_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event_tags(event_id: str, db_path: str | Path | None = None) -> list[Tag]:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT t.* FROM calendar_tags t "
|
||||||
|
"JOIN calendar_event_tags et ON et.tag_id = t.id "
|
||||||
|
"WHERE et.event_id = ?",
|
||||||
|
(event_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_tag(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reminder Rule CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_reminder_rule(rule: ReminderRule, db_path: str | Path | None = None) -> None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO calendar_reminder_rules "
|
||||||
|
"(id, event_id, event_type_id, offset_minutes, channels) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
rule.id,
|
||||||
|
rule.event_id,
|
||||||
|
rule.event_type_id,
|
||||||
|
rule.offset_minutes,
|
||||||
|
json.dumps(rule.channels),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_reminder_rules_for_event(
|
||||||
|
event_id: str, db_path: str | Path | None = None
|
||||||
|
) -> list[ReminderRule]:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_reminder_rules WHERE event_id = ?",
|
||||||
|
(event_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_reminder_rule(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_reminder_rules_for_type(
|
||||||
|
event_type_id: str, db_path: str | Path | None = None
|
||||||
|
) -> list[ReminderRule]:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_reminder_rules WHERE event_type_id = ?",
|
||||||
|
(event_type_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_reminder_rule(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_reminder_rule(rule_id: str, db_path: str | Path | None = None) -> bool:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = await db.execute("DELETE FROM calendar_reminder_rules WHERE id = ?", (rule_id,))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reminder Delivery CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_reminder_delivery(
|
||||||
|
delivery: ReminderDelivery, db_path: str | Path | None = None
|
||||||
|
) -> None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO calendar_reminder_deliveries "
|
||||||
|
"(id, reminder_rule_id, event_id, scheduled_time, status, channel, "
|
||||||
|
"attempts, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
delivery.id,
|
||||||
|
delivery.reminder_rule_id,
|
||||||
|
delivery.event_id,
|
||||||
|
delivery.scheduled_time,
|
||||||
|
delivery.status,
|
||||||
|
delivery.channel,
|
||||||
|
delivery.attempts,
|
||||||
|
delivery.last_error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_deliveries(
|
||||||
|
event_id: str,
|
||||||
|
reminder_rule_id: str,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
status: str = "sent",
|
||||||
|
) -> list[ReminderDelivery]:
|
||||||
|
"""Check idempotency — return existing deliveries for an event+rule.
|
||||||
|
|
||||||
|
By default only ``sent`` deliveries are returned, so the scheduler's
|
||||||
|
idempotency check skips rules that already succeeded. Stuck ``pending``
|
||||||
|
or ``failed`` deliveries are ignored, allowing retry on the next scan.
|
||||||
|
"""
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_reminder_deliveries "
|
||||||
|
"WHERE event_id = ? AND reminder_rule_id = ? AND status = ?",
|
||||||
|
(event_id, reminder_rule_id, status),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_reminder_delivery(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_delivery_status(
|
||||||
|
delivery_id: str,
|
||||||
|
status: str,
|
||||||
|
last_error: str | None = None,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
) -> bool:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = await db.execute(
|
||||||
|
"UPDATE calendar_reminder_deliveries "
|
||||||
|
"SET status = ?, attempts = attempts + 1, last_error = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(status, last_error, delivery_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# External Calendar Config CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_external_config(
|
||||||
|
config: ExternalCalendarConfig, db_path: str | Path | None = None
|
||||||
|
) -> None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO calendar_external_configs "
|
||||||
|
"(id, user_id, provider, credentials, sync_frequency, sync_scope, "
|
||||||
|
"last_sync, sync_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
config.id,
|
||||||
|
config.user_id,
|
||||||
|
config.provider,
|
||||||
|
config.credentials,
|
||||||
|
config.sync_frequency,
|
||||||
|
json.dumps(config.sync_scope),
|
||||||
|
config.last_sync,
|
||||||
|
config.sync_token,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_external_configs(
|
||||||
|
user_id: str, db_path: str | Path | None = None
|
||||||
|
) -> list[ExternalCalendarConfig]:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_external_configs WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_external_config(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_external_config(
|
||||||
|
config_id: str, fields: dict[str, object], db_path: str | Path | None = None
|
||||||
|
) -> bool:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
set_clauses: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
for field_name, value in fields.items():
|
||||||
|
if field_name == "credentials":
|
||||||
|
set_clauses.append("credentials = ?")
|
||||||
|
params.append(value)
|
||||||
|
elif field_name == "sync_frequency":
|
||||||
|
set_clauses.append("sync_frequency = ?")
|
||||||
|
params.append(value)
|
||||||
|
elif field_name == "sync_scope":
|
||||||
|
set_clauses.append("sync_scope = ?")
|
||||||
|
params.append(json.dumps(value))
|
||||||
|
elif field_name == "last_sync":
|
||||||
|
set_clauses.append("last_sync = ?")
|
||||||
|
params.append(value)
|
||||||
|
elif field_name == "sync_token":
|
||||||
|
set_clauses.append("sync_token = ?")
|
||||||
|
params.append(value)
|
||||||
|
if not set_clauses:
|
||||||
|
return False
|
||||||
|
params.append(config_id)
|
||||||
|
sql = f"UPDATE calendar_external_configs SET {', '.join(set_clauses)} WHERE id = ?"
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = await db.execute(sql, tuple(params))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_external_config(config_id: str, db_path: str | Path | None = None) -> bool:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM calendar_external_configs WHERE id = ?", (config_id,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invitation CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_invitation(invitation: Invitation, db_path: str | Path | None = None) -> None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO calendar_invitations "
|
||||||
|
"(id, event_id, inviter_user_id, invitee_email, status, responded_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
invitation.id,
|
||||||
|
invitation.event_id,
|
||||||
|
invitation.inviter_user_id,
|
||||||
|
invitation.invitee_email,
|
||||||
|
invitation.status,
|
||||||
|
invitation.responded_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invitation(
|
||||||
|
invitation_id: str, db_path: str | Path | None = None
|
||||||
|
) -> Invitation | None:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_invitations WHERE id = ?", (invitation_id,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return _row_to_invitation(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_invitations(
|
||||||
|
invitee_email: str, db_path: str | Path | None = None
|
||||||
|
) -> list[Invitation]:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_invitations WHERE invitee_email = ? ORDER BY responded_at DESC",
|
||||||
|
(invitee_email,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_invitation(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_invitation_status(
|
||||||
|
invitation_id: str,
|
||||||
|
status: str,
|
||||||
|
responded_at: str,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
) -> bool:
|
||||||
|
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
async with aiosqlite.connect(str(path)) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
await db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = await db.execute(
|
||||||
|
"UPDATE calendar_invitations SET status = ?, responded_at = ? WHERE id = ?",
|
||||||
|
(status, responded_at, invitation_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""Post-processing extraction of schedule info from conversation text.
|
||||||
|
|
||||||
|
Two-stage approach (U4):
|
||||||
|
1. Zero-LLM regex keyword gate — skip LLM entirely if no time-related keywords.
|
||||||
|
2. LLM extraction — call the LLM gateway to pull structured event data.
|
||||||
|
|
||||||
|
Extracted events are persisted via ``CalendarService.create_event`` with
|
||||||
|
``source="post_extract"`` and the originating ``conversation_id`` for
|
||||||
|
traceability (R15).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PostProcessingExtractor:
|
||||||
|
"""Extract schedule info from conversation text after a chat turn.
|
||||||
|
|
||||||
|
Two-stage: regex keyword gate (zero LLM) → LLM extraction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Time-related keywords that trigger LLM extraction
|
||||||
|
_KEYWORD_RE = re.compile(
|
||||||
|
r"明天|后天|下周|本周|今天下午|今天上午|上午|下午|晚上|"
|
||||||
|
r"\d+点|\d+月\d+日|\d+号|开会|截止|deadline|schedule|"
|
||||||
|
r"reminder|提醒|预约|约定|安排",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, calendar_service: CalendarService, llm_gateway=None):
|
||||||
|
self.service = calendar_service
|
||||||
|
self.llm_gateway = llm_gateway # Optional, may be set later
|
||||||
|
|
||||||
|
async def extract(
|
||||||
|
self,
|
||||||
|
conversation_text: str,
|
||||||
|
conversation_id: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Extract events from conversation text.
|
||||||
|
|
||||||
|
Returns list of created event dicts. Empty if no keywords or no events extracted.
|
||||||
|
Never raises — all failures are logged and swallowed.
|
||||||
|
"""
|
||||||
|
# 1. Keyword gate — zero LLM cost if no match
|
||||||
|
if not self._KEYWORD_RE.search(conversation_text):
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. LLM extraction
|
||||||
|
events_data = await self._llm_extract(conversation_text)
|
||||||
|
if not events_data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 3. Create events with source="post_extract"
|
||||||
|
created = []
|
||||||
|
for event_data in events_data:
|
||||||
|
try:
|
||||||
|
event = await self.service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title=event_data.get("title", ""),
|
||||||
|
start_time=event_data.get("start_time", ""),
|
||||||
|
end_time=event_data.get("end_time", ""),
|
||||||
|
description=event_data.get("description", ""),
|
||||||
|
source="post_extract",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
created.append(event.to_dict())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to create extracted event: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
async def _llm_extract(self, text: str) -> list[dict]:
|
||||||
|
"""Call LLM gateway to extract events from text.
|
||||||
|
|
||||||
|
Returns list of event dicts: [{title, start_time, end_time, description}].
|
||||||
|
Returns [] on any error or empty result.
|
||||||
|
"""
|
||||||
|
if self.llm_gateway is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
prompt = self._build_extraction_prompt(text)
|
||||||
|
try:
|
||||||
|
response = await self.llm_gateway.acomplete(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
temperature=0.1,
|
||||||
|
)
|
||||||
|
return self._parse_llm_response(response)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LLM extraction failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _build_extraction_prompt(self, text: str) -> str:
|
||||||
|
"""Build the LLM extraction prompt."""
|
||||||
|
return f"""Extract schedule/event information from the following conversation text.
|
||||||
|
Return a JSON array of events. Each event should have: title, start_time (ISO 8601), end_time (ISO 8601), description.
|
||||||
|
If no events are found, return an empty array [].
|
||||||
|
|
||||||
|
Conversation text:
|
||||||
|
{text}
|
||||||
|
|
||||||
|
Respond with ONLY the JSON array, no other text."""
|
||||||
|
|
||||||
|
def _parse_llm_response(self, response: str) -> list[dict]:
|
||||||
|
"""Parse LLM response as JSON array. Returns [] on any error."""
|
||||||
|
try:
|
||||||
|
# Strip markdown code fences if present
|
||||||
|
cleaned = response.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:]
|
||||||
|
if cleaned.endswith("```"):
|
||||||
|
cleaned = cleaned[:-3]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
return [item for item in data if isinstance(item, dict) and "title" in item]
|
||||||
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
|
logger.warning(f"Failed to parse LLM response as JSON: {e}")
|
||||||
|
return []
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""Data models for the calendar subsystem.
|
||||||
|
|
||||||
|
All dataclasses are DTOs carried between CalendarService, Agent tools,
|
||||||
|
REST routes, and the frontend. They mirror the ``calendar_*`` DB rows.
|
||||||
|
All timestamps are ISO 8601 UTC (see KTD-11).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
"""Return current UTC time as ISO 8601 string."""
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EventType:
|
||||||
|
"""User-defined event type (e.g. "会议", "截止", "个人")."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
color: str = "#4A90D9"
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"name": self.name,
|
||||||
|
"color": self.color,
|
||||||
|
"is_default": self.is_default,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tag:
|
||||||
|
"""User-defined tag for events."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {"id": self.id, "user_id": self.user_id, "name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CalendarEvent:
|
||||||
|
"""A calendar event.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
source: "manual" | "agent" | "post_extract" — origin traceability (R15).
|
||||||
|
is_invited: True if this event arrived via invitation (R33 — special styling).
|
||||||
|
rrule: RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10".
|
||||||
|
external_id: ID in external calendar (for sync).
|
||||||
|
external_provider: "caldav" | "outlook" | None.
|
||||||
|
last_modified: ISO 8601 UTC, for conflict resolution (last-write-wins).
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
title: str
|
||||||
|
description: str = ""
|
||||||
|
start_time: str = "" # ISO 8601 UTC (KTD-11)
|
||||||
|
end_time: str = "" # ISO 8601 UTC
|
||||||
|
is_all_day: bool = False
|
||||||
|
location: str = ""
|
||||||
|
event_type_id: str | None = None
|
||||||
|
rrule: str | None = None
|
||||||
|
source: str = "manual" # "manual" | "agent" | "post_extract"
|
||||||
|
is_invited: bool = False
|
||||||
|
conversation_id: str | None = None
|
||||||
|
external_id: str | None = None
|
||||||
|
external_provider: str | None = None
|
||||||
|
last_modified: str = ""
|
||||||
|
created_at: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"title": self.title,
|
||||||
|
"description": self.description,
|
||||||
|
"start_time": self.start_time,
|
||||||
|
"end_time": self.end_time,
|
||||||
|
"is_all_day": self.is_all_day,
|
||||||
|
"location": self.location,
|
||||||
|
"event_type_id": self.event_type_id,
|
||||||
|
"rrule": self.rrule,
|
||||||
|
"source": self.source,
|
||||||
|
"is_invited": self.is_invited,
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"external_id": self.external_id,
|
||||||
|
"external_provider": self.external_provider,
|
||||||
|
"last_modified": self.last_modified,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EventTag:
|
||||||
|
"""Many-to-many junction between events and tags."""
|
||||||
|
|
||||||
|
event_id: str
|
||||||
|
tag_id: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {"event_id": self.event_id, "tag_id": self.tag_id}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReminderRule:
|
||||||
|
"""Reminder rule — per-event or per-event-type default.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
event_id: FK to events (nullable for type-level defaults).
|
||||||
|
event_type_id: FK to event_types (for default reminders).
|
||||||
|
offset_minutes: -15 = 15 min before, -1440 = 1 day before.
|
||||||
|
channels: ["client", "email", "webhook"].
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
event_id: str | None = None
|
||||||
|
event_type_id: str | None = None
|
||||||
|
offset_minutes: int = -15
|
||||||
|
channels: list[str] = field(default_factory=lambda: ["client"])
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"event_id": self.event_id,
|
||||||
|
"event_type_id": self.event_type_id,
|
||||||
|
"offset_minutes": self.offset_minutes,
|
||||||
|
"channels": self.channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReminderDelivery:
|
||||||
|
"""Tracks delivery status of a reminder instance."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
reminder_rule_id: str
|
||||||
|
event_id: str
|
||||||
|
scheduled_time: str # ISO 8601 UTC
|
||||||
|
status: str = "pending" # "pending" | "sent" | "failed" | "read"
|
||||||
|
channel: str = "client"
|
||||||
|
attempts: int = 0
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"reminder_rule_id": self.reminder_rule_id,
|
||||||
|
"event_id": self.event_id,
|
||||||
|
"scheduled_time": self.scheduled_time,
|
||||||
|
"status": self.status,
|
||||||
|
"channel": self.channel,
|
||||||
|
"attempts": self.attempts,
|
||||||
|
"last_error": self.last_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExternalCalendarConfig:
|
||||||
|
"""Configuration for an external calendar sync provider.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
provider: "caldav" | "outlook".
|
||||||
|
credentials: encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token).
|
||||||
|
sync_frequency: sync interval in minutes.
|
||||||
|
sync_scope: event type IDs to sync, empty = all.
|
||||||
|
sync_token: delta token for incremental sync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
provider: str # "caldav" | "outlook"
|
||||||
|
credentials: str = "" # encrypted JSON
|
||||||
|
sync_frequency: int = 30 # minutes
|
||||||
|
sync_scope: list[str] = field(default_factory=list)
|
||||||
|
last_sync: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"provider": self.provider,
|
||||||
|
"credentials": "***", # Never expose credentials
|
||||||
|
"sync_frequency": self.sync_frequency,
|
||||||
|
"sync_scope": self.sync_scope,
|
||||||
|
"last_sync": self.last_sync,
|
||||||
|
"sync_token": self.sync_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Invitation:
|
||||||
|
"""Event invitation from one user to another."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
event_id: str
|
||||||
|
inviter_user_id: str
|
||||||
|
invitee_email: str
|
||||||
|
status: str = "pending" # "pending" | "accepted" | "declined" | "tentative"
|
||||||
|
responded_at: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"event_id": self.event_id,
|
||||||
|
"inviter_user_id": self.inviter_user_id,
|
||||||
|
"invitee_email": self.invitee_email,
|
||||||
|
"status": self.status,
|
||||||
|
"responded_at": self.responded_at,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""RRULE recurrence expansion wrapper.
|
||||||
|
|
||||||
|
Uses ``dateutil.rrule`` for RFC 5545 compliant recurrence rule expansion.
|
||||||
|
All times are UTC (see KTD-11).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from dateutil.rrule import rrulestr
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(dt_str: str) -> datetime:
|
||||||
|
"""Parse ISO 8601 string to timezone-aware datetime (UTC)."""
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def expand_rrule(
|
||||||
|
rrule_str: str | None,
|
||||||
|
dtstart: str,
|
||||||
|
range_start: str | None = None,
|
||||||
|
range_end: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Expand a recurrence rule into individual occurrence start times.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rrule_str: RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=4").
|
||||||
|
If None or empty, returns ``[dtstart]``.
|
||||||
|
dtstart: ISO 8601 start time of the first occurrence (UTC).
|
||||||
|
range_start: Optional ISO 8601 lower bound (inclusive) for filtering.
|
||||||
|
range_end: Optional ISO 8601 upper bound (exclusive) for filtering.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ISO 8601 UTC strings for each occurrence's start time
|
||||||
|
within the given range. If no range is specified, returns all
|
||||||
|
occurrences (bounded by COUNT or UNTIL in the RRULE).
|
||||||
|
"""
|
||||||
|
if not rrule_str:
|
||||||
|
return [dtstart]
|
||||||
|
|
||||||
|
start_dt = _parse_dt(dtstart)
|
||||||
|
|
||||||
|
# rrulestr expects the RRULE to have a DTSTART context.
|
||||||
|
# We prepend DTSTART to ensure the rule starts from the event's start time.
|
||||||
|
full_rule = (
|
||||||
|
f"DTSTART:{start_dt.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}\nRRULE:{rrule_str}"
|
||||||
|
)
|
||||||
|
rule = rrulestr(full_rule)
|
||||||
|
|
||||||
|
if range_start is not None and range_end is not None:
|
||||||
|
rs = _parse_dt(range_start)
|
||||||
|
re_ = _parse_dt(range_end)
|
||||||
|
# Half-open interval [start, end) — standard date-range convention
|
||||||
|
occurrences = [dt for dt in rule if rs <= dt < re_]
|
||||||
|
elif range_start is not None:
|
||||||
|
rs = _parse_dt(range_start)
|
||||||
|
occurrences = [dt for dt in rule if dt >= rs]
|
||||||
|
elif range_end is not None:
|
||||||
|
re_ = _parse_dt(range_end)
|
||||||
|
occurrences = [dt for dt in rule if dt < re_]
|
||||||
|
else:
|
||||||
|
# ponytail: 1000-occurrence ceiling for unbounded rules (FREQ=DAILY
|
||||||
|
# without COUNT/UNTIL). Upgrade path: accept a max_occurrences param.
|
||||||
|
occurrences = list(itertools.islice(rule, 1000))
|
||||||
|
|
||||||
|
# Convert back to ISO 8601 UTC strings
|
||||||
|
return [dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") for dt in occurrences]
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""Reminder dispatcher — multi-channel delivery (client push / email / webhook).
|
||||||
|
|
||||||
|
Strategy pattern: one method per channel. External dependencies (WS sender,
|
||||||
|
SMTP config, webhook URL) are injected so tests can mock them without
|
||||||
|
patching module imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from agentkit.calendar.models import CalendarEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SmtpConfig:
|
||||||
|
"""SMTP server configuration for the email reminder channel."""
|
||||||
|
|
||||||
|
host: str = "localhost"
|
||||||
|
# ponytail: STARTTLS on 587 is the modern default; plaintext port 25 is
|
||||||
|
# only appropriate for local MTA relays. Upgrade: implicit TLS on 465.
|
||||||
|
port: int = 587
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
use_tls: bool = True
|
||||||
|
from_email: str = "noreply@agentkit.local"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_webhook_url(url: str) -> bool:
|
||||||
|
"""Validate webhook URL to prevent SSRF attacks.
|
||||||
|
|
||||||
|
ponytail: Basic SSRF guard — blocks private/internal IP ranges (RFC 1918),
|
||||||
|
loopback, link-local (169.254.x includes cloud metadata endpoints), and
|
||||||
|
non-http(s) schemes. Uses blocking socket.getaddrinfo; upgrade to
|
||||||
|
asyncio.getaddrinfo if webhook dispatch is on a hot path.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
return False
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
return False
|
||||||
|
if hostname == "localhost" or hostname.startswith("169.254."):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(hostname, None)
|
||||||
|
for _, _, _, _, sockaddr in infos:
|
||||||
|
addr = sockaddr[0]
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(addr)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||||
|
return False
|
||||||
|
except socket.gaierror:
|
||||||
|
pass # DNS resolution failed — let httpx handle the connection error
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderDispatcher:
|
||||||
|
"""Dispatch reminders via client push, email, and webhook channels.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ws_sender: Async callback ``(user_id, message_dict) -> None`` for client
|
||||||
|
push. The callback implementation is responsible for resolving
|
||||||
|
``user_id`` to an active WebSocket session.
|
||||||
|
smtp_config: SMTP settings for the email channel. ``None`` disables email.
|
||||||
|
webhook_url: URL for the webhook channel. ``None`` disables webhook.
|
||||||
|
get_user_email: Async callback ``user_id -> email | None`` used to
|
||||||
|
resolve the recipient address for email reminders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ws_sender: Callable[[str, dict[str, object]], Awaitable[None]] | None = None,
|
||||||
|
smtp_config: SmtpConfig | None = None,
|
||||||
|
webhook_url: str | None = None,
|
||||||
|
get_user_email: Callable[[str], Awaitable[str | None]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._ws_sender = ws_sender
|
||||||
|
self._smtp_config = smtp_config
|
||||||
|
self._webhook_url = webhook_url
|
||||||
|
self._get_user_email = get_user_email
|
||||||
|
|
||||||
|
async def dispatch(self, channel: str, event: CalendarEvent, user_id: str) -> bool:
|
||||||
|
"""Send a reminder via *channel*. Returns ``True`` on success."""
|
||||||
|
if channel == "client":
|
||||||
|
return await self._send_client(event, user_id)
|
||||||
|
if channel == "email":
|
||||||
|
return await self._send_email(event, user_id)
|
||||||
|
if channel == "webhook":
|
||||||
|
return await self._send_webhook(event, user_id)
|
||||||
|
logger.warning("Unknown reminder channel: %s", channel)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _send_client(self, event: CalendarEvent, user_id: str) -> bool:
|
||||||
|
if self._ws_sender is None:
|
||||||
|
return False
|
||||||
|
await self._ws_sender(
|
||||||
|
user_id,
|
||||||
|
{"type": "calendar_reminder", "data": event.to_dict()},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _send_email(self, event: CalendarEvent, user_id: str) -> bool:
|
||||||
|
if self._smtp_config is None or self._get_user_email is None:
|
||||||
|
return False
|
||||||
|
email = await self._get_user_email(user_id)
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
import aiosmtplib
|
||||||
|
except ImportError:
|
||||||
|
# ponytail: aiosmtplib is an optional dep — email channel silently
|
||||||
|
# disabled when not installed. Upgrade: add aiosmtplib to pyproject.toml.
|
||||||
|
logger.debug("aiosmtplib not installed — skipping email reminder")
|
||||||
|
return False
|
||||||
|
message = (
|
||||||
|
f"From: {self._smtp_config.from_email}\r\n"
|
||||||
|
f"To: {email}\r\n"
|
||||||
|
f"Subject: Reminder: {event.title}\r\n\r\n"
|
||||||
|
f"{event.title} starts at {event.start_time}.\r\n"
|
||||||
|
)
|
||||||
|
await aiosmtplib.send(
|
||||||
|
message,
|
||||||
|
hostname=self._smtp_config.host,
|
||||||
|
port=self._smtp_config.port,
|
||||||
|
username=self._smtp_config.username,
|
||||||
|
password=self._smtp_config.password,
|
||||||
|
start_tls=self._smtp_config.use_tls,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _send_webhook(self, event: CalendarEvent, user_id: str) -> bool:
|
||||||
|
if self._webhook_url is None:
|
||||||
|
return False
|
||||||
|
if not _is_safe_webhook_url(self._webhook_url):
|
||||||
|
logger.warning("Webhook URL blocked: private/internal address")
|
||||||
|
return False
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
self._webhook_url,
|
||||||
|
json={"event": event.to_dict(), "user_id": user_id},
|
||||||
|
)
|
||||||
|
return resp.status_code < 400
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""Reminder scheduler — background loop that scans upcoming events and
|
||||||
|
dispatches reminders via :class:`ReminderDispatcher`.
|
||||||
|
|
||||||
|
Follows the ``start()``/``stop()`` + ``asyncio.create_task`` loop pattern from
|
||||||
|
``server/task_store.py`` (KTD-2 — conscious deviation from APScheduler).
|
||||||
|
|
||||||
|
ponytail: app.py lifespan wiring is deferred — the orchestrator will call
|
||||||
|
``start()``/``stop()`` when integrating into the application lifecycle.
|
||||||
|
|
||||||
|
ponytail: ``asyncio.sleep`` polling has second-level precision. If sub-second
|
||||||
|
scheduling is needed, upgrade to APScheduler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
DEFAULT_CALENDAR_DB_PATH,
|
||||||
|
get_pending_deliveries,
|
||||||
|
insert_reminder_delivery,
|
||||||
|
list_all_events_in_time_range,
|
||||||
|
list_reminder_rules_for_event,
|
||||||
|
update_delivery_status,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ReminderDelivery, ReminderRule
|
||||||
|
from agentkit.calendar.reminders import ReminderDispatcher
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(dt_str: str) -> datetime:
|
||||||
|
"""Parse ISO 8601 string to timezone-aware datetime (UTC)."""
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderScheduler:
|
||||||
|
"""Background scheduler that scans for events entering the reminder window
|
||||||
|
and dispatches via the configured channels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
dispatcher: ReminderDispatcher | None = None,
|
||||||
|
interval_seconds: int = 60,
|
||||||
|
lookback_seconds: int = 3600,
|
||||||
|
max_retries: int = 3,
|
||||||
|
retry_base_delay: float = 1.0,
|
||||||
|
) -> None:
|
||||||
|
self._db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
self._dispatcher = dispatcher or ReminderDispatcher()
|
||||||
|
self._interval = interval_seconds
|
||||||
|
self._lookback = lookback_seconds
|
||||||
|
self._max_retries = max_retries
|
||||||
|
self._retry_base_delay = retry_base_delay
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the background scan loop."""
|
||||||
|
if self._task is None:
|
||||||
|
self._task = asyncio.create_task(self._loop())
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Cancel the background scan loop."""
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
"""Main scan loop — runs until cancelled."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.scan_once()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Reminder scheduler scan error")
|
||||||
|
await asyncio.sleep(self._interval)
|
||||||
|
|
||||||
|
async def scan_once(self) -> int:
|
||||||
|
"""Run a single scan cycle. Returns the number of deliveries created.
|
||||||
|
|
||||||
|
Public method so tests can invoke a single scan without waiting for the
|
||||||
|
loop interval.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
window_start = now - timedelta(seconds=self._lookback)
|
||||||
|
window_end = now + timedelta(seconds=self._interval)
|
||||||
|
|
||||||
|
# Query a broad range of events — the reminder_time filter happens below.
|
||||||
|
# ponytail: recurring event reminder expansion is not handled here; only
|
||||||
|
# the event's stored start_time is used. Upgrade: expand RRULE occurrences
|
||||||
|
# and check each occurrence's reminder time.
|
||||||
|
query_start = (now - timedelta(hours=2)).isoformat()
|
||||||
|
query_end = (now + timedelta(hours=48)).isoformat()
|
||||||
|
events = await list_all_events_in_time_range(query_start, query_end, self._db_path)
|
||||||
|
|
||||||
|
dispatched = 0
|
||||||
|
for event in events:
|
||||||
|
rules = await list_reminder_rules_for_event(event.id, self._db_path)
|
||||||
|
for rule in rules:
|
||||||
|
reminder_time = _parse_dt(event.start_time) + timedelta(minutes=rule.offset_minutes)
|
||||||
|
if window_start <= reminder_time <= window_end:
|
||||||
|
dispatched += await self._process_reminder(event, rule, reminder_time)
|
||||||
|
return dispatched
|
||||||
|
|
||||||
|
async def _process_reminder(
|
||||||
|
self,
|
||||||
|
event: CalendarEvent,
|
||||||
|
rule: ReminderRule,
|
||||||
|
reminder_time: datetime,
|
||||||
|
) -> int:
|
||||||
|
"""Create delivery records and dispatch. Returns count of deliveries created.
|
||||||
|
|
||||||
|
Idempotent: if any delivery already exists for this event+rule, skip.
|
||||||
|
"""
|
||||||
|
existing = await get_pending_deliveries(event.id, rule.id, self._db_path, status="sent")
|
||||||
|
if existing:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
for channel in rule.channels:
|
||||||
|
delivery = ReminderDelivery(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
reminder_rule_id=rule.id,
|
||||||
|
event_id=event.id,
|
||||||
|
scheduled_time=reminder_time.isoformat(),
|
||||||
|
status="pending",
|
||||||
|
channel=channel,
|
||||||
|
attempts=0,
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
|
await insert_reminder_delivery(delivery, self._db_path)
|
||||||
|
created += 1
|
||||||
|
await self._dispatch_with_retry(event, delivery)
|
||||||
|
return created
|
||||||
|
|
||||||
|
async def _dispatch_with_retry(
|
||||||
|
self,
|
||||||
|
event: CalendarEvent,
|
||||||
|
delivery: ReminderDelivery,
|
||||||
|
) -> bool:
|
||||||
|
"""Attempt dispatch up to ``max_retries`` times with exponential backoff.
|
||||||
|
|
||||||
|
Updates the delivery record's ``attempts`` counter after each try.
|
||||||
|
Returns ``True`` on success, ``False`` if all retries exhausted.
|
||||||
|
"""
|
||||||
|
for attempt in range(self._max_retries):
|
||||||
|
error: str | None = None
|
||||||
|
try:
|
||||||
|
success = await self._dispatcher.dispatch(delivery.channel, event, event.user_id)
|
||||||
|
if success:
|
||||||
|
await update_delivery_status(delivery.id, "sent", None, self._db_path)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
error = str(exc)
|
||||||
|
|
||||||
|
await update_delivery_status(delivery.id, "failed", error, self._db_path)
|
||||||
|
|
||||||
|
if attempt < self._max_retries - 1:
|
||||||
|
await asyncio.sleep(self._retry_base_delay * (2**attempt))
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,393 @@
|
||||||
|
"""CalendarService — business-logic layer for calendar operations.
|
||||||
|
|
||||||
|
REST routes (U2) and agent tools are thin wrappers over this service.
|
||||||
|
The service dispatches to ``db`` functions for persistence and to
|
||||||
|
``recurrence`` for RRULE expansion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
DEFAULT_CALENDAR_DB_PATH,
|
||||||
|
add_tag_to_event,
|
||||||
|
delete_event as db_delete_event,
|
||||||
|
get_event as db_get_event,
|
||||||
|
get_invitation as db_get_invitation,
|
||||||
|
insert_event,
|
||||||
|
insert_event_type,
|
||||||
|
insert_invitation,
|
||||||
|
insert_reminder_rule,
|
||||||
|
insert_tag,
|
||||||
|
list_event_types as db_list_event_types,
|
||||||
|
list_events as db_list_events,
|
||||||
|
list_invitations as db_list_invitations,
|
||||||
|
list_reminder_rules_for_type,
|
||||||
|
list_tags as db_list_tags,
|
||||||
|
update_event as db_update_event,
|
||||||
|
update_event_type as db_update_event_type,
|
||||||
|
update_invitation_status,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import (
|
||||||
|
CalendarEvent,
|
||||||
|
EventType,
|
||||||
|
Invitation,
|
||||||
|
Tag,
|
||||||
|
_now_iso,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.recurrence import expand_rrule
|
||||||
|
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_iso(dt_str: str) -> None:
|
||||||
|
"""Validate that *dt_str* is a parseable ISO 8601 string."""
|
||||||
|
if not dt_str:
|
||||||
|
raise ValueError("start_time must be a valid ISO 8601 string")
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(dt_str)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid ISO 8601 format: {dt_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(dt_str: str) -> datetime:
|
||||||
|
"""Parse ISO 8601 string to timezone-aware datetime (UTC)."""
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _format_dt(dt: datetime) -> str:
|
||||||
|
"""Format datetime as ISO 8601 UTC string."""
|
||||||
|
return dt.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarService:
|
||||||
|
"""Create, query, and manage calendar events, types, tags, and invitations.
|
||||||
|
|
||||||
|
Mirrors ``DocumentService``: ``__init__`` stores a db_path, async methods
|
||||||
|
delegate to ``calendar.db`` functions. RRULE expansion is handled here
|
||||||
|
so routes and tools get a flat list of occurrences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
auth_db_path: str | Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Event CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_event(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
title: str,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
description: str = "",
|
||||||
|
location: str = "",
|
||||||
|
is_all_day: bool = False,
|
||||||
|
event_type_id: str | None = None,
|
||||||
|
rrule: str | None = None,
|
||||||
|
source: str = "manual",
|
||||||
|
is_invited: bool = False,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
tag_ids: list[str] | None = None,
|
||||||
|
) -> CalendarEvent:
|
||||||
|
"""Create a calendar event with UUID, timestamps, tags, and cloned reminders."""
|
||||||
|
_validate_iso(start_time)
|
||||||
|
now = _now_iso()
|
||||||
|
event = CalendarEvent(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
is_all_day=is_all_day,
|
||||||
|
location=location,
|
||||||
|
event_type_id=event_type_id,
|
||||||
|
rrule=rrule,
|
||||||
|
source=source,
|
||||||
|
is_invited=is_invited,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
last_modified=now,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
await insert_event(event, self.db_path)
|
||||||
|
|
||||||
|
# Link tags if provided — skip any that don't belong to the user
|
||||||
|
if tag_ids:
|
||||||
|
user_tags = await self.list_tags(user_id)
|
||||||
|
user_tag_ids = {t.id for t in user_tags}
|
||||||
|
for tag_id in tag_ids:
|
||||||
|
if tag_id not in user_tag_ids:
|
||||||
|
logger.debug("Skipping tag %s — not owned by user %s", tag_id, user_id)
|
||||||
|
continue
|
||||||
|
await add_tag_to_event(event.id, tag_id, self.db_path)
|
||||||
|
|
||||||
|
# Clone type-level default reminder rules to the event — skip if type
|
||||||
|
# doesn't belong to the user
|
||||||
|
if event_type_id:
|
||||||
|
user_types = await self.list_event_types(user_id)
|
||||||
|
user_type_ids = {t.id for t in user_types}
|
||||||
|
if event_type_id not in user_type_ids:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping event_type %s — not owned by user %s", event_type_id, user_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
type_rules = await list_reminder_rules_for_type(event_type_id, self.db_path)
|
||||||
|
for rule in type_rules:
|
||||||
|
cloned = dataclasses.replace(
|
||||||
|
rule,
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
event_id=event.id,
|
||||||
|
event_type_id=None,
|
||||||
|
)
|
||||||
|
await insert_reminder_rule(cloned, self.db_path)
|
||||||
|
|
||||||
|
logger.info(f"Created event {event.id} ({title}) for user {user_id}")
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def get_event(self, event_id: str) -> CalendarEvent | None:
|
||||||
|
"""Return a single event by id, or None."""
|
||||||
|
return await db_get_event(event_id, self.db_path)
|
||||||
|
|
||||||
|
async def list_events(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
event_type_id: str | None = None,
|
||||||
|
tag_id: str | None = None,
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""List events for a user, expanding recurring events within [start, end].
|
||||||
|
|
||||||
|
Non-recurring events are filtered by date range manually (not in the
|
||||||
|
DB query) so that recurring events whose first occurrence falls
|
||||||
|
outside the range are still included and expanded.
|
||||||
|
"""
|
||||||
|
# Fetch all events for the user with type/tag filters — no date filter
|
||||||
|
# at the DB level so recurring events are not excluded.
|
||||||
|
events = await db_list_events(
|
||||||
|
user_id,
|
||||||
|
event_type_id=event_type_id,
|
||||||
|
tag_id=tag_id,
|
||||||
|
db_path=self.db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
result: list[CalendarEvent] = []
|
||||||
|
for event in events:
|
||||||
|
if event.rrule:
|
||||||
|
# Expand recurring event within [start, end] range
|
||||||
|
try:
|
||||||
|
occurrences = expand_rrule(
|
||||||
|
event.rrule,
|
||||||
|
event.start_time,
|
||||||
|
range_start=start,
|
||||||
|
range_end=end,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# ponytail: malformed RRULE → fall back to single occurrence
|
||||||
|
# so one bad event doesn't crash the whole list. Upgrade
|
||||||
|
# path: surface a validation error at create_event time.
|
||||||
|
logger.warning(
|
||||||
|
"Malformed RRULE %r for event %s; falling back to start_time",
|
||||||
|
event.rrule,
|
||||||
|
event.id,
|
||||||
|
)
|
||||||
|
occurrences = [event.start_time]
|
||||||
|
for occ_start_str in occurrences:
|
||||||
|
occ = self._make_occurrence(event, occ_start_str)
|
||||||
|
result.append(occ)
|
||||||
|
else:
|
||||||
|
# Non-recurring: filter by date range manually
|
||||||
|
if _is_in_range(event.start_time, start, end):
|
||||||
|
result.append(event)
|
||||||
|
|
||||||
|
# Sort by start_time for consistent ordering
|
||||||
|
result.sort(key=lambda e: e.start_time)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _make_occurrence(self, event: CalendarEvent, occ_start_str: str) -> CalendarEvent:
|
||||||
|
"""Create a copy of *event* with start/end times shifted to the occurrence."""
|
||||||
|
occ_start_dt = _parse_dt(occ_start_str)
|
||||||
|
original_start = _parse_dt(event.start_time)
|
||||||
|
original_end = _parse_dt(event.end_time)
|
||||||
|
duration = original_end - original_start
|
||||||
|
occ_end_dt = occ_start_dt + duration
|
||||||
|
return dataclasses.replace(
|
||||||
|
event,
|
||||||
|
start_time=_format_dt(occ_start_dt),
|
||||||
|
end_time=_format_dt(occ_end_dt),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_event(self, event_id: str, fields: dict) -> bool:
|
||||||
|
"""Update specific fields of an event. Auto-updates last_modified."""
|
||||||
|
fields = {**fields, "last_modified": _now_iso()}
|
||||||
|
return await db_update_event(event_id, fields, self.db_path)
|
||||||
|
|
||||||
|
async def delete_event(self, event_id: str) -> bool:
|
||||||
|
"""Delete an event and its dependent rows."""
|
||||||
|
return await db_delete_event(event_id, self.db_path)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Event Type CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_event_types(self, user_id: str) -> list[EventType]:
|
||||||
|
"""List all event types for a user."""
|
||||||
|
return await db_list_event_types(user_id, self.db_path)
|
||||||
|
|
||||||
|
async def get_event_type(self, type_id: str) -> EventType | None:
|
||||||
|
"""Return a single event type by id, or None."""
|
||||||
|
async with aiosqlite.connect(str(self.db_path)) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_event_types WHERE id = ?",
|
||||||
|
(type_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return EventType(
|
||||||
|
id=row["id"],
|
||||||
|
user_id=row["user_id"],
|
||||||
|
name=row["name"],
|
||||||
|
color=row["color"],
|
||||||
|
is_default=bool(row["is_default"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_event_type(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
name: str,
|
||||||
|
color: str = "#4A90D9",
|
||||||
|
) -> EventType:
|
||||||
|
"""Create a new event type."""
|
||||||
|
et = EventType(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
user_id=user_id,
|
||||||
|
name=name,
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
await insert_event_type(et, self.db_path)
|
||||||
|
return et
|
||||||
|
|
||||||
|
async def update_event_type(self, type_id: str, fields: dict) -> bool:
|
||||||
|
"""Update specific fields of an event type."""
|
||||||
|
return await db_update_event_type(type_id, fields, self.db_path)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Tag CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_tags(self, user_id: str) -> list[Tag]:
|
||||||
|
"""List all tags for a user."""
|
||||||
|
return await db_list_tags(user_id, self.db_path)
|
||||||
|
|
||||||
|
async def create_tag(self, user_id: str, name: str) -> Tag:
|
||||||
|
"""Create a new tag."""
|
||||||
|
tag = Tag(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
user_id=user_id,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
await insert_tag(tag, self.db_path)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Invitation CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_invitation(
|
||||||
|
self,
|
||||||
|
event_id: str,
|
||||||
|
inviter_user_id: str,
|
||||||
|
invitee_email: str,
|
||||||
|
) -> Invitation:
|
||||||
|
"""Create an invitation with status='pending'."""
|
||||||
|
invitation = Invitation(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
event_id=event_id,
|
||||||
|
inviter_user_id=inviter_user_id,
|
||||||
|
invitee_email=invitee_email,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
await insert_invitation(invitation, self.db_path)
|
||||||
|
return invitation
|
||||||
|
|
||||||
|
async def respond_to_invitation(self, invitation_id: str, status: str) -> bool:
|
||||||
|
"""Update invitation status and set responded_at."""
|
||||||
|
return await update_invitation_status(
|
||||||
|
invitation_id,
|
||||||
|
status,
|
||||||
|
_now_iso(),
|
||||||
|
self.db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_invitation(self, invitation_id: str) -> Invitation | None:
|
||||||
|
"""Return a single invitation by id, or None."""
|
||||||
|
return await db_get_invitation(invitation_id, self.db_path)
|
||||||
|
|
||||||
|
async def list_invitations(self, invitee_email: str) -> list[Invitation]:
|
||||||
|
"""List all invitations for an invitee email."""
|
||||||
|
return await db_list_invitations(invitee_email, self.db_path)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# User search (auth DB)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def search_users(self, q: str) -> list[dict]:
|
||||||
|
"""Search users by username or email. Returns top 10 matches.
|
||||||
|
|
||||||
|
Only ``username`` and ``email`` are returned — never user_id or
|
||||||
|
password fields (G5/A3 — least-privilege user search).
|
||||||
|
"""
|
||||||
|
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
pattern = f"%{escaped_q}%"
|
||||||
|
async with aiosqlite.connect(str(self.auth_db_path)) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT username, email FROM users "
|
||||||
|
"WHERE username LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' LIMIT 10",
|
||||||
|
(pattern, pattern),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [{"username": row["username"], "email": row["email"]} for row in rows]
|
||||||
|
|
||||||
|
async def get_user_email(self, user_id: str) -> str | None:
|
||||||
|
"""Look up a user's email from the auth DB by user_id."""
|
||||||
|
async with aiosqlite.connect(str(self.auth_db_path)) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT email FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["email"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_in_range(dt_str: str, start: str | None, end: str | None) -> bool:
|
||||||
|
"""Check if *dt_str* falls within the half-open range [start, end)."""
|
||||||
|
dt = _parse_dt(dt_str)
|
||||||
|
if start is not None:
|
||||||
|
if dt < _parse_dt(start):
|
||||||
|
return False
|
||||||
|
if end is not None:
|
||||||
|
if dt >= _parse_dt(end):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Abstract sync provider interface (U6).
|
||||||
|
|
||||||
|
All external calendar sync providers (CalDAV, Outlook, Google) implement
|
||||||
|
this interface so :class:`SyncManager` can orchestrate them uniformly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractSyncProvider(ABC):
|
||||||
|
"""Interface for bidirectional external calendar sync."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def pull_changes(
|
||||||
|
self, config: ExternalCalendarConfig, since: str | None = None
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""Pull remote changes into local DB. Returns pulled/updated events."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def push_changes(
|
||||||
|
self, config: ExternalCalendarConfig, events: list[CalendarEvent]
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""Push local events to remote. Returns updated events (with external_id set)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]:
|
||||||
|
"""Test connectivity. Returns (ok, error_msg). error_msg is "" on success."""
|
||||||
|
...
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""CalDAV sync provider — bidirectional sync with Apple Calendar (U6).
|
||||||
|
|
||||||
|
Uses the ``caldav`` library for the CalDAV protocol and ``icalendar`` for
|
||||||
|
ICS serialization/parsing. Conflict resolution is last-write-wins based on
|
||||||
|
``last_modified``; conflicts emit a ``calendar_sync_conflict`` WS notification
|
||||||
|
via the injectable ``notify_callback`` (G4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import caldav
|
||||||
|
from icalendar import Calendar, Event
|
||||||
|
from icalendar.prop import vRecur
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
DEFAULT_CALENDAR_DB_PATH,
|
||||||
|
get_event_by_external_id,
|
||||||
|
insert_event,
|
||||||
|
update_event,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso
|
||||||
|
from agentkit.calendar.sync.base import AbstractSyncProvider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Async callback signature: (event_type: str, payload: dict) -> None
|
||||||
|
NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(dt_str: str) -> datetime:
|
||||||
|
"""Parse ISO 8601 string to UTC-aware datetime."""
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_iso_utc(dt: datetime) -> str:
|
||||||
|
"""Convert datetime to ISO 8601 UTC string."""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_dt(component: Any, key: str) -> tuple[str, bool]:
|
||||||
|
"""Extract date/datetime from icalendar component. Returns (iso, is_all_day)."""
|
||||||
|
prop = component.get(key)
|
||||||
|
if prop is None:
|
||||||
|
return "", False
|
||||||
|
val = prop.dt
|
||||||
|
if isinstance(val, date) and not isinstance(val, datetime):
|
||||||
|
return val.isoformat(), True
|
||||||
|
return _to_iso_utc(val), False
|
||||||
|
|
||||||
|
|
||||||
|
class CalDAVSyncProvider(AbstractSyncProvider):
|
||||||
|
"""Bidirectional CalDAV sync provider for Apple Calendar.
|
||||||
|
|
||||||
|
The ``client_factory`` parameter allows tests to inject a mock CalDAV
|
||||||
|
client without touching ``caldav.DAVClient``. When ``None``, a real
|
||||||
|
``caldav.DAVClient`` is constructed from ``config.credentials``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
client_factory: Callable[[ExternalCalendarConfig], Any] | None = None,
|
||||||
|
notify_callback: NotifyCallback | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
self._client_factory = client_factory
|
||||||
|
self._notify = notify_callback
|
||||||
|
# ponytail: conflicts list is in-memory only; if the process restarts
|
||||||
|
# before a sync completes, conflict history is lost. Upgrade: persist
|
||||||
|
# to a calendar_sync_conflicts table.
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Client construction
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_client(self, config: ExternalCalendarConfig) -> Any:
|
||||||
|
"""Build a caldav.DAVClient from config credentials."""
|
||||||
|
if self._client_factory is not None:
|
||||||
|
return self._client_factory(config)
|
||||||
|
# ponytail: credentials stored as plain JSON dict; encryption deferred.
|
||||||
|
# Upgrade: use agentkit.server.auth.crypto to encrypt at rest.
|
||||||
|
creds = json.loads(config.credentials) if config.credentials else {}
|
||||||
|
return caldav.DAVClient(
|
||||||
|
url=creds.get("url", ""),
|
||||||
|
username=creds.get("username", ""),
|
||||||
|
password=creds.get("password", ""),
|
||||||
|
# ponytail: 30s timeout prevents indefinite hangs on unreachable
|
||||||
|
# CalDAV servers. Upgrade: make configurable per-config.
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_calendar(self, config: ExternalCalendarConfig) -> Any:
|
||||||
|
"""Connect and return the first calendar from the principal."""
|
||||||
|
client = self._make_client(config)
|
||||||
|
principal = client.principal()
|
||||||
|
calendars = principal.calendars()
|
||||||
|
if not calendars:
|
||||||
|
raise RuntimeError("No CalDAV calendars found for this account")
|
||||||
|
return calendars[0]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Pull
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def pull_changes(
|
||||||
|
self, config: ExternalCalendarConfig, since: str | None = None
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""Pull remote CalDAV events into local DB.
|
||||||
|
|
||||||
|
Fetches events in a date range starting from ``since`` (or 1 year ago
|
||||||
|
if None). Matches by ``external_id`` (CalDAV UID). Creates new local
|
||||||
|
events or updates existing ones. Conflicts (both sides modified) are
|
||||||
|
resolved last-write-wins with a WS notification.
|
||||||
|
"""
|
||||||
|
# caldav is synchronous — run in a thread to avoid blocking the loop
|
||||||
|
remote_events = await asyncio.to_thread(self._fetch_remote_events, config, since)
|
||||||
|
|
||||||
|
result: list[CalendarEvent] = []
|
||||||
|
for remote in remote_events:
|
||||||
|
local = await get_event_by_external_id(
|
||||||
|
remote.external_id, "caldav", config.user_id, self.db_path
|
||||||
|
)
|
||||||
|
if local is None:
|
||||||
|
# New remote event → create local
|
||||||
|
await insert_event(remote, self.db_path)
|
||||||
|
result.append(remote)
|
||||||
|
else:
|
||||||
|
# Existing → check for conflict
|
||||||
|
resolved = await self._resolve_pull_conflict(local, remote)
|
||||||
|
if resolved is not None:
|
||||||
|
result.append(resolved)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _fetch_remote_events(
|
||||||
|
self, config: ExternalCalendarConfig, since: str | None
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""Synchronous CalDAV fetch — runs in thread."""
|
||||||
|
calendar = self._get_calendar(config)
|
||||||
|
|
||||||
|
# Date range: since → now+90d (or wide default)
|
||||||
|
if since:
|
||||||
|
start_dt = _parse_iso(since)
|
||||||
|
else:
|
||||||
|
start_dt = datetime.now(timezone.utc) - timedelta(days=365)
|
||||||
|
end_dt = datetime.now(timezone.utc) + timedelta(days=90)
|
||||||
|
|
||||||
|
caldav_events = calendar.date_search(start_dt, end_dt)
|
||||||
|
events: list[CalendarEvent] = []
|
||||||
|
for ce in caldav_events:
|
||||||
|
parsed = self._parse_caldav_event(ce, config.user_id)
|
||||||
|
if parsed is not None:
|
||||||
|
events.append(parsed)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _parse_caldav_event(self, caldav_event: Any, user_id: str) -> CalendarEvent | None:
|
||||||
|
"""Convert a caldav.Event to a CalendarEvent dataclass."""
|
||||||
|
try:
|
||||||
|
ical_data = caldav_event.data
|
||||||
|
cal = Calendar.from_ical(ical_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse CalDAV event: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
for component in cal.walk("VEVENT"):
|
||||||
|
uid = str(component.get("UID", "") or "") or None
|
||||||
|
if not uid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = str(component.get("SUMMARY", "") or "")
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_str, is_all_day = _extract_dt(component, "DTSTART")
|
||||||
|
end_str, _ = _extract_dt(component, "DTEND")
|
||||||
|
if not end_str:
|
||||||
|
end_str = start_str
|
||||||
|
|
||||||
|
description = str(component.get("DESCRIPTION", "") or "")
|
||||||
|
location = str(component.get("LOCATION", "") or "")
|
||||||
|
|
||||||
|
rrule_str: str | None = None
|
||||||
|
rrule = component.get("RRULE")
|
||||||
|
if rrule is not None:
|
||||||
|
rrule_str = rrule.to_ical().decode("utf-8")
|
||||||
|
|
||||||
|
# LAST-MODIFIED from VEVENT (for conflict resolution)
|
||||||
|
lm_prop = component.get("LAST-MODIFIED")
|
||||||
|
if lm_prop is not None:
|
||||||
|
last_modified = _to_iso_utc(lm_prop.dt)
|
||||||
|
else:
|
||||||
|
last_modified = _now_iso()
|
||||||
|
|
||||||
|
now = _now_iso()
|
||||||
|
return CalendarEvent(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
start_time=start_str,
|
||||||
|
end_time=end_str,
|
||||||
|
is_all_day=is_all_day,
|
||||||
|
location=location,
|
||||||
|
rrule=rrule_str,
|
||||||
|
source="manual",
|
||||||
|
external_id=uid,
|
||||||
|
external_provider="caldav",
|
||||||
|
last_modified=last_modified,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _resolve_pull_conflict(
|
||||||
|
self, local: CalendarEvent, remote: CalendarEvent
|
||||||
|
) -> CalendarEvent | None:
|
||||||
|
"""Resolve conflict when both local and remote exist.
|
||||||
|
|
||||||
|
If remote is newer → update local. If local is newer → conflict
|
||||||
|
(last-write-wins keeps local, but log + notify). If equal → no-op.
|
||||||
|
Returns the winning event (or None if local kept unchanged).
|
||||||
|
"""
|
||||||
|
local_lm = (
|
||||||
|
_parse_iso(local.last_modified)
|
||||||
|
if local.last_modified
|
||||||
|
else datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
remote_lm = (
|
||||||
|
_parse_iso(remote.last_modified)
|
||||||
|
if remote.last_modified
|
||||||
|
else datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
if remote_lm > local_lm:
|
||||||
|
# Remote wins → update local
|
||||||
|
await self._notify_conflict(local, remote, winner="remote")
|
||||||
|
fields = {
|
||||||
|
"title": remote.title,
|
||||||
|
"description": remote.description,
|
||||||
|
"start_time": remote.start_time,
|
||||||
|
"end_time": remote.end_time,
|
||||||
|
"is_all_day": remote.is_all_day,
|
||||||
|
"location": remote.location,
|
||||||
|
"rrule": remote.rrule,
|
||||||
|
"last_modified": remote.last_modified,
|
||||||
|
}
|
||||||
|
await update_event(local.id, fields, self.db_path)
|
||||||
|
return remote
|
||||||
|
|
||||||
|
if local_lm > remote_lm:
|
||||||
|
# Local wins → conflict, keep local, notify
|
||||||
|
await self._notify_conflict(local, remote, winner="local")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Equal timestamps → no change needed
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Push
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def push_changes(
|
||||||
|
self, config: ExternalCalendarConfig, events: list[CalendarEvent]
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""Push local events to CalDAV. Returns events with external_id set."""
|
||||||
|
result: list[CalendarEvent] = []
|
||||||
|
for event in events:
|
||||||
|
updated = await self._push_single(config, event)
|
||||||
|
result.append(updated)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _push_single(
|
||||||
|
self, config: ExternalCalendarConfig, event: CalendarEvent
|
||||||
|
) -> CalendarEvent:
|
||||||
|
"""Push a single event to CalDAV, return event with external_id set."""
|
||||||
|
ical_bytes = self._event_to_ics(event)
|
||||||
|
# caldav is synchronous — run in thread
|
||||||
|
saved_uid = await asyncio.to_thread(self._save_remote_event, config, ical_bytes, event)
|
||||||
|
|
||||||
|
# If event had no external_id, set it from the saved UID
|
||||||
|
if not event.external_id and saved_uid:
|
||||||
|
fields = {
|
||||||
|
"external_id": saved_uid,
|
||||||
|
"external_provider": "caldav",
|
||||||
|
"last_modified": _now_iso(),
|
||||||
|
}
|
||||||
|
await update_event(event.id, fields, self.db_path)
|
||||||
|
event.external_id = saved_uid
|
||||||
|
event.external_provider = "caldav"
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
def _save_remote_event(
|
||||||
|
self, config: ExternalCalendarConfig, ical_bytes: bytes, event: CalendarEvent
|
||||||
|
) -> str | None:
|
||||||
|
"""Synchronous CalDAV save — runs in thread. Returns remote UID."""
|
||||||
|
calendar = self._get_calendar(config)
|
||||||
|
saved = calendar.save_event(ical_bytes)
|
||||||
|
# Extract UID from saved event
|
||||||
|
try:
|
||||||
|
cal = Calendar.from_ical(saved.data)
|
||||||
|
for comp in cal.walk("VEVENT"):
|
||||||
|
uid = str(comp.get("UID", "") or "") or None
|
||||||
|
if uid:
|
||||||
|
return uid
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to extract UID from saved event: %s", e)
|
||||||
|
return event.external_id
|
||||||
|
|
||||||
|
def _event_to_ics(self, event: CalendarEvent) -> bytes:
|
||||||
|
"""Convert CalendarEvent to ICS bytes using icalendar library."""
|
||||||
|
cal = Calendar()
|
||||||
|
cal.add("prodid", "-//Fischer AgentKit//Calendar//EN")
|
||||||
|
cal.add("version", "2.0")
|
||||||
|
|
||||||
|
vevent = Event()
|
||||||
|
# Use external_id if available, else local id as UID
|
||||||
|
vevent.add("uid", event.external_id or event.id)
|
||||||
|
vevent.add("summary", event.title)
|
||||||
|
|
||||||
|
if event.start_time:
|
||||||
|
start_dt = _parse_iso(event.start_time)
|
||||||
|
if event.is_all_day:
|
||||||
|
vevent.add("dtstart", start_dt.date())
|
||||||
|
else:
|
||||||
|
vevent.add("dtstart", start_dt)
|
||||||
|
|
||||||
|
if event.end_time:
|
||||||
|
end_dt = _parse_iso(event.end_time)
|
||||||
|
if event.is_all_day:
|
||||||
|
vevent.add("dtend", end_dt.date())
|
||||||
|
else:
|
||||||
|
vevent.add("dtend", end_dt)
|
||||||
|
|
||||||
|
if event.description:
|
||||||
|
vevent.add("description", event.description)
|
||||||
|
if event.location:
|
||||||
|
vevent.add("location", event.location)
|
||||||
|
if event.rrule:
|
||||||
|
vevent.add("rrule", vRecur.from_ical(event.rrule))
|
||||||
|
|
||||||
|
# LAST-MODIFIED for conflict resolution
|
||||||
|
if event.last_modified:
|
||||||
|
vevent.add("last-modified", _parse_iso(event.last_modified))
|
||||||
|
|
||||||
|
cal.add_component(vevent)
|
||||||
|
return cal.to_ical()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Test connection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]:
|
||||||
|
"""Test CalDAV connectivity. Returns (ok, error_msg)."""
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(self._get_calendar, config)
|
||||||
|
return True, ""
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Conflict notification
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _notify_conflict(
|
||||||
|
self, local: CalendarEvent, remote: CalendarEvent, winner: str
|
||||||
|
) -> None:
|
||||||
|
"""Log conflict and send WS notification via callback (G4)."""
|
||||||
|
logger.info(
|
||||||
|
"Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s",
|
||||||
|
local.id,
|
||||||
|
local.external_id,
|
||||||
|
local.last_modified,
|
||||||
|
remote.last_modified,
|
||||||
|
winner,
|
||||||
|
)
|
||||||
|
if self._notify is not None:
|
||||||
|
payload = {
|
||||||
|
"event_id": local.id,
|
||||||
|
"title": local.title,
|
||||||
|
"external_id": local.external_id,
|
||||||
|
"local_last_modified": local.last_modified,
|
||||||
|
"remote_last_modified": remote.last_modified,
|
||||||
|
"winner": winner,
|
||||||
|
}
|
||||||
|
await self._notify("calendar_sync_conflict", payload)
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""ICS (iCalendar) import/export provider (U8).
|
||||||
|
|
||||||
|
Uses the ``icalendar`` library for RFC 5545 compliant parsing/serialization.
|
||||||
|
Import delegates to ``calendar.db.insert_event`` for direct persistence with
|
||||||
|
``external_id`` set (so duplicate UIDs can be skipped on re-import).
|
||||||
|
Export reads via ``CalendarService.list_events``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from icalendar import Calendar, Event
|
||||||
|
from icalendar.prop import vRecur
|
||||||
|
|
||||||
|
from agentkit.calendar.db import get_event_by_external_id, insert_event
|
||||||
|
from agentkit.calendar.models import CalendarEvent, _now_iso
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_iso_utc(dt: datetime) -> str:
|
||||||
|
"""Convert a datetime to ISO 8601 UTC string."""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(dt_str: str) -> datetime:
|
||||||
|
"""Parse an ISO 8601 string to a UTC-aware datetime."""
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_dt(component: Any, key: str) -> tuple[str, bool]:
|
||||||
|
"""Extract a date/datetime property from an icalendar component.
|
||||||
|
|
||||||
|
Returns ``(iso_string, is_all_day)``. ``is_all_day`` is True when the
|
||||||
|
value is a bare ``date`` (not a ``datetime``).
|
||||||
|
"""
|
||||||
|
prop = component.get(key)
|
||||||
|
if prop is None:
|
||||||
|
return "", False
|
||||||
|
val = prop.dt
|
||||||
|
if isinstance(val, date) and not isinstance(val, datetime):
|
||||||
|
return val.isoformat(), True
|
||||||
|
return _to_iso_utc(val), False
|
||||||
|
|
||||||
|
|
||||||
|
class ICSProvider:
|
||||||
|
"""Import/export calendar events as iCalendar (.ics) files."""
|
||||||
|
|
||||||
|
def __init__(self, service: CalendarService) -> None:
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
async def import_ics(self, ics_bytes: bytes, user_id: str) -> dict[str, Any]:
|
||||||
|
"""Parse ICS bytes and create events for *user_id*.
|
||||||
|
|
||||||
|
Returns ``{"imported": N, "skipped": M, "errors": [...]}``.
|
||||||
|
Raises ``ValueError`` if the ICS content cannot be parsed at all.
|
||||||
|
"""
|
||||||
|
# ponytail: hard size/count caps to prevent DoS via crafted ICS.
|
||||||
|
# Upgrade: stream-parse for unbounded inputs.
|
||||||
|
if len(ics_bytes) > 10 * 1024 * 1024:
|
||||||
|
raise ValueError("ICS file too large (max 10MB)")
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
cal = Calendar.from_ical(ics_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to parse ICS: {e}") from e
|
||||||
|
|
||||||
|
components = list(cal.walk("VEVENT"))
|
||||||
|
# ponytail: hard size/count caps to prevent DoS via crafted ICS.
|
||||||
|
if len(components) > 10000:
|
||||||
|
raise ValueError("Too many events in ICS file (max 10000)")
|
||||||
|
|
||||||
|
for component in components:
|
||||||
|
try:
|
||||||
|
uid = str(component.get("UID", "") or "") or None
|
||||||
|
|
||||||
|
# Dedup by (external_id, provider, user_id)
|
||||||
|
if uid:
|
||||||
|
existing = await get_event_by_external_id(
|
||||||
|
uid, "ics", user_id, self.service.db_path
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = str(component.get("SUMMARY", "") or "")
|
||||||
|
if not title:
|
||||||
|
errors.append("VEVENT missing SUMMARY, skipped")
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_str, is_all_day = _extract_dt(component, "DTSTART")
|
||||||
|
if not start_str:
|
||||||
|
errors.append("VEVENT missing DTSTART, skipped")
|
||||||
|
continue
|
||||||
|
end_str, _ = _extract_dt(component, "DTEND")
|
||||||
|
if not end_str:
|
||||||
|
end_str = start_str
|
||||||
|
|
||||||
|
description = str(component.get("DESCRIPTION", "") or "")
|
||||||
|
location = str(component.get("LOCATION", "") or "")
|
||||||
|
|
||||||
|
rrule_str: str | None = None
|
||||||
|
rrule = component.get("RRULE")
|
||||||
|
if rrule is not None:
|
||||||
|
rrule_str = rrule.to_ical().decode("utf-8")
|
||||||
|
|
||||||
|
now = _now_iso()
|
||||||
|
event = CalendarEvent(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
start_time=start_str,
|
||||||
|
end_time=end_str,
|
||||||
|
is_all_day=is_all_day,
|
||||||
|
location=location,
|
||||||
|
rrule=rrule_str,
|
||||||
|
source="manual",
|
||||||
|
external_id=uid,
|
||||||
|
external_provider="ics" if uid else None,
|
||||||
|
last_modified=now,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
await insert_event(event, self.service.db_path)
|
||||||
|
imported += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Failed to import VEVENT: {e}")
|
||||||
|
logger.warning("ICS import error: %s", e)
|
||||||
|
|
||||||
|
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||||
|
|
||||||
|
def export_ics(self, events: list[CalendarEvent]) -> bytes:
|
||||||
|
"""Serialize *events* to ICS bytes."""
|
||||||
|
cal = Calendar()
|
||||||
|
cal.add("prodid", "-//Fischer AgentKit//Calendar//EN")
|
||||||
|
cal.add("version", "2.0")
|
||||||
|
|
||||||
|
# ponytail: list_events expands RRULE into occurrences (same event.id).
|
||||||
|
# ICS wants one VEVENT with RRULE, not N copies — dedup by id.
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for event in events:
|
||||||
|
if event.id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(event.id)
|
||||||
|
cal.add_component(self._event_to_vevent(event))
|
||||||
|
|
||||||
|
return cal.to_ical()
|
||||||
|
|
||||||
|
def _event_to_vevent(self, event: CalendarEvent) -> Event:
|
||||||
|
"""Convert a :class:`CalendarEvent` to an icalendar ``Event`` component."""
|
||||||
|
vevent = Event()
|
||||||
|
vevent.add("uid", event.external_id or event.id)
|
||||||
|
vevent.add("summary", event.title)
|
||||||
|
|
||||||
|
start_dt = _parse_iso(event.start_time)
|
||||||
|
end_dt = _parse_iso(event.end_time)
|
||||||
|
|
||||||
|
if event.is_all_day:
|
||||||
|
vevent.add("dtstart", start_dt.date())
|
||||||
|
vevent.add("dtend", end_dt.date())
|
||||||
|
else:
|
||||||
|
vevent.add("dtstart", start_dt)
|
||||||
|
vevent.add("dtend", end_dt)
|
||||||
|
|
||||||
|
if event.description:
|
||||||
|
vevent.add("description", event.description)
|
||||||
|
if event.location:
|
||||||
|
vevent.add("location", event.location)
|
||||||
|
if event.rrule:
|
||||||
|
# ponytail: vRecur.from_ical reorders keys (e.g. COUNT before BYDAY)
|
||||||
|
# but the result is semantically equivalent RFC 5545.
|
||||||
|
vevent.add("rrule", vRecur.from_ical(event.rrule))
|
||||||
|
|
||||||
|
return vevent
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""SyncManager — orchestrates external calendar sync providers (U6, G8).
|
||||||
|
|
||||||
|
Iterates user ``ExternalCalendarConfig`` entries, dispatches to the matching
|
||||||
|
provider (CalDAV, Outlook, …), and resolves conflicts via last-write-wins
|
||||||
|
with WS notification. The manager is intended to be registered on
|
||||||
|
``app.state.sync_manager`` and started/stopped in ``app.py`` lifespan.
|
||||||
|
|
||||||
|
ponytail: wiring into ``app.py`` lifespan is deferred — this module provides
|
||||||
|
the API only. Upgrade: add ``SyncManager.start()``/``stop()`` asyncio loop
|
||||||
|
in ``app.py`` next to the reminder scheduler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
DEFAULT_CALENDAR_DB_PATH,
|
||||||
|
list_events,
|
||||||
|
list_external_configs,
|
||||||
|
update_external_config,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso
|
||||||
|
from agentkit.calendar.sync.base import AbstractSyncProvider
|
||||||
|
from agentkit.calendar.sync.caldav_provider import CalDAVSyncProvider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Async callback signature: (event_type: str, payload: dict) -> None
|
||||||
|
NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
class SyncManager:
|
||||||
|
"""Orchestrates all external calendar sync providers for a user.
|
||||||
|
|
||||||
|
Providers are registered by ``ExternalCalendarConfig.provider`` name.
|
||||||
|
The ``notify_callback`` is forwarded to providers for conflict WS push.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
notify_callback: NotifyCallback | None = None,
|
||||||
|
providers: dict[str, AbstractSyncProvider] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
self._notify = notify_callback
|
||||||
|
# ponytail: provider registry is hardcoded for now; if a third provider
|
||||||
|
# (e.g. Google) is added, switch to entry-point discovery. Upgrade:
|
||||||
|
# ``importlib.metadata.entry_points(group="agentkit.sync_providers")``.
|
||||||
|
self._providers: dict[str, AbstractSyncProvider] = providers or {
|
||||||
|
"caldav": CalDAVSyncProvider(db_path=self.db_path, notify_callback=notify_callback),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_provider(self, provider_name: str) -> AbstractSyncProvider | None:
|
||||||
|
"""Return the provider for *provider_name*, or None if unsupported."""
|
||||||
|
return self._providers.get(provider_name)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Sync orchestration
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def sync_all(self, user_id: str) -> dict[str, Any]:
|
||||||
|
"""Sync all external calendar configs for a user.
|
||||||
|
|
||||||
|
Returns ``{"synced": N, "errors": [...]}``.
|
||||||
|
"""
|
||||||
|
configs = await list_external_configs(user_id, self.db_path)
|
||||||
|
synced = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
for config in configs:
|
||||||
|
try:
|
||||||
|
await self.sync_provider(config.id)
|
||||||
|
synced += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{config.id}: {e}")
|
||||||
|
logger.warning("Sync failed for config %s: %s", config.id, e)
|
||||||
|
return {"synced": synced, "errors": errors}
|
||||||
|
|
||||||
|
async def sync_provider(self, config_id: str) -> dict[str, Any]:
|
||||||
|
"""Sync a single external calendar config by ID.
|
||||||
|
|
||||||
|
Pulls remote changes, then pushes local changes modified since
|
||||||
|
``last_sync``. Updates ``last_sync`` on success.
|
||||||
|
|
||||||
|
Returns ``{"pulled": N, "pushed": M}``.
|
||||||
|
"""
|
||||||
|
config = await self._get_config(config_id)
|
||||||
|
if config is None:
|
||||||
|
raise ValueError(f"ExternalCalendarConfig not found: {config_id}")
|
||||||
|
|
||||||
|
provider = self._get_provider(config.provider)
|
||||||
|
if provider is None:
|
||||||
|
raise ValueError(f"Unsupported provider: {config.provider}")
|
||||||
|
|
||||||
|
since = config.last_sync
|
||||||
|
|
||||||
|
# 1. Pull remote → local (creates/updates local events, resolves conflicts)
|
||||||
|
pulled = await provider.pull_changes(config, since=since)
|
||||||
|
|
||||||
|
# 2. Push local changes → remote (events modified since last_sync)
|
||||||
|
# ponytail: reset `since` to now after pull so pulled events (whose
|
||||||
|
# last_modified was set to the remote timestamp) aren't pushed back to
|
||||||
|
# remote, creating a sync loop. Upgrade: filter by pulled event IDs for
|
||||||
|
# sub-second accuracy.
|
||||||
|
since = datetime.now(timezone.utc).isoformat()
|
||||||
|
local_events = await self._get_modified_events(config, since)
|
||||||
|
pushed = await provider.push_changes(config, local_events)
|
||||||
|
|
||||||
|
# 3. Update last_sync
|
||||||
|
now = _now_iso()
|
||||||
|
await update_external_config(config.id, {"last_sync": now}, self.db_path)
|
||||||
|
config.last_sync = now
|
||||||
|
|
||||||
|
return {"pulled": len(pulled), "pushed": len(pushed)}
|
||||||
|
|
||||||
|
async def _get_config(self, config_id: str) -> ExternalCalendarConfig | None:
|
||||||
|
"""Fetch a single ExternalCalendarConfig by ID."""
|
||||||
|
# ponytail: no db.get_external_config(id) exists; we list all for the
|
||||||
|
# user. This is O(N) over the user's configs (typically <5). Upgrade:
|
||||||
|
# add ``get_external_config(config_id)`` to db.py if this becomes hot.
|
||||||
|
# We don't know the user_id here, so scan all configs in the DB.
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from agentkit.calendar.db import _row_to_external_config
|
||||||
|
|
||||||
|
async with aiosqlite.connect(str(self.db_path)) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM calendar_external_configs WHERE id = ?", (config_id,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return _row_to_external_config(row) if row else None
|
||||||
|
|
||||||
|
async def _get_modified_events(
|
||||||
|
self, config: ExternalCalendarConfig, since: str | None
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""Return local events for the user modified since *since*.
|
||||||
|
|
||||||
|
When *since* is None (first sync), returns all events for the user
|
||||||
|
that do not yet have an ``external_id`` (i.e. new local events to push).
|
||||||
|
When *since* is set, returns events whose ``last_modified`` >= *since*.
|
||||||
|
"""
|
||||||
|
events = await list_events(config.user_id, db_path=self.db_path)
|
||||||
|
if since is None:
|
||||||
|
# First sync: push only events without external_id
|
||||||
|
return [e for e in events if not e.external_id]
|
||||||
|
|
||||||
|
since_dt = datetime.fromisoformat(since)
|
||||||
|
if since_dt.tzinfo is None:
|
||||||
|
since_dt = since_dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
result: list[CalendarEvent] = []
|
||||||
|
for event in events:
|
||||||
|
if not event.last_modified:
|
||||||
|
continue
|
||||||
|
event_lm = datetime.fromisoformat(event.last_modified)
|
||||||
|
if event_lm.tzinfo is None:
|
||||||
|
event_lm = event_lm.replace(tzinfo=timezone.utc)
|
||||||
|
if event_lm >= since_dt:
|
||||||
|
result.append(event)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Conflict resolution
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def resolve_conflict(
|
||||||
|
self, local_event: CalendarEvent, remote_event: CalendarEvent
|
||||||
|
) -> CalendarEvent:
|
||||||
|
"""Resolve a sync conflict using last-write-wins (G8/G4).
|
||||||
|
|
||||||
|
Compares ``last_modified`` timestamps. The newer event wins. Sends a
|
||||||
|
``calendar_sync_conflict`` WS notification via the notify callback.
|
||||||
|
Returns the winning event.
|
||||||
|
"""
|
||||||
|
local_lm = (
|
||||||
|
datetime.fromisoformat(local_event.last_modified)
|
||||||
|
if local_event.last_modified
|
||||||
|
else datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
if local_lm.tzinfo is None:
|
||||||
|
local_lm = local_lm.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
remote_lm = (
|
||||||
|
datetime.fromisoformat(remote_event.last_modified)
|
||||||
|
if remote_event.last_modified
|
||||||
|
else datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
if remote_lm.tzinfo is None:
|
||||||
|
remote_lm = remote_lm.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if remote_lm > local_lm:
|
||||||
|
winner = remote_event
|
||||||
|
winner_label = "remote"
|
||||||
|
elif local_lm > remote_lm:
|
||||||
|
winner = local_event
|
||||||
|
winner_label = "local"
|
||||||
|
else:
|
||||||
|
# Equal timestamps → local wins by default
|
||||||
|
winner = local_event
|
||||||
|
winner_label = "local"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Conflict resolved for event %s: winner=%s (local_lm=%s remote_lm=%s)",
|
||||||
|
local_event.id,
|
||||||
|
winner_label,
|
||||||
|
local_event.last_modified,
|
||||||
|
remote_event.last_modified,
|
||||||
|
)
|
||||||
|
|
||||||
|
# G4: WS notification
|
||||||
|
if self._notify is not None:
|
||||||
|
payload = {
|
||||||
|
"event_id": local_event.id,
|
||||||
|
"title": local_event.title,
|
||||||
|
"external_id": local_event.external_id,
|
||||||
|
"local_last_modified": local_event.last_modified,
|
||||||
|
"remote_last_modified": remote_event.last_modified,
|
||||||
|
"winner": winner_label,
|
||||||
|
}
|
||||||
|
await self._notify("calendar_sync_conflict", payload)
|
||||||
|
|
||||||
|
return winner
|
||||||
|
|
@ -0,0 +1,549 @@
|
||||||
|
"""Outlook sync provider — bidirectional sync with Microsoft Graph API (U7).
|
||||||
|
|
||||||
|
Uses ``httpx.AsyncClient`` for all Graph API calls. Conflict resolution is
|
||||||
|
last-write-wins based on ``last_modified``; conflicts emit a
|
||||||
|
``calendar_sync_conflict`` WS notification via the injectable ``notify_callback``
|
||||||
|
(G4).
|
||||||
|
|
||||||
|
ponytail: browser OAuth flow (auth-code grant + redirect) is deferred to U12
|
||||||
|
settings UI. This provider assumes tokens are already stored in
|
||||||
|
``ExternalCalendarConfig.credentials``. Upgrade: add an ``OutlookOAuthFlow``
|
||||||
|
helper that performs the device-code or auth-code flow and writes tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs, quote, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
DEFAULT_CALENDAR_DB_PATH,
|
||||||
|
get_event_by_external_id,
|
||||||
|
insert_event,
|
||||||
|
update_event,
|
||||||
|
update_external_config,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso
|
||||||
|
from agentkit.calendar.sync.base import AbstractSyncProvider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Async callback signature: (event_type: str, payload: dict) -> None
|
||||||
|
NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||||
|
|
||||||
|
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||||
|
TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||||
|
DEFAULT_SCOPE = "https://graph.microsoft.com/Calendars.ReadWrite offline_access"
|
||||||
|
|
||||||
|
_DAY_MAP = {
|
||||||
|
"monday": "MO",
|
||||||
|
"tuesday": "TU",
|
||||||
|
"wednesday": "WE",
|
||||||
|
"thursday": "TH",
|
||||||
|
"friday": "FR",
|
||||||
|
"saturday": "SA",
|
||||||
|
"sunday": "SU",
|
||||||
|
}
|
||||||
|
_DAY_MAP_REVERSE = {v: k for k, v in _DAY_MAP.items()}
|
||||||
|
_FREQ_MAP = {
|
||||||
|
"daily": "DAILY",
|
||||||
|
"weekly": "WEEKLY",
|
||||||
|
"absoluteMonthly": "MONTHLY",
|
||||||
|
"absoluteYearly": "YEARLY",
|
||||||
|
}
|
||||||
|
_FREQ_MAP_REVERSE = {v: k for k, v in _FREQ_MAP.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(dt_str: str) -> datetime:
|
||||||
|
"""Parse ISO 8601 string to UTC-aware datetime."""
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _outlook_dt_to_iso(dt_obj: dict[str, Any]) -> str:
|
||||||
|
"""Convert Outlook dateTimeTimeZone to ISO 8601 UTC.
|
||||||
|
|
||||||
|
ponytail: assumes Graph returns UTC (no ``Prefer: outlook.timezone`` header
|
||||||
|
is sent). If a non-UTC timezone is returned, it's treated as UTC. Upgrade:
|
||||||
|
use ``zoneinfo`` with a Windows→IANA timezone mapping for correct conversion.
|
||||||
|
"""
|
||||||
|
if not dt_obj:
|
||||||
|
return ""
|
||||||
|
dt_str = dt_obj.get("dateTime", "")
|
||||||
|
if not dt_str:
|
||||||
|
return ""
|
||||||
|
if "T" in dt_str:
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
else:
|
||||||
|
# Date only (all-day event)
|
||||||
|
dt = datetime.fromisoformat(dt_str + "T00:00:00")
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_to_outlook_dt(iso_str: str, is_all_day: bool) -> dict[str, str]:
|
||||||
|
"""Convert ISO 8601 UTC to Outlook dateTimeTimeZone."""
|
||||||
|
if not iso_str:
|
||||||
|
return {"dateTime": "", "timeZone": "UTC"}
|
||||||
|
dt = _parse_iso(iso_str)
|
||||||
|
if is_all_day:
|
||||||
|
return {"dateTime": dt.strftime("%Y-%m-%d"), "timeZone": "UTC"}
|
||||||
|
return {"dateTime": dt.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "UTC"}
|
||||||
|
|
||||||
|
|
||||||
|
def _outlook_recurrence_to_rrule(recurrence: dict[str, Any] | None) -> str | None:
|
||||||
|
"""Convert Outlook recurrence pattern to RRULE string."""
|
||||||
|
if not recurrence:
|
||||||
|
return None
|
||||||
|
pattern = recurrence.get("pattern", {}) or {}
|
||||||
|
range_obj = recurrence.get("range", {}) or {}
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
freq = _FREQ_MAP.get(pattern.get("type", ""))
|
||||||
|
if freq:
|
||||||
|
parts.append(f"FREQ={freq}")
|
||||||
|
|
||||||
|
interval = pattern.get("interval")
|
||||||
|
if interval and interval > 1:
|
||||||
|
parts.append(f"INTERVAL={interval}")
|
||||||
|
|
||||||
|
days = pattern.get("daysOfWeek", [])
|
||||||
|
if days:
|
||||||
|
bydays = [_DAY_MAP[d] for d in days if d in _DAY_MAP]
|
||||||
|
if bydays:
|
||||||
|
parts.append(f"BYDAY={','.join(bydays)}")
|
||||||
|
|
||||||
|
count = pattern.get("numberOfOccurrences")
|
||||||
|
if count:
|
||||||
|
parts.append(f"COUNT={count}")
|
||||||
|
elif range_obj.get("type") == "endDate" and range_obj.get("endDate"):
|
||||||
|
end_date = range_obj["endDate"] # "2026-12-31"
|
||||||
|
parts.append(f"UNTIL={end_date.replace('-', '')}T235959Z")
|
||||||
|
|
||||||
|
return ";".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def _rrule_to_outlook_recurrence(rrule: str, start_date: str) -> dict[str, Any] | None:
|
||||||
|
"""Convert RRULE string to Outlook recurrence pattern.
|
||||||
|
|
||||||
|
``start_date`` is the event's start date in ``YYYY-MM-DD`` format (required
|
||||||
|
by Graph API for the ``range.startDate`` field).
|
||||||
|
"""
|
||||||
|
parts: dict[str, str] = {}
|
||||||
|
for part in rrule.split(";"):
|
||||||
|
if "=" in part:
|
||||||
|
k, v = part.split("=", 1)
|
||||||
|
parts[k.upper()] = v
|
||||||
|
|
||||||
|
freq = parts.get("FREQ", "").upper()
|
||||||
|
pattern_type = _FREQ_MAP_REVERSE.get(freq)
|
||||||
|
if not pattern_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pattern: dict[str, Any] = {"type": pattern_type}
|
||||||
|
|
||||||
|
interval = parts.get("INTERVAL")
|
||||||
|
pattern["interval"] = int(interval) if interval else 1
|
||||||
|
|
||||||
|
byday = parts.get("BYDAY")
|
||||||
|
if byday:
|
||||||
|
pattern["daysOfWeek"] = [
|
||||||
|
_DAY_MAP_REVERSE[d] for d in byday.split(",") if d in _DAY_MAP_REVERSE
|
||||||
|
]
|
||||||
|
|
||||||
|
count = parts.get("COUNT")
|
||||||
|
until = parts.get("UNTIL")
|
||||||
|
|
||||||
|
if count:
|
||||||
|
pattern["numberOfOccurrences"] = int(count)
|
||||||
|
range_obj: dict[str, Any] = {
|
||||||
|
"type": "numbered",
|
||||||
|
"startDate": start_date,
|
||||||
|
"numberOfOccurrences": int(count),
|
||||||
|
}
|
||||||
|
elif until:
|
||||||
|
# UNTIL=20261231T235959Z → "2026-12-31"
|
||||||
|
date_str = until[:8]
|
||||||
|
end_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
|
||||||
|
range_obj = {"type": "endDate", "startDate": start_date, "endDate": end_date}
|
||||||
|
else:
|
||||||
|
range_obj = {"type": "noEnd", "startDate": start_date}
|
||||||
|
|
||||||
|
return {"pattern": pattern, "range": range_obj}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_delta_token(delta_link: str) -> str | None:
|
||||||
|
"""Extract ``$deltaToken`` from a Graph delta link URL."""
|
||||||
|
parsed = urlparse(delta_link)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
values = params.get("$deltaToken", [])
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
class OutlookSyncProvider(AbstractSyncProvider):
|
||||||
|
"""Bidirectional Outlook sync provider via Microsoft Graph REST API.
|
||||||
|
|
||||||
|
The ``client_factory`` parameter allows tests to inject a mock
|
||||||
|
``httpx.AsyncClient`` without making real HTTP calls. When ``None``, a
|
||||||
|
real ``httpx.AsyncClient`` is constructed per-operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: str | Path | None = None,
|
||||||
|
client_factory: Callable[[], Any] | None = None,
|
||||||
|
notify_callback: NotifyCallback | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||||
|
self._client_factory = client_factory
|
||||||
|
self._notify = notify_callback
|
||||||
|
# ponytail: conflicts list is in-memory only; if the process restarts
|
||||||
|
# before a sync completes, conflict history is lost. Upgrade: persist
|
||||||
|
# to a calendar_sync_conflicts table.
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Client / auth
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_client(self) -> Any:
|
||||||
|
"""Return an httpx.AsyncClient (real or mock from factory)."""
|
||||||
|
if self._client_factory is not None:
|
||||||
|
return self._client_factory()
|
||||||
|
return httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
def _load_creds(self, config: ExternalCalendarConfig) -> dict[str, Any]:
|
||||||
|
return json.loads(config.credentials) if config.credentials else {}
|
||||||
|
|
||||||
|
def _save_creds(self, config: ExternalCalendarConfig, creds: dict[str, Any]) -> None:
|
||||||
|
config.credentials = json.dumps(creds)
|
||||||
|
|
||||||
|
async def _refresh_token(self, client: Any, config: ExternalCalendarConfig) -> dict[str, Any]:
|
||||||
|
"""Refresh the access token using the refresh_token grant.
|
||||||
|
|
||||||
|
Posts to the Azure AD token endpoint, updates ``config.credentials``
|
||||||
|
in-memory, and persists the new credentials to the DB.
|
||||||
|
"""
|
||||||
|
creds = self._load_creds(config)
|
||||||
|
resp = await client.request(
|
||||||
|
"POST",
|
||||||
|
TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"client_id": creds.get("client_id", ""),
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": creds.get("refresh_token", ""),
|
||||||
|
"scope": DEFAULT_SCOPE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code in (400, 401):
|
||||||
|
logger.error("Outlook refresh token expired or revoked (status=%s)", resp.status_code)
|
||||||
|
if self._notify is not None:
|
||||||
|
await self._notify(
|
||||||
|
"calendar_sync_error",
|
||||||
|
{
|
||||||
|
"config_id": config.id,
|
||||||
|
"message": "Outlook authentication expired, please re-authenticate",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# ponytail: re-auth UI is in U12. Upgrade: trigger OAuth re-flow automatically.
|
||||||
|
raise RuntimeError("Outlook refresh token expired — re-authentication required")
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
creds["access_token"] = payload["access_token"]
|
||||||
|
if "refresh_token" in payload:
|
||||||
|
creds["refresh_token"] = payload["refresh_token"]
|
||||||
|
creds["expires_at"] = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=payload.get("expires_in", 3600))
|
||||||
|
).isoformat()
|
||||||
|
self._save_creds(config, creds)
|
||||||
|
await update_external_config(config.id, {"credentials": config.credentials}, self.db_path)
|
||||||
|
return creds
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
client: Any,
|
||||||
|
config: ExternalCalendarConfig,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
json_body: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Make an authenticated Graph API request with 401 auto-refresh + retry."""
|
||||||
|
creds = self._load_creds(config)
|
||||||
|
headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"}
|
||||||
|
resp = await client.request(method, url, headers=headers, json=json_body)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
creds = await self._refresh_token(client, config)
|
||||||
|
headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"}
|
||||||
|
resp = await client.request(method, url, headers=headers, json=json_body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json() if resp.text else {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Pull
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def pull_changes(
|
||||||
|
self, config: ExternalCalendarConfig, since: str | None = None
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""Pull remote Outlook events via delta query.
|
||||||
|
|
||||||
|
First call (no ``sync_token``) is a full sync within a date range.
|
||||||
|
Subsequent calls use the stored delta token for incremental sync.
|
||||||
|
Returns pulled/updated events.
|
||||||
|
"""
|
||||||
|
client = self._get_client()
|
||||||
|
try:
|
||||||
|
remote_events, delta_token = await self._pull_delta(client, config)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
# Persist delta token for next incremental sync
|
||||||
|
if delta_token:
|
||||||
|
config.sync_token = delta_token
|
||||||
|
await update_external_config(config.id, {"sync_token": delta_token}, self.db_path)
|
||||||
|
|
||||||
|
result: list[CalendarEvent] = []
|
||||||
|
for remote in remote_events:
|
||||||
|
local = await get_event_by_external_id(
|
||||||
|
remote.external_id, "outlook", config.user_id, self.db_path
|
||||||
|
)
|
||||||
|
if local is None:
|
||||||
|
# New remote event → create local
|
||||||
|
await insert_event(remote, self.db_path)
|
||||||
|
result.append(remote)
|
||||||
|
else:
|
||||||
|
# Existing → check for conflict
|
||||||
|
resolved = await self._resolve_pull_conflict(local, remote)
|
||||||
|
if resolved is not None:
|
||||||
|
result.append(resolved)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _pull_delta(
|
||||||
|
self, client: Any, config: ExternalCalendarConfig
|
||||||
|
) -> tuple[list[CalendarEvent], str | None]:
|
||||||
|
"""Call /me/calendarView/delta. Returns (events, delta_token)."""
|
||||||
|
url = self._build_delta_url(config)
|
||||||
|
# ponytail: single-page fetch; pagination via @odata.nextLink is not
|
||||||
|
# followed. Upgrade: loop on nextLink until exhausted, then read
|
||||||
|
# deltaLink from the final page.
|
||||||
|
payload = await self._request(client, config, "GET", url)
|
||||||
|
events: list[CalendarEvent] = []
|
||||||
|
for raw in payload.get("value", []):
|
||||||
|
parsed = self._parse_outlook_event(raw, config.user_id)
|
||||||
|
if parsed is not None:
|
||||||
|
events.append(parsed)
|
||||||
|
delta_link = payload.get("@odata.deltaLink")
|
||||||
|
delta_token = _extract_delta_token(delta_link) if delta_link else None
|
||||||
|
return events, delta_token
|
||||||
|
|
||||||
|
def _build_delta_url(self, config: ExternalCalendarConfig) -> str:
|
||||||
|
"""Build the delta query URL. Uses sync_token if present (incremental)."""
|
||||||
|
if config.sync_token:
|
||||||
|
return f"{GRAPH_BASE}/me/calendarView/delta?$deltaToken={quote(config.sync_token, safe='')}"
|
||||||
|
# Initial sync — use date range to scope the fetch
|
||||||
|
start = (datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
end = (datetime.now(timezone.utc) + timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
return f"{GRAPH_BASE}/me/calendarView/delta?startDateTime={start}&endDateTime={end}"
|
||||||
|
|
||||||
|
def _parse_outlook_event(self, raw: dict[str, Any], user_id: str) -> CalendarEvent | None:
|
||||||
|
"""Convert a Graph event JSON to a CalendarEvent dataclass."""
|
||||||
|
eid = raw.get("id")
|
||||||
|
if not eid:
|
||||||
|
return None
|
||||||
|
title = raw.get("subject") or ""
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
|
||||||
|
start_str = _outlook_dt_to_iso(raw.get("start", {}))
|
||||||
|
end_str = _outlook_dt_to_iso(raw.get("end", {})) or start_str
|
||||||
|
is_all_day = bool(raw.get("isAllDay", False))
|
||||||
|
|
||||||
|
body = raw.get("body", {}) or {}
|
||||||
|
description = body.get("content", "") or ""
|
||||||
|
|
||||||
|
location_obj = raw.get("location", {}) or {}
|
||||||
|
location = location_obj.get("displayName", "") or ""
|
||||||
|
|
||||||
|
rrule = _outlook_recurrence_to_rrule(raw.get("recurrence"))
|
||||||
|
|
||||||
|
last_modified = raw.get("lastModifiedDateTime", "") or _now_iso()
|
||||||
|
now = _now_iso()
|
||||||
|
return CalendarEvent(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
start_time=start_str,
|
||||||
|
end_time=end_str,
|
||||||
|
is_all_day=is_all_day,
|
||||||
|
location=location,
|
||||||
|
rrule=rrule,
|
||||||
|
source="manual",
|
||||||
|
external_id=eid,
|
||||||
|
external_provider="outlook",
|
||||||
|
last_modified=last_modified,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resolve_pull_conflict(
|
||||||
|
self, local: CalendarEvent, remote: CalendarEvent
|
||||||
|
) -> CalendarEvent | None:
|
||||||
|
"""Resolve conflict when both local and remote exist (last-write-wins).
|
||||||
|
|
||||||
|
If remote is newer → update local. If local is newer → conflict
|
||||||
|
(last-write-wins keeps local, but log + notify). If equal → no-op.
|
||||||
|
Returns the winning event (or None if local kept unchanged).
|
||||||
|
"""
|
||||||
|
local_lm = (
|
||||||
|
_parse_iso(local.last_modified)
|
||||||
|
if local.last_modified
|
||||||
|
else datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
remote_lm = (
|
||||||
|
_parse_iso(remote.last_modified)
|
||||||
|
if remote.last_modified
|
||||||
|
else datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
if remote_lm > local_lm:
|
||||||
|
# Remote wins → update local
|
||||||
|
await self._notify_conflict(local, remote, winner="remote")
|
||||||
|
fields = {
|
||||||
|
"title": remote.title,
|
||||||
|
"description": remote.description,
|
||||||
|
"start_time": remote.start_time,
|
||||||
|
"end_time": remote.end_time,
|
||||||
|
"is_all_day": remote.is_all_day,
|
||||||
|
"location": remote.location,
|
||||||
|
"rrule": remote.rrule,
|
||||||
|
"last_modified": remote.last_modified,
|
||||||
|
}
|
||||||
|
await update_event(local.id, fields, self.db_path)
|
||||||
|
return remote
|
||||||
|
|
||||||
|
if local_lm > remote_lm:
|
||||||
|
# Local wins → conflict, keep local, notify
|
||||||
|
await self._notify_conflict(local, remote, winner="local")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Equal timestamps → no change needed
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Push
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def push_changes(
|
||||||
|
self, config: ExternalCalendarConfig, events: list[CalendarEvent]
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
"""Push local events to Outlook. Returns events with external_id set."""
|
||||||
|
client = self._get_client()
|
||||||
|
try:
|
||||||
|
result: list[CalendarEvent] = []
|
||||||
|
for event in events:
|
||||||
|
updated = await self._push_single(client, config, event)
|
||||||
|
result.append(updated)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _push_single(
|
||||||
|
self, client: Any, config: ExternalCalendarConfig, event: CalendarEvent
|
||||||
|
) -> CalendarEvent:
|
||||||
|
"""Push a single event to Outlook, return event with external_id set."""
|
||||||
|
body = self._event_to_outlook(event)
|
||||||
|
if event.external_id:
|
||||||
|
# Update existing remote event
|
||||||
|
url = f"{GRAPH_BASE}/me/events/{event.external_id}"
|
||||||
|
await self._request(client, config, "PATCH", url, json_body=body)
|
||||||
|
fields = {"last_modified": _now_iso()}
|
||||||
|
await update_event(event.id, fields, self.db_path)
|
||||||
|
return event
|
||||||
|
# Create new remote event
|
||||||
|
url = f"{GRAPH_BASE}/me/events"
|
||||||
|
payload = await self._request(client, config, "POST", url, json_body=body)
|
||||||
|
new_id = payload.get("id")
|
||||||
|
if new_id:
|
||||||
|
fields = {
|
||||||
|
"external_id": new_id,
|
||||||
|
"external_provider": "outlook",
|
||||||
|
"last_modified": _now_iso(),
|
||||||
|
}
|
||||||
|
await update_event(event.id, fields, self.db_path)
|
||||||
|
event.external_id = new_id
|
||||||
|
event.external_provider = "outlook"
|
||||||
|
return event
|
||||||
|
|
||||||
|
def _event_to_outlook(self, event: CalendarEvent) -> dict[str, Any]:
|
||||||
|
"""Convert CalendarEvent to Outlook Graph event JSON."""
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"subject": event.title,
|
||||||
|
"start": _iso_to_outlook_dt(event.start_time, event.is_all_day),
|
||||||
|
"end": _iso_to_outlook_dt(event.end_time, event.is_all_day),
|
||||||
|
"isAllDay": event.is_all_day,
|
||||||
|
}
|
||||||
|
if event.description:
|
||||||
|
body["body"] = {"contentType": "Text", "content": event.description}
|
||||||
|
if event.location:
|
||||||
|
body["location"] = {"displayName": event.location}
|
||||||
|
if event.rrule:
|
||||||
|
start_date = event.start_time[:10] if event.start_time else "2026-01-01"
|
||||||
|
rec = _rrule_to_outlook_recurrence(event.rrule, start_date)
|
||||||
|
if rec is not None:
|
||||||
|
body["recurrence"] = rec
|
||||||
|
return body
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Test connection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]:
|
||||||
|
"""Test Outlook connectivity via GET /me. Returns (ok, error_msg)."""
|
||||||
|
client = self._get_client()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
await self._request(client, config, "GET", f"{GRAPH_BASE}/me")
|
||||||
|
ok, msg = True, ""
|
||||||
|
except Exception as e:
|
||||||
|
ok, msg = False, str(e)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
return ok, msg
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Conflict notification
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _notify_conflict(
|
||||||
|
self, local: CalendarEvent, remote: CalendarEvent, winner: str
|
||||||
|
) -> None:
|
||||||
|
"""Log conflict and send WS notification via callback (G4)."""
|
||||||
|
logger.info(
|
||||||
|
"Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s",
|
||||||
|
local.id,
|
||||||
|
local.external_id,
|
||||||
|
local.last_modified,
|
||||||
|
remote.last_modified,
|
||||||
|
winner,
|
||||||
|
)
|
||||||
|
if self._notify is not None:
|
||||||
|
payload = {
|
||||||
|
"event_id": local.id,
|
||||||
|
"title": local.title,
|
||||||
|
"external_id": local.external_id,
|
||||||
|
"local_last_modified": local.last_modified,
|
||||||
|
"remote_last_modified": remote.last_modified,
|
||||||
|
"winner": winner,
|
||||||
|
}
|
||||||
|
await self._notify("calendar_sync_conflict", payload)
|
||||||
|
|
@ -101,8 +101,8 @@ class IntentRouter:
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 按得分降序排序,得分相同时按 skill 名称字母序稳定排序
|
# 按得分降序排序;得分相同时保持列表顺序(Python sort 稳定)
|
||||||
candidates.sort(key=lambda c: (-c[2], c[0].name))
|
candidates.sort(key=lambda c: -c[2])
|
||||||
best_skill, best_kws, _best_score = candidates[0]
|
best_skill, best_kws, _best_score = candidates[0]
|
||||||
confidence = min(1.0, 0.5 + 0.1 * len(best_kws))
|
confidence = min(1.0, 0.5 + 0.1 * len(best_kws))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.0",
|
"@ant-design/icons-vue": "^7.0.0",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.0",
|
||||||
|
"@fullcalendar/interaction": "^6.1.0",
|
||||||
|
"@fullcalendar/timegrid": "^6.1.0",
|
||||||
|
"@fullcalendar/vue3": "^6.1.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-shell": "^2",
|
"@tauri-apps/plugin-shell": "^2",
|
||||||
"@vue-flow/background": "^1.3.0",
|
"@vue-flow/background": "^1.3.0",
|
||||||
|
|
@ -533,6 +537,56 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fullcalendar/core": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@fullcalendar/core/-/core-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "~10.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/daygrid": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/interaction": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@fullcalendar/interaction/-/interaction-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/timegrid": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fullcalendar/daygrid": "~6.1.21"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/vue3": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@fullcalendar/vue3/-/vue3-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-OGt6WSC+/zz/ej6a0KfIBNl7BYuGchpZU49SsedYyv3WZWbghAE+D8YD6nhH1ia/I4p5Gcsv/nEXgEkT/I8aYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21",
|
||||||
|
"vue": "^3.0.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|
@ -2585,6 +2639,17 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.12.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.12.1.tgz",
|
||||||
|
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode.js": {
|
"node_modules/punycode.js": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.0",
|
"@ant-design/icons-vue": "^7.0.0",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.0",
|
||||||
|
"@fullcalendar/interaction": "^6.1.0",
|
||||||
|
"@fullcalendar/timegrid": "^6.1.0",
|
||||||
|
"@fullcalendar/vue3": "^6.1.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-shell": "^2",
|
"@tauri-apps/plugin-shell": "^2",
|
||||||
"@vue-flow/background": "^1.3.0",
|
"@vue-flow/background": "^1.3.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
/** Calendar API client — thin wrapper over /api/v1/calendar endpoints. */
|
||||||
|
|
||||||
|
import { BaseApiClient, getDynamicBaseURL } from './base'
|
||||||
|
|
||||||
|
// ── Domain types (co-located with API client) ──────────────────────────
|
||||||
|
|
||||||
|
export interface ICalendarEvent {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
start_time: string // ISO 8601 UTC (KTD-11)
|
||||||
|
end_time: string // ISO 8601 UTC
|
||||||
|
is_all_day: boolean
|
||||||
|
location: string
|
||||||
|
event_type_id: string | null
|
||||||
|
rrule: string | null // RFC 5545 RRULE string
|
||||||
|
source: 'manual' | 'agent' | 'post_extract'
|
||||||
|
is_invited: boolean
|
||||||
|
conversation_id: string | null
|
||||||
|
external_id: string | null
|
||||||
|
external_provider: string | null
|
||||||
|
last_modified: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEventType {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
is_default: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITag {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IInvitation {
|
||||||
|
id: string
|
||||||
|
event_id: string
|
||||||
|
inviter_user_id: string
|
||||||
|
invitee_email: string
|
||||||
|
status: 'pending' | 'accepted' | 'declined' | 'tentative'
|
||||||
|
responded_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IExternalCalendarConfig {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
provider: 'caldav' | 'outlook'
|
||||||
|
credentials: string // '***' on read-back; never real credentials
|
||||||
|
sync_frequency: number
|
||||||
|
sync_scope: string[]
|
||||||
|
last_sync: string | null
|
||||||
|
sync_token: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUserSearchResult {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ICreateEventRequest {
|
||||||
|
title: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
description?: string
|
||||||
|
location?: string
|
||||||
|
is_all_day?: boolean
|
||||||
|
event_type_id?: string | null
|
||||||
|
rrule?: string | null
|
||||||
|
tag_ids?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUpdateEventRequest {
|
||||||
|
title?: string
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
description?: string
|
||||||
|
location?: string
|
||||||
|
is_all_day?: boolean
|
||||||
|
event_type_id?: string | null
|
||||||
|
rrule?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateEventTypeRequest {
|
||||||
|
name: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateTagRequest {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateInvitationRequest {
|
||||||
|
event_id: string
|
||||||
|
invitee_email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateExternalConfigRequest {
|
||||||
|
provider: 'caldav' | 'outlook'
|
||||||
|
credentials: string // JSON string with provider-specific auth
|
||||||
|
sync_frequency?: number
|
||||||
|
sync_scope?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Runtime type guard ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime guard for ICalendarEvent — validates the minimum fields required
|
||||||
|
* for the calendar store to function safely.
|
||||||
|
* ponytail: checks only the keys the store actually reads; full schema
|
||||||
|
* validation belongs at the API boundary, not in the WS event handler.
|
||||||
|
*/
|
||||||
|
export function isCalendarEvent(value: unknown): value is ICalendarEvent {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
const v = value as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof v.id === 'string' &&
|
||||||
|
typeof v.title === 'string' &&
|
||||||
|
typeof v.start_time === 'string' &&
|
||||||
|
typeof v.end_time === 'string' &&
|
||||||
|
typeof v.is_all_day === 'boolean'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API client ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1/calendar'
|
||||||
|
|
||||||
|
class CalendarApiClient extends BaseApiClient {
|
||||||
|
constructor(baseUrl: string = API_BASE) {
|
||||||
|
super(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List events with optional date/type/tag filters */
|
||||||
|
async listEvents(
|
||||||
|
start?: string,
|
||||||
|
end?: string,
|
||||||
|
eventTypeId?: string,
|
||||||
|
tagId?: string,
|
||||||
|
): Promise<{ success: boolean; events: ICalendarEvent[]; count: number }> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (start) params.set('start', start)
|
||||||
|
if (end) params.set('end', end)
|
||||||
|
if (eventTypeId) params.set('type_id', eventTypeId)
|
||||||
|
if (tagId) params.set('tag_id', tagId)
|
||||||
|
const qs = params.toString()
|
||||||
|
const path = qs ? `/events?${qs}` : '/events'
|
||||||
|
return this.request(path, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new event */
|
||||||
|
async createEvent(
|
||||||
|
data: ICreateEventRequest,
|
||||||
|
): Promise<{ success: boolean; event: ICalendarEvent }> {
|
||||||
|
return this.request('/events', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single event by id */
|
||||||
|
async getEvent(id: string): Promise<{ success: boolean; event: ICalendarEvent }> {
|
||||||
|
return this.request(`/events/${id}`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update specific fields of an event (PATCH — partial update) */
|
||||||
|
async updateEvent(
|
||||||
|
id: string,
|
||||||
|
data: IUpdateEventRequest,
|
||||||
|
): Promise<{ success: boolean; event: ICalendarEvent; updated: boolean }> {
|
||||||
|
return this.request(`/events/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete an event */
|
||||||
|
async deleteEvent(id: string): Promise<{ success: boolean; deleted: boolean }> {
|
||||||
|
return this.request(`/events/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List event types for the current user */
|
||||||
|
async listEventTypes(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
event_types: IEventType[]
|
||||||
|
count: number
|
||||||
|
}> {
|
||||||
|
return this.request('/event-types', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an event type */
|
||||||
|
async createEventType(
|
||||||
|
data: ICreateEventTypeRequest,
|
||||||
|
): Promise<{ success: boolean; event_type: IEventType }> {
|
||||||
|
return this.request('/event-types', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List tags for the current user */
|
||||||
|
async listTags(): Promise<{ success: boolean; tags: ITag[]; count: number }> {
|
||||||
|
return this.request('/tags', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a tag */
|
||||||
|
async createTag(data: ICreateTagRequest): Promise<{ success: boolean; tag: ITag }> {
|
||||||
|
return this.request('/tags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import events from an uploaded .ics file (multipart) */
|
||||||
|
async importIcs(file: File): Promise<{
|
||||||
|
success: boolean
|
||||||
|
imported: number
|
||||||
|
skipped: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return this.request('/import-ics', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {}, // Let browser set Content-Type for FormData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the export-ics download URL for a date range.
|
||||||
|
* Returns an absolute or relative URL the caller can open/download.
|
||||||
|
* ponytail: the endpoint returns binary text/calendar, not JSON, so we
|
||||||
|
* hand back a URL rather than going through request<T> (which JSON-parses).
|
||||||
|
*/
|
||||||
|
exportIcs(start?: string, end?: string): string {
|
||||||
|
const base = getDynamicBaseURL()
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (start) params.set('start', start)
|
||||||
|
if (end) params.set('end', end)
|
||||||
|
const qs = params.toString()
|
||||||
|
const path = qs ? `/api/v1/calendar/export-ics?${qs}` : '/api/v1/calendar/export-ics'
|
||||||
|
return base ? `${base}${path}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an invitation (invite a user to an event by email) */
|
||||||
|
async createInvitation(
|
||||||
|
data: ICreateInvitationRequest,
|
||||||
|
): Promise<{ success: boolean; invitation: IInvitation }> {
|
||||||
|
return this.request(`/events/${data.event_id}/invitations`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ invitee_email: data.invitee_email }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List invitations for the current user */
|
||||||
|
async listInvitations(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
invitations: IInvitation[]
|
||||||
|
count: number
|
||||||
|
}> {
|
||||||
|
return this.request('/invitations', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Respond to an invitation (accept/decline/tentative) */
|
||||||
|
async respondToInvitation(
|
||||||
|
id: string,
|
||||||
|
status: 'accepted' | 'declined' | 'tentative',
|
||||||
|
): Promise<{ success: boolean; status: string }> {
|
||||||
|
return this.request(`/invitations/${id}/respond`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search users by username or email — returns top 10 matches (G5/A3) */
|
||||||
|
async searchUsers(q: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
users: IUserSearchResult[]
|
||||||
|
count: number
|
||||||
|
}> {
|
||||||
|
return this.request(`/users/search?q=${encodeURIComponent(q)}`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List external calendar configs for the current user */
|
||||||
|
async listExternalConfigs(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
configs: IExternalCalendarConfig[]
|
||||||
|
count: number
|
||||||
|
}> {
|
||||||
|
return this.request('/external-configs', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an external calendar config */
|
||||||
|
async createExternalConfig(
|
||||||
|
data: ICreateExternalConfigRequest,
|
||||||
|
): Promise<{ success: boolean; config: IExternalCalendarConfig }> {
|
||||||
|
return this.request('/external-configs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test connection to an external calendar */
|
||||||
|
async testExternalConnection(
|
||||||
|
id: string,
|
||||||
|
): Promise<{ success: boolean; connected: boolean; error?: string }> {
|
||||||
|
return this.request(`/external-configs/${id}/test`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger an immediate sync for an external calendar config */
|
||||||
|
async syncNow(id: string): Promise<{ success: boolean; synced: boolean; error?: string }> {
|
||||||
|
return this.request(`/external-configs/${id}/sync`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete an external calendar config */
|
||||||
|
async deleteExternalConfig(id: string): Promise<{ success: boolean; deleted: boolean }> {
|
||||||
|
return this.request(`/external-configs/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calendarApi = new CalendarApiClient()
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { ICalendarEvent, IInvitation } from './calendar'
|
||||||
|
|
||||||
/** Chat request payload */
|
/** Chat request payload */
|
||||||
export interface IChatRequest {
|
export interface IChatRequest {
|
||||||
message: string
|
message: string
|
||||||
|
|
@ -130,6 +132,11 @@ export type WsServerMessage =
|
||||||
| { type: 'round_summary'; data: IRoundSummaryData }
|
| { type: 'round_summary'; data: IRoundSummaryData }
|
||||||
| { type: 'user_intervention'; data: IUserInterventionData }
|
| { type: 'user_intervention'; data: IUserInterventionData }
|
||||||
| { type: 'board_concluded'; data: IBoardConcludedData }
|
| { type: 'board_concluded'; data: IBoardConcludedData }
|
||||||
|
// Calendar 事件 (KTD-10 — piggyback on chat WS)
|
||||||
|
| { type: 'calendar_event_created'; data: ICalendarEventCreatedData }
|
||||||
|
| { type: 'calendar_reminder'; data: ICalendarReminderData }
|
||||||
|
| { type: 'calendar_invitation'; data: ICalendarInvitationData }
|
||||||
|
| { type: 'calendar_sync_conflict'; data: ICalendarSyncConflictData }
|
||||||
|
|
||||||
/** Expert info within a team */
|
/** Expert info within a team */
|
||||||
export interface IExpertInfo {
|
export interface IExpertInfo {
|
||||||
|
|
@ -233,6 +240,39 @@ export interface IBoardMessage {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Calendar WS 事件 payload 类型 ───────────────────────────────────
|
||||||
|
|
||||||
|
/** calendar_event_created payload */
|
||||||
|
export interface ICalendarEventCreatedData {
|
||||||
|
event: ICalendarEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** calendar_reminder payload */
|
||||||
|
export interface ICalendarReminderData {
|
||||||
|
event_id: string
|
||||||
|
title: string
|
||||||
|
start_time: string
|
||||||
|
offset_minutes: number
|
||||||
|
channels: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** calendar_invitation payload (G6) */
|
||||||
|
export interface ICalendarInvitationData {
|
||||||
|
invitation: IInvitation
|
||||||
|
event_title: string
|
||||||
|
inviter_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** calendar_sync_conflict payload (G4) */
|
||||||
|
export interface ICalendarSyncConflictData {
|
||||||
|
event_id: string
|
||||||
|
event_title: string
|
||||||
|
provider: string
|
||||||
|
local_modified: string
|
||||||
|
remote_modified: string
|
||||||
|
resolution: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Expert template (matches backend GET /api/v1/experts response item) */
|
/** Expert template (matches backend GET /api/v1/experts response item) */
|
||||||
export interface IExpertTemplate {
|
export interface IExpertTemplate {
|
||||||
name: string
|
name: string
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
<template>
|
||||||
|
<a-drawer
|
||||||
|
:open="open"
|
||||||
|
@close="emit('update:open', false)"
|
||||||
|
placement="right"
|
||||||
|
width="80%"
|
||||||
|
title="日历管理"
|
||||||
|
:bodyStyle="{ padding: '0 24px 24px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }"
|
||||||
|
>
|
||||||
|
<a-tabs v-model:activeKey="activeTab" class="calendar-drawer__tabs">
|
||||||
|
<a-tab-pane key="calendar" tab="日历">
|
||||||
|
<div class="calendar-drawer__toolbar">
|
||||||
|
<a-radio-group :value="store.viewMode" @change="onViewChange">
|
||||||
|
<a-radio-button value="calendar">日历</a-radio-button>
|
||||||
|
<a-radio-button value="card">卡片</a-radio-button>
|
||||||
|
<a-radio-button value="list">列表</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
<a-space>
|
||||||
|
<a-badge :count="store.pendingInvitations.length" :offset="[6, 0]">
|
||||||
|
<a-button @click="emit('manage-invitations')">邀请管理</a-button>
|
||||||
|
</a-badge>
|
||||||
|
<a-button type="primary" @click="emit('create')">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新建事件
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-drawer__content">
|
||||||
|
<CalendarGrid
|
||||||
|
v-if="store.viewMode === 'calendar'"
|
||||||
|
@create="onCreate"
|
||||||
|
@edit="onEdit"
|
||||||
|
/>
|
||||||
|
<CardView
|
||||||
|
v-else-if="store.viewMode === 'card'"
|
||||||
|
@edit="onEdit"
|
||||||
|
/>
|
||||||
|
<ListView
|
||||||
|
v-else
|
||||||
|
@edit="onEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="reminder" tab="提醒设置" force-render>
|
||||||
|
<ReminderConfig />
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="sync" tab="同步设置" force-render>
|
||||||
|
<SyncSettings />
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</a-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import type { ICalendarEvent } from '@/api/calendar'
|
||||||
|
import CalendarGrid from './CalendarGrid.vue'
|
||||||
|
import CardView from './CardView.vue'
|
||||||
|
import ListView from './ListView.vue'
|
||||||
|
import ReminderConfig from './ReminderConfig.vue'
|
||||||
|
import SyncSettings from './SyncSettings.vue'
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
(e: 'create', start?: Date, end?: Date): void
|
||||||
|
(e: 'edit', event: ICalendarEvent): void
|
||||||
|
(e: 'manage-invitations'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeTab = ref<string>('calendar')
|
||||||
|
|
||||||
|
function onViewChange(e: { target: { value: string } }): void {
|
||||||
|
store.setViewMode(e.target.value as 'calendar' | 'card' | 'list')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreate(start?: Date, end?: Date): void {
|
||||||
|
emit('create', start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEdit(event: ICalendarEvent): void {
|
||||||
|
emit('edit', event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calendar-drawer__tabs {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-drawer__toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-drawer__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid var(--border-color-split);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<FullCalendar :options="calendarOptions" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import FullCalendar from '@fullcalendar/vue3'
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
|
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction'
|
||||||
|
import type {
|
||||||
|
CalendarOptions,
|
||||||
|
EventInput,
|
||||||
|
DateSelectArg,
|
||||||
|
EventDropArg,
|
||||||
|
EventClickArg,
|
||||||
|
} from '@fullcalendar/core'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import type { ICalendarEvent } from '@/api/calendar'
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'create', start?: Date, end?: Date): void
|
||||||
|
(e: 'edit', event: ICalendarEvent): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toFcEvent(ev: ICalendarEvent): EventInput {
|
||||||
|
const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id)
|
||||||
|
const color = eventType?.color || '#1677ff'
|
||||||
|
return {
|
||||||
|
id: ev.id,
|
||||||
|
title: ev.title,
|
||||||
|
start: ev.start_time,
|
||||||
|
end: ev.end_time,
|
||||||
|
allDay: ev.is_all_day,
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor: color,
|
||||||
|
classNames: ev.is_invited ? ['fc-event-invited'] : [],
|
||||||
|
extendedProps: { event: ev },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(arg: DateSelectArg): void {
|
||||||
|
emit('create', arg.start, arg.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEventDrop(arg: EventDropArg): Promise<void> {
|
||||||
|
const ev = arg.event.extendedProps.event as ICalendarEvent
|
||||||
|
await store.updateEvent(ev.id, {
|
||||||
|
start_time: arg.event.start?.toISOString() ?? ev.start_time,
|
||||||
|
end_time: arg.event.end?.toISOString() ?? ev.end_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEventClick(arg: EventClickArg): void {
|
||||||
|
const ev = arg.event.extendedProps.event as ICalendarEvent
|
||||||
|
emit('edit', ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarOptions = computed<CalendarOptions>(() => ({
|
||||||
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||||
|
initialView: 'dayGridMonth',
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
editable: true,
|
||||||
|
height: '100%',
|
||||||
|
events: store.events.map(toFcEvent),
|
||||||
|
select: handleSelect,
|
||||||
|
eventDrop: handleEventDrop,
|
||||||
|
eventClick: handleEventClick,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calendar-grid {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid :deep(.fc) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid :deep(.fc-event-invited) {
|
||||||
|
border-style: dashed !important;
|
||||||
|
border-width: 2px !important;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
<template>
|
||||||
|
<div class="calendar-panel" :class="{ 'calendar-panel--highlight': highlight }">
|
||||||
|
<div class="calendar-panel__header">
|
||||||
|
<div class="calendar-panel__title">
|
||||||
|
<CalendarOutlined />
|
||||||
|
<span>日历</span>
|
||||||
|
<span class="calendar-panel__date">{{ todayLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<a-button size="small" type="link" @click="openDrawer">
|
||||||
|
<template #icon><ExpandOutlined /></template>
|
||||||
|
展开
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.isLoading && store.events.length === 0" class="calendar-panel__loading">
|
||||||
|
<a-spin size="small" />
|
||||||
|
<span>加载日程...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="store.error" class="calendar-panel__error">
|
||||||
|
<WarningOutlined />
|
||||||
|
<span>{{ store.error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="todayEvents.length === 0 && upcomingEvents.length === 0" class="calendar-panel__empty">
|
||||||
|
<CalendarOutlined />
|
||||||
|
<span>暂无日程</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="calendar-panel__body">
|
||||||
|
<div v-if="todayEvents.length > 0" class="calendar-panel__section">
|
||||||
|
<div class="calendar-panel__section-title">今日</div>
|
||||||
|
<div
|
||||||
|
v-for="ev in todayEvents"
|
||||||
|
:key="ev.id"
|
||||||
|
class="calendar-panel__item"
|
||||||
|
:class="{ 'calendar-panel__item--invited': ev.is_invited }"
|
||||||
|
@click="onEventClick(ev)"
|
||||||
|
>
|
||||||
|
<span class="calendar-panel__item-time">{{ formatTime(ev.start_time) }}</span>
|
||||||
|
<span class="calendar-panel__item-title">{{ ev.title }}</span>
|
||||||
|
<EventBadge :event="ev" />
|
||||||
|
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="upcomingEvents.length > 0" class="calendar-panel__section">
|
||||||
|
<div class="calendar-panel__section-title">即将到来</div>
|
||||||
|
<div
|
||||||
|
v-for="ev in upcomingEvents"
|
||||||
|
:key="ev.id"
|
||||||
|
class="calendar-panel__item"
|
||||||
|
:class="{ 'calendar-panel__item--invited': ev.is_invited }"
|
||||||
|
@click="onEventClick(ev)"
|
||||||
|
>
|
||||||
|
<span class="calendar-panel__item-time">{{ formatUpcomingTime(ev.start_time) }}</span>
|
||||||
|
<span class="calendar-panel__item-title">{{ ev.title }}</span>
|
||||||
|
<EventBadge :event="ev" />
|
||||||
|
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CalendarDrawer
|
||||||
|
:open="drawerOpen"
|
||||||
|
@update:open="drawerOpen = $event"
|
||||||
|
@create="onCreate"
|
||||||
|
@edit="onEdit"
|
||||||
|
@manage-invitations="onManageInvitations"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventEditor
|
||||||
|
v-model:open="editorOpen"
|
||||||
|
:event="editorEvent"
|
||||||
|
:prefill-start="editorPrefillStart"
|
||||||
|
:prefill-end="editorPrefillEnd"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InvitationManager
|
||||||
|
v-model:open="invitationOpen"
|
||||||
|
:event="editorEvent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { CalendarOutlined, ExpandOutlined, WarningOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import type { ICalendarEvent } from '@/api/calendar'
|
||||||
|
import EventBadge from './EventBadge.vue'
|
||||||
|
import CalendarDrawer from './CalendarDrawer.vue'
|
||||||
|
import EventEditor from './EventEditor.vue'
|
||||||
|
import InvitationManager from './InvitationManager.vue'
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const highlight = ref(false)
|
||||||
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// EventEditor state
|
||||||
|
const editorOpen = ref(false)
|
||||||
|
const editorEvent = ref<ICalendarEvent | null>(null)
|
||||||
|
const editorPrefillStart = ref<Date | null>(null)
|
||||||
|
const editorPrefillEnd = ref<Date | null>(null)
|
||||||
|
|
||||||
|
// InvitationManager state
|
||||||
|
const invitationOpen = ref(false)
|
||||||
|
|
||||||
|
const todayLabel = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(new Date())
|
||||||
|
|
||||||
|
const todayEvents = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
|
||||||
|
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString()
|
||||||
|
return store.events
|
||||||
|
.filter((e) => e.start_time >= startOfDay && e.start_time <= endOfDay)
|
||||||
|
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||||
|
})
|
||||||
|
|
||||||
|
const upcomingEvents = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString()
|
||||||
|
return store.events
|
||||||
|
.filter((e) => e.start_time > endOfDay)
|
||||||
|
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||||
|
.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Highlight animation when new event arrives via WS
|
||||||
|
watch(
|
||||||
|
() => store.events.length,
|
||||||
|
(newLen, oldLen) => {
|
||||||
|
if (newLen > (oldLen ?? 0)) {
|
||||||
|
highlight.value = true
|
||||||
|
if (highlightTimer) clearTimeout(highlightTimer)
|
||||||
|
highlightTimer = setTimeout(() => {
|
||||||
|
highlight.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function openDrawer(): void {
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEventClick(ev: ICalendarEvent): void {
|
||||||
|
onEdit(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreate(start?: Date, end?: Date): void {
|
||||||
|
editorEvent.value = null
|
||||||
|
editorPrefillStart.value = start ?? null
|
||||||
|
editorPrefillEnd.value = end ?? null
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEdit(ev: ICalendarEvent): void {
|
||||||
|
editorEvent.value = ev
|
||||||
|
editorPrefillStart.value = null
|
||||||
|
editorPrefillEnd.value = null
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onManageInvitations(): void {
|
||||||
|
invitationOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaved(_event: ICalendarEvent): void {
|
||||||
|
// Event already added/updated in store by EventEditor; nothing else needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpcomingTime(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const tomorrow = new Date(now)
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
if (date.toDateString() === tomorrow.toDateString()) {
|
||||||
|
return `明天 ${formatTime(iso)}`
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadEvents()
|
||||||
|
store.loadEventTypes()
|
||||||
|
store.loadTags()
|
||||||
|
store.loadInvitations()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calendar-panel {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
transition: background var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel--highlight {
|
||||||
|
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__date {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__loading,
|
||||||
|
.calendar-panel__error,
|
||||||
|
.calendar-panel__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-8) 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__section {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__section-title {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__item--invited {
|
||||||
|
border: 1px dashed var(--border-color-hover);
|
||||||
|
background: var(--color-primary-light, rgba(22, 119, 255, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__item-time {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel__item-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
<template>
|
||||||
|
<div class="card-view">
|
||||||
|
<div class="card-view__toolbar">
|
||||||
|
<a-radio-group :value="groupBy" @change="onGroupChange">
|
||||||
|
<a-radio-button value="date">按日期</a-radio-button>
|
||||||
|
<a-radio-button value="type">按类型</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-if="sortedEvents.length === 0" description="暂无日程" class="card-view__empty" />
|
||||||
|
|
||||||
|
<div v-else class="card-view__groups">
|
||||||
|
<div v-for="group in groups" :key="group.key" class="card-view__group">
|
||||||
|
<div class="card-view__group-header">
|
||||||
|
<span class="card-view__group-label">{{ group.label }}</span>
|
||||||
|
<a-tag size="small">{{ group.events.length }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="card-view__cards">
|
||||||
|
<div
|
||||||
|
v-for="ev in group.events"
|
||||||
|
:key="ev.id"
|
||||||
|
class="card-view__card"
|
||||||
|
:class="{ 'card-view__card--invited': ev.is_invited }"
|
||||||
|
:style="cardStyle(ev)"
|
||||||
|
@click="emit('edit', ev)"
|
||||||
|
>
|
||||||
|
<div class="card-view__card-header">
|
||||||
|
<span class="card-view__card-time">{{ formatTime(ev.start_time) }}</span>
|
||||||
|
<EventBadge :event="ev" />
|
||||||
|
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="card-view__card-title">{{ ev.title }}</div>
|
||||||
|
<div v-if="ev.location" class="card-view__card-location">
|
||||||
|
<EnvironmentOutlined />
|
||||||
|
<span>{{ ev.location }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { EnvironmentOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import type { ICalendarEvent } from '@/api/calendar'
|
||||||
|
import EventBadge from './EventBadge.vue'
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'edit', event: ICalendarEvent): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupBy = ref<'date' | 'type'>('date')
|
||||||
|
|
||||||
|
function onGroupChange(e: { target: { value: string } }): void {
|
||||||
|
groupBy.value = e.target.value as 'date' | 'type'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEvents = computed(() =>
|
||||||
|
[...store.events].sort((a, b) => a.start_time.localeCompare(b.start_time)),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface CardGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
events: ICalendarEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = computed<CardGroup[]>(() => {
|
||||||
|
const map = new Map<string, ICalendarEvent[]>()
|
||||||
|
if (groupBy.value === 'date') {
|
||||||
|
for (const ev of sortedEvents.value) {
|
||||||
|
const key = ev.start_time.slice(0, 10)
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(ev)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([key, events]) => ({
|
||||||
|
key,
|
||||||
|
label: formatDateLabel(key),
|
||||||
|
events,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
for (const ev of sortedEvents.value) {
|
||||||
|
const key = ev.event_type_id ?? 'none'
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(ev)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([key, events]) => ({
|
||||||
|
key,
|
||||||
|
label:
|
||||||
|
key === 'none'
|
||||||
|
? '未分类'
|
||||||
|
: store.eventTypes.find((t) => t.id === key)?.name ?? '未分类',
|
||||||
|
events,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function cardStyle(ev: ICalendarEvent): { borderLeftColor: string } {
|
||||||
|
const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id)
|
||||||
|
return { borderLeftColor: eventType?.color || '#1677ff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00')
|
||||||
|
const today = new Date()
|
||||||
|
if (date.toDateString() === today.toDateString()) return '今天'
|
||||||
|
const tomorrow = new Date(today)
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
if (date.toDateString() === tomorrow.toDateString()) return '明天'
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-view {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__toolbar {
|
||||||
|
padding: 0 var(--space-3) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__empty {
|
||||||
|
padding: var(--space-8) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__card {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-left: 3px solid #1677ff;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__card:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--border-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__card--invited {
|
||||||
|
border-style: dashed;
|
||||||
|
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__card-time {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__card-title {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-view__card-location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<template>
|
||||||
|
<span v-if="icon" class="event-badge" :class="`event-badge--${event.source}`" :title="title">
|
||||||
|
<component :is="icon" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { RobotOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
|
||||||
|
import type { ICalendarEvent } from '@/api/calendar'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
event: ICalendarEvent
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
switch (props.event.source) {
|
||||||
|
case 'agent':
|
||||||
|
return RobotOutlined
|
||||||
|
case 'post_extract':
|
||||||
|
return ThunderboltOutlined
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
switch (props.event.source) {
|
||||||
|
case 'agent':
|
||||||
|
return 'Agent 创建'
|
||||||
|
case 'post_extract':
|
||||||
|
return '对话提取'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-badge--agent {
|
||||||
|
color: var(--accent-team, #722ed1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-badge--post_extract {
|
||||||
|
color: var(--accent-board, #fa8c16);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
<template>
|
||||||
|
<a-drawer
|
||||||
|
:open="open"
|
||||||
|
@close="close"
|
||||||
|
placement="right"
|
||||||
|
:width="560"
|
||||||
|
:title="isEdit ? '编辑事件' : '新建事件'"
|
||||||
|
:destroyOnClose="true"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="标题" required>
|
||||||
|
<a-input v-model:value="form.title" placeholder="事件标题" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="描述">
|
||||||
|
<a-textarea v-model:value="form.description" :rows="3" placeholder="事件描述" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="全天">
|
||||||
|
<a-switch v-model:checked="form.is_all_day" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="日期范围" required>
|
||||||
|
<a-range-picker v-model:value="dateRange" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="!form.is_all_day" label="时间">
|
||||||
|
<a-space>
|
||||||
|
<a-time-picker v-model:value="startTime" format="HH:mm" :allowClear="false" />
|
||||||
|
<span>至</span>
|
||||||
|
<a-time-picker v-model:value="endTime" format="HH:mm" :allowClear="false" />
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="地点">
|
||||||
|
<a-input v-model:value="form.location" placeholder="事件地点" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="事件类型">
|
||||||
|
<a-select v-model:value="form.event_type_id" allowClear placeholder="选择类型">
|
||||||
|
<a-select-option v-for="t in store.eventTypes" :key="t.id" :value="t.id">
|
||||||
|
<a-tag :color="t.color" size="small">{{ t.name }}</a-tag>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="标签">
|
||||||
|
<a-select
|
||||||
|
v-model:value="selectedTagIds"
|
||||||
|
mode="tags"
|
||||||
|
placeholder="选择或输入标签"
|
||||||
|
:token-separators="[',']"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="t in store.tags" :key="t.id" :value="t.id">
|
||||||
|
{{ t.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="重复">
|
||||||
|
<a-space>
|
||||||
|
<a-select v-model:value="rruleFreq" style="width: 120px">
|
||||||
|
<a-select-option value="none">不重复</a-select-option>
|
||||||
|
<a-select-option value="DAILY">每天</a-select-option>
|
||||||
|
<a-select-option value="WEEKLY">每周</a-select-option>
|
||||||
|
<a-select-option value="MONTHLY">每月</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<template v-if="rruleFreq !== 'none'">
|
||||||
|
<span>每</span>
|
||||||
|
<a-input-number v-model:value="rruleInterval" :min="1" :max="99" />
|
||||||
|
<span>{{ freqUnitLabel }}</span>
|
||||||
|
</template>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="提醒">
|
||||||
|
<!-- ponytail: reminders UI is local-only; ICreateEventRequest has no reminders field yet.
|
||||||
|
Upgrade path: add reminders array to ICreateEventRequest/IUpdateEventRequest when backend supports it. -->
|
||||||
|
<div v-for="(r, i) in reminders" :key="i" class="event-editor__reminder">
|
||||||
|
<a-input-number v-model:value="r.offset" :min="0" addon-before="提前" addon-after="分钟" />
|
||||||
|
<a-select v-model:value="r.channel" style="width: 100px">
|
||||||
|
<a-select-option value="in_app">应用内</a-select-option>
|
||||||
|
<a-select-option value="email">邮件</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-button danger size="small" @click="reminders.splice(i, 1)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-button size="small" @click="reminders.push({ offset: 15, channel: 'in_app' })">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
添加提醒
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<a-space>
|
||||||
|
<a-button @click="close">取消</a-button>
|
||||||
|
<a-button type="primary" :loading="store.isLoading" @click="onSave">保存</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import type { ICalendarEvent, ICreateEventRequest, IUpdateEventRequest } from '@/api/calendar'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
event: ICalendarEvent | null
|
||||||
|
prefillStart?: Date | null
|
||||||
|
prefillEnd?: Date | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
(e: 'saved', event: ICalendarEvent): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.event)
|
||||||
|
|
||||||
|
interface ReminderRule {
|
||||||
|
offset: number
|
||||||
|
channel: 'in_app' | 'email'
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
is_all_day: false,
|
||||||
|
location: '',
|
||||||
|
event_type_id: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
||||||
|
const startTime = ref<Dayjs>(dayjs().hour(9).minute(0).second(0))
|
||||||
|
const endTime = ref<Dayjs>(dayjs().hour(10).minute(0).second(0))
|
||||||
|
const selectedTagIds = ref<string[]>([])
|
||||||
|
const rruleFreq = ref<'none' | 'DAILY' | 'WEEKLY' | 'MONTHLY'>('none')
|
||||||
|
const rruleInterval = ref(1)
|
||||||
|
const reminders = ref<ReminderRule[]>([])
|
||||||
|
|
||||||
|
const freqUnitLabel = computed(() => {
|
||||||
|
switch (rruleFreq.value) {
|
||||||
|
case 'DAILY':
|
||||||
|
return '天'
|
||||||
|
case 'WEEKLY':
|
||||||
|
return '周'
|
||||||
|
case 'MONTHLY':
|
||||||
|
return '月'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize form when drawer opens or event changes
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.event],
|
||||||
|
() => {
|
||||||
|
if (!props.open) return
|
||||||
|
if (props.event) {
|
||||||
|
// Edit mode — pre-fill from event
|
||||||
|
form.title = props.event.title
|
||||||
|
form.description = props.event.description
|
||||||
|
form.is_all_day = props.event.is_all_day
|
||||||
|
form.location = props.event.location
|
||||||
|
form.event_type_id = props.event.event_type_id
|
||||||
|
|
||||||
|
const start = dayjs(props.event.start_time)
|
||||||
|
const end = dayjs(props.event.end_time)
|
||||||
|
dateRange.value = [start.startOf('day'), end.startOf('day')]
|
||||||
|
startTime.value = start
|
||||||
|
endTime.value = end
|
||||||
|
|
||||||
|
parseRrule(props.event.rrule)
|
||||||
|
// ponytail: ICalendarEvent has no tags field; can't pre-fill tag selection.
|
||||||
|
selectedTagIds.value = []
|
||||||
|
reminders.value = []
|
||||||
|
} else {
|
||||||
|
// Create mode
|
||||||
|
form.title = ''
|
||||||
|
form.description = ''
|
||||||
|
form.is_all_day = false
|
||||||
|
form.location = ''
|
||||||
|
form.event_type_id = null
|
||||||
|
|
||||||
|
const start = props.prefillStart ? dayjs(props.prefillStart) : dayjs().hour(9).minute(0)
|
||||||
|
const end = props.prefillEnd ? dayjs(props.prefillEnd) : start.add(1, 'hour')
|
||||||
|
dateRange.value = [start.startOf('day'), end.startOf('day')]
|
||||||
|
startTime.value = start
|
||||||
|
endTime.value = end
|
||||||
|
|
||||||
|
rruleFreq.value = 'none'
|
||||||
|
rruleInterval.value = 1
|
||||||
|
selectedTagIds.value = []
|
||||||
|
reminders.value = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function parseRrule(rrule: string | null): void {
|
||||||
|
if (!rrule) {
|
||||||
|
rruleFreq.value = 'none'
|
||||||
|
rruleInterval.value = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const freqMatch = rrule.match(/FREQ=(\w+)/)
|
||||||
|
const intervalMatch = rrule.match(/INTERVAL=(\d+)/)
|
||||||
|
if (freqMatch && ['DAILY', 'WEEKLY', 'MONTHLY'].includes(freqMatch[1])) {
|
||||||
|
rruleFreq.value = freqMatch[1] as 'DAILY' | 'WEEKLY' | 'MONTHLY'
|
||||||
|
rruleInterval.value = intervalMatch ? parseInt(intervalMatch[1], 10) : 1
|
||||||
|
} else {
|
||||||
|
rruleFreq.value = 'none'
|
||||||
|
rruleInterval.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRrule(): string | null {
|
||||||
|
if (rruleFreq.value === 'none') return null
|
||||||
|
return `FREQ=${rruleFreq.value};INTERVAL=${rruleInterval.value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit('update:open', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve tag selections to IDs, creating new tags as needed */
|
||||||
|
async function resolveTagIds(): Promise<string[]> {
|
||||||
|
const ids: string[] = []
|
||||||
|
for (const val of selectedTagIds.value) {
|
||||||
|
const existing = store.tags.find((t) => t.id === val)
|
||||||
|
if (existing) {
|
||||||
|
ids.push(existing.id)
|
||||||
|
} else {
|
||||||
|
// New tag name typed by user — create it
|
||||||
|
const tag = await store.createTag({ name: val })
|
||||||
|
if (tag) ids.push(tag.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave(): Promise<void> {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
message.warning('请输入标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!dateRange.value) {
|
||||||
|
message.warning('请选择日期范围')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startDate, endDate] = dateRange.value
|
||||||
|
let startIso: string
|
||||||
|
let endIso: string
|
||||||
|
|
||||||
|
if (form.is_all_day) {
|
||||||
|
startIso = startDate.startOf('day').toISOString()
|
||||||
|
endIso = endDate.startOf('day').toISOString()
|
||||||
|
} else {
|
||||||
|
// KTD-11: combine local date + time, convert to UTC ISO 8601
|
||||||
|
startIso = startDate
|
||||||
|
.hour(startTime.value.hour())
|
||||||
|
.minute(startTime.value.minute())
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
.toISOString()
|
||||||
|
endIso = endDate
|
||||||
|
.hour(endTime.value.hour())
|
||||||
|
.minute(endTime.value.minute())
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rrule = buildRrule()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (props.event) {
|
||||||
|
const updateData: IUpdateEventRequest = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
start_time: startIso,
|
||||||
|
end_time: endIso,
|
||||||
|
is_all_day: form.is_all_day,
|
||||||
|
location: form.location,
|
||||||
|
event_type_id: form.event_type_id,
|
||||||
|
rrule,
|
||||||
|
}
|
||||||
|
await store.updateEvent(props.event.id, updateData)
|
||||||
|
message.success('事件已更新')
|
||||||
|
emit('saved', props.event)
|
||||||
|
} else {
|
||||||
|
const tagIds = await resolveTagIds()
|
||||||
|
const createData: ICreateEventRequest = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
start_time: startIso,
|
||||||
|
end_time: endIso,
|
||||||
|
is_all_day: form.is_all_day,
|
||||||
|
location: form.location,
|
||||||
|
event_type_id: form.event_type_id,
|
||||||
|
rrule,
|
||||||
|
tag_ids: tagIds,
|
||||||
|
}
|
||||||
|
const created = await store.createEvent(createData)
|
||||||
|
message.success('事件已创建')
|
||||||
|
if (created) emit('saved', created)
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
} catch {
|
||||||
|
message.error(props.event ? '更新失败' : '创建失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-editor__reminder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
<template>
|
||||||
|
<a-drawer
|
||||||
|
:open="open"
|
||||||
|
@close="close"
|
||||||
|
placement="right"
|
||||||
|
:width="480"
|
||||||
|
title="邀请管理"
|
||||||
|
:destroyOnClose="true"
|
||||||
|
>
|
||||||
|
<!-- Event selector + invitees for selected event -->
|
||||||
|
<div class="invitation-manager__section">
|
||||||
|
<div class="invitation-manager__section-header">
|
||||||
|
<span class="invitation-manager__section-title">事件邀请</span>
|
||||||
|
<a-button
|
||||||
|
v-if="selectedEventId"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="inviteModalOpen = true"
|
||||||
|
>
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
邀请
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="selectedEventId"
|
||||||
|
allowClear
|
||||||
|
placeholder="选择事件以管理邀请"
|
||||||
|
style="width: 100%; margin-bottom: 12px"
|
||||||
|
@change="loadEventInvitations"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="ev in store.events" :key="ev.id" :value="ev.id">
|
||||||
|
{{ ev.title }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<template v-if="selectedEventId">
|
||||||
|
<a-empty v-if="eventInvitations.length === 0" description="暂无邀请" />
|
||||||
|
<a-list v-else :dataSource="eventInvitations" size="small">
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<a-list-item>
|
||||||
|
<a-list-item-meta>
|
||||||
|
<template #title>{{ item.invitee_email }}</template>
|
||||||
|
<template #description>
|
||||||
|
<a-tag :color="statusColor(item.status)">{{ statusLabel(item.status) }}</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider />
|
||||||
|
|
||||||
|
<!-- Incoming invitations (G6) -->
|
||||||
|
<div class="invitation-manager__section">
|
||||||
|
<div class="invitation-manager__section-header">
|
||||||
|
<span class="invitation-manager__section-title">收到的邀请</span>
|
||||||
|
<a-badge :count="store.pendingInvitations.length" />
|
||||||
|
</div>
|
||||||
|
<a-empty v-if="store.pendingInvitations.length === 0" description="暂无待处理邀请" />
|
||||||
|
<a-list v-else :dataSource="store.pendingInvitations" size="small">
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<a-list-item>
|
||||||
|
<a-list-item-meta>
|
||||||
|
<template #title>{{ invitationEventTitle(item.event_id) }}</template>
|
||||||
|
<template #description>{{ item.invitee_email }}</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
<template #actions>
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" type="primary" @click="respond(item.id, 'accepted')">
|
||||||
|
接受
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" @click="respond(item.id, 'tentative')">待定</a-button>
|
||||||
|
<a-button size="small" danger @click="respond(item.id, 'declined')">
|
||||||
|
拒绝
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User search modal for inviting (G5/A3) -->
|
||||||
|
<a-modal v-model:open="inviteModalOpen" title="邀请用户" :footer="null" :width="420">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchQuery"
|
||||||
|
placeholder="搜索用户名或邮箱"
|
||||||
|
@search="doSearch"
|
||||||
|
:loading="searching"
|
||||||
|
/>
|
||||||
|
<a-list
|
||||||
|
v-if="searchResults.length > 0"
|
||||||
|
:dataSource="searchResults"
|
||||||
|
size="small"
|
||||||
|
class="invitation-manager__search-results"
|
||||||
|
>
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<a-list-item>
|
||||||
|
<a-list-item-meta>
|
||||||
|
<template #title>{{ item.username }}</template>
|
||||||
|
<template #description>{{ item.email }}</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
<template #actions>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="invitingEmail === item.email"
|
||||||
|
@click="inviteUser(item.email)"
|
||||||
|
>
|
||||||
|
邀请
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
</a-modal>
|
||||||
|
</a-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import { calendarApi } from '@/api/calendar'
|
||||||
|
import type { ICalendarEvent, IInvitation, IUserSearchResult } from '@/api/calendar'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
event: ICalendarEvent | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const selectedEventId = ref<string | null>(null)
|
||||||
|
const eventInvitations = ref<IInvitation[]>([])
|
||||||
|
const inviteModalOpen = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchResults = ref<IUserSearchResult[]>([])
|
||||||
|
const searching = ref(false)
|
||||||
|
const invitingEmail = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Sync event prop to internal selection
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.event],
|
||||||
|
() => {
|
||||||
|
if (!props.open) return
|
||||||
|
selectedEventId.value = props.event?.id ?? null
|
||||||
|
if (selectedEventId.value) {
|
||||||
|
loadEventInvitations()
|
||||||
|
} else {
|
||||||
|
eventInvitations.value = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit('update:open', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEventInvitations(): Promise<void> {
|
||||||
|
if (!selectedEventId.value) {
|
||||||
|
eventInvitations.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.listInvitations()
|
||||||
|
eventInvitations.value = (resp.invitations || []).filter(
|
||||||
|
(i) => i.event_id === selectedEventId.value,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load event invitations:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch(): Promise<void> {
|
||||||
|
if (!searchQuery.value.trim()) return
|
||||||
|
searching.value = true
|
||||||
|
try {
|
||||||
|
searchResults.value = await store.searchUsers(searchQuery.value)
|
||||||
|
} finally {
|
||||||
|
searching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inviteUser(email: string): Promise<void> {
|
||||||
|
if (!selectedEventId.value) return
|
||||||
|
invitingEmail.value = email
|
||||||
|
try {
|
||||||
|
await calendarApi.createInvitation({
|
||||||
|
event_id: selectedEventId.value,
|
||||||
|
invitee_email: email,
|
||||||
|
})
|
||||||
|
message.success(`已邀请 ${email}`)
|
||||||
|
await loadEventInvitations()
|
||||||
|
} catch {
|
||||||
|
message.error('邀请失败')
|
||||||
|
} finally {
|
||||||
|
invitingEmail.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respond(
|
||||||
|
id: string,
|
||||||
|
status: 'accepted' | 'declined' | 'tentative',
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await store.respondToInvitation(id, status)
|
||||||
|
message.success(status === 'accepted' ? '已接受' : status === 'declined' ? '已拒绝' : '已标记待定')
|
||||||
|
} catch {
|
||||||
|
message.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invitationEventTitle(eventId: string): string {
|
||||||
|
return store.events.find((e) => e.id === eventId)?.title ?? '未知事件'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: IInvitation['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'accepted':
|
||||||
|
return 'green'
|
||||||
|
case 'declined':
|
||||||
|
return 'red'
|
||||||
|
case 'tentative':
|
||||||
|
return 'orange'
|
||||||
|
default:
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: IInvitation['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'accepted':
|
||||||
|
return '已接受'
|
||||||
|
case 'declined':
|
||||||
|
return '已拒绝'
|
||||||
|
case 'tentative':
|
||||||
|
return '待定'
|
||||||
|
default:
|
||||||
|
return '待回复'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.invitation-manager__section {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-manager__section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-manager__section-title {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-manager__search-results {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
<template>
|
||||||
|
<div class="list-view">
|
||||||
|
<!-- Batch operations toolbar -->
|
||||||
|
<div v-if="selectedRowKeys.length > 0" class="list-view__toolbar">
|
||||||
|
<span class="list-view__toolbar-count">已选 {{ selectedRowKeys.length }} 项</span>
|
||||||
|
<a-space>
|
||||||
|
<a-popconfirm title="确认删除选中事件?" @confirm="batchDelete">
|
||||||
|
<a-button size="small" danger>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-select
|
||||||
|
v-model:value="batchTypeId"
|
||||||
|
size="small"
|
||||||
|
placeholder="更改类型"
|
||||||
|
allowClear
|
||||||
|
style="width: 140px"
|
||||||
|
@change="batchChangeType"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="t in store.eventTypes" :key="t.id" :value="t.id">
|
||||||
|
{{ t.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<!-- ponytail: IUpdateEventRequest has no tag_ids field; batch add-tag is UI-only until backend supports it. -->
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="batchTagName"
|
||||||
|
size="small"
|
||||||
|
placeholder="添加标签"
|
||||||
|
style="width: 140px"
|
||||||
|
@search="batchAddTag"
|
||||||
|
/>
|
||||||
|
<a-button size="small" @click="clearSelection">取消选择</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-if="sortedEvents.length === 0" description="暂无日程" class="list-view__empty" />
|
||||||
|
<a-table
|
||||||
|
v-else
|
||||||
|
:dataSource="sortedEvents"
|
||||||
|
:columns="columns"
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
:pagination="{ pageSize: 50, showSizeChanger: false }"
|
||||||
|
:customRow="customRow"
|
||||||
|
:rowSelection="rowSelection"
|
||||||
|
:scroll="{ y: 'calc(100% - 64px)' }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'start_time'">
|
||||||
|
<span class="list-view__time">
|
||||||
|
{{ formatDateTime(record.start_time) }}
|
||||||
|
<span class="list-view__time-sep">→</span>
|
||||||
|
{{ formatTime(record.end_time) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'title'">
|
||||||
|
<span class="list-view__title" :class="{ 'list-view__title--invited': record.is_invited }">
|
||||||
|
{{ record.title }}
|
||||||
|
<a-tag v-if="record.is_invited" color="purple" size="small">受邀</a-tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'source'">
|
||||||
|
<EventBadge :event="record" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'event_type_id'">
|
||||||
|
<a-tag
|
||||||
|
v-if="record.event_type_id"
|
||||||
|
:color="getTypeColor(record.event_type_id)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ getTypeName(record.event_type_id) }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-else class="list-view__muted">—</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'location'">
|
||||||
|
<span v-if="record.location">{{ record.location }}</span>
|
||||||
|
<span v-else class="list-view__muted">—</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { DeleteOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import type { ICalendarEvent } from '@/api/calendar'
|
||||||
|
import EventBadge from './EventBadge.vue'
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'edit', event: ICalendarEvent): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '时间', dataIndex: 'start_time', key: 'start_time', width: 200 },
|
||||||
|
{ title: '标题', dataIndex: 'title', key: 'title' },
|
||||||
|
{ title: '来源', dataIndex: 'source', key: 'source', width: 60 },
|
||||||
|
{ title: '类型', dataIndex: 'event_type_id', key: 'event_type_id', width: 100 },
|
||||||
|
{ title: '地点', dataIndex: 'location', key: 'location', width: 140 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sortedEvents = computed(() =>
|
||||||
|
[...store.events].sort((a, b) => a.start_time.localeCompare(b.start_time)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Batch selection ---
|
||||||
|
const selectedRowKeys = ref<(string | number)[]>([])
|
||||||
|
const batchTypeId = ref<string | null>(null)
|
||||||
|
const batchTagName = ref('')
|
||||||
|
|
||||||
|
const rowSelection = {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys: (string | number)[]): void => {
|
||||||
|
selectedRowKeys.value = keys
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(): void {
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
batchTypeId.value = null
|
||||||
|
batchTagName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchDelete(): Promise<void> {
|
||||||
|
const count = selectedRowKeys.value.length
|
||||||
|
try {
|
||||||
|
for (const id of selectedRowKeys.value) {
|
||||||
|
await store.deleteEvent(id as string)
|
||||||
|
}
|
||||||
|
message.success(`已删除 ${count} 个事件`)
|
||||||
|
clearSelection()
|
||||||
|
} catch {
|
||||||
|
message.error('批量删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchChangeType(typeId: string | null): Promise<void> {
|
||||||
|
if (!typeId) return
|
||||||
|
const count = selectedRowKeys.value.length
|
||||||
|
try {
|
||||||
|
for (const id of selectedRowKeys.value) {
|
||||||
|
await store.updateEvent(id as string, { event_type_id: typeId })
|
||||||
|
}
|
||||||
|
message.success(`已更新 ${count} 个事件的类型`)
|
||||||
|
batchTypeId.value = null
|
||||||
|
clearSelection()
|
||||||
|
} catch {
|
||||||
|
message.error('批量更新类型失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchAddTag(): void {
|
||||||
|
// ponytail: IUpdateEventRequest has no tag_ids; API doesn't support batch tag assignment yet.
|
||||||
|
message.info('批量添加标签功能暂未支持(API 待扩展)')
|
||||||
|
batchTagName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function customRow(record: ICalendarEvent): Record<string, () => void> {
|
||||||
|
return {
|
||||||
|
onClick: () => emit('edit', record),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeName(id: string): string {
|
||||||
|
return store.eventTypes.find((t) => t.id === id)?.name ?? '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeColor(id: string): string {
|
||||||
|
return store.eventTypes.find((t) => t.id === id)?.color ?? 'blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(iso))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-view {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view__toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view__toolbar-count {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view__empty {
|
||||||
|
padding: var(--space-8) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view__time {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view__time-sep {
|
||||||
|
margin: 0 var(--space-1);
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view__title {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view__title--invited {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view__muted {
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
<template>
|
||||||
|
<div class="reminder-config">
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="为每种事件类型配置默认提醒规则。新建事件时将由后端(U5)自动继承对应规则。"
|
||||||
|
style="margin-bottom: var(--space-3)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-form layout="inline" style="margin-bottom: var(--space-3)">
|
||||||
|
<a-form-item label="事件类型">
|
||||||
|
<a-select
|
||||||
|
:value="selectedTypeId"
|
||||||
|
style="width: 220px"
|
||||||
|
placeholder="选择事件类型"
|
||||||
|
:options="typeOptions"
|
||||||
|
allow-clear
|
||||||
|
@change="onTypeChange"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<div v-if="!selectedTypeId" class="reminder-config__empty">
|
||||||
|
<BellOutlined />
|
||||||
|
<span>请选择事件类型以配置提醒规则</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="reminder-config__rules">
|
||||||
|
<div v-if="draftRules.length === 0" class="reminder-config__empty">
|
||||||
|
<BellOutlined />
|
||||||
|
<span>暂无提醒规则</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="reminder-config__rule-list">
|
||||||
|
<div
|
||||||
|
v-for="(rule, index) in draftRules"
|
||||||
|
:key="index"
|
||||||
|
class="reminder-config__rule"
|
||||||
|
>
|
||||||
|
<span class="reminder-config__rule-offset">
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
{{ rule.offset_minutes }} 分钟前
|
||||||
|
</span>
|
||||||
|
<div class="reminder-config__rule-channels">
|
||||||
|
<a-tag
|
||||||
|
v-for="ch in rule.channels"
|
||||||
|
:key="ch"
|
||||||
|
:color="channelColor(ch)"
|
||||||
|
>
|
||||||
|
{{ channelLabel(ch) }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<a-button type="link" danger size="small" @click="removeRule(index)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
移除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider style="margin: var(--space-3) 0" />
|
||||||
|
|
||||||
|
<div class="reminder-config__add">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="newOffset"
|
||||||
|
:min="0"
|
||||||
|
:step="5"
|
||||||
|
addon-before="提前"
|
||||||
|
addon-after="分钟"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<a-checkbox-group v-model:value="newChannels" :options="channelOptions" />
|
||||||
|
<a-button type="primary" :disabled="!canAdd" @click="addRule">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
添加规则
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: var(--space-4)">
|
||||||
|
<a-button type="primary" @click="save">
|
||||||
|
<template #icon><CheckCircleOutlined /></template>
|
||||||
|
保存规则
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { notification } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
BellOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
|
||||||
|
type ReminderChannel = 'client' | 'email' | 'webhook'
|
||||||
|
|
||||||
|
interface IReminderRule {
|
||||||
|
offset_minutes: number
|
||||||
|
channels: ReminderChannel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'calendar:reminder-rules'
|
||||||
|
|
||||||
|
const channelOptions: Array<{ label: string; value: ReminderChannel }> = [
|
||||||
|
{ label: '客户端', value: 'client' },
|
||||||
|
{ label: '邮件', value: 'email' },
|
||||||
|
{ label: 'Webhook', value: 'webhook' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ponytail: backend endpoint for per-event-type reminder rule CRUD is
|
||||||
|
// deferred — U5 inherits rules at event creation time. Until the CRUD
|
||||||
|
// route lands, rules are persisted locally in localStorage (native
|
||||||
|
// platform feature, no new deps). Functional but per-device; the upgrade
|
||||||
|
// path is a GET/PUT /event-types/{id}/reminder-rules endpoint.
|
||||||
|
function loadRules(): Record<string, IReminderRule[]> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as Record<string, IReminderRule[]>) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRules(rules: Record<string, IReminderRule[]>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(rules))
|
||||||
|
} catch {
|
||||||
|
/* quota / private mode — non-fatal, rules stay in-memory for the session */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const allRules = ref<Record<string, IReminderRule[]>>(loadRules())
|
||||||
|
const selectedTypeId = ref<string | null>(null)
|
||||||
|
const draftRules = ref<IReminderRule[]>([])
|
||||||
|
const newOffset = ref(15)
|
||||||
|
const newChannels = ref<ReminderChannel[]>(['client'])
|
||||||
|
|
||||||
|
const typeOptions = computed(() =>
|
||||||
|
store.eventTypes.map((t) => ({ label: t.name, value: t.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const canAdd = computed(() => newChannels.value.length > 0 && newOffset.value >= 0)
|
||||||
|
|
||||||
|
// When the selected type changes, load its rules into the working draft.
|
||||||
|
watch(
|
||||||
|
selectedTypeId,
|
||||||
|
(id) => {
|
||||||
|
if (!id) {
|
||||||
|
draftRules.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftRules.value = (allRules.value[id] ?? []).map((r) => ({
|
||||||
|
offset_minutes: r.offset_minutes,
|
||||||
|
channels: [...r.channels],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function onTypeChange(id: string | undefined): void {
|
||||||
|
selectedTypeId.value = id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRule(): void {
|
||||||
|
if (!canAdd.value) return
|
||||||
|
draftRules.value.push({
|
||||||
|
offset_minutes: newOffset.value,
|
||||||
|
channels: [...newChannels.value],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRule(index: number): void {
|
||||||
|
draftRules.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
if (!selectedTypeId.value) return
|
||||||
|
allRules.value[selectedTypeId.value] = draftRules.value.map((r) => ({
|
||||||
|
offset_minutes: r.offset_minutes,
|
||||||
|
channels: [...r.channels],
|
||||||
|
}))
|
||||||
|
persistRules(allRules.value)
|
||||||
|
notification.success({ message: '提醒规则已保存' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(ch: ReminderChannel): string {
|
||||||
|
return channelOptions.find((o) => o.value === ch)?.label ?? ch
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelColor(ch: ReminderChannel): string {
|
||||||
|
return ch === 'client' ? 'blue' : ch === 'email' ? 'green' : 'orange'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// ponytail: Tauri system-notification integration (showing native OS
|
||||||
|
// notifications for calendar_reminder WS events) is deferred — the
|
||||||
|
// store currently surfaces reminders via ant-design notification only.
|
||||||
|
if (store.eventTypes.length === 0) {
|
||||||
|
store.loadEventTypes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reminder-config {
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-6) 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__rule-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__rule {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--border-color-split);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__rule-offset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__rule-channels {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
<template>
|
||||||
|
<div class="sync-settings">
|
||||||
|
<!-- G4: persistent conflict alerts sourced from store.syncConflicts
|
||||||
|
(populated by the store's handleWsEvent on calendar_sync_conflict). -->
|
||||||
|
<a-alert
|
||||||
|
v-for="(c, i) in store.syncConflicts"
|
||||||
|
:key="`${c.event_id}-${i}`"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:message="`同步冲突:${c.event_title}`"
|
||||||
|
style="margin-bottom: var(--space-3)"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<div class="sync-settings__conflict">
|
||||||
|
<div><span class="sync-settings__label">提供商:</span>{{ c.provider }}</div>
|
||||||
|
<div><span class="sync-settings__label">本地修改:</span>{{ formatTime(c.local_modified) }}</div>
|
||||||
|
<div><span class="sync-settings__label">远端修改:</span>{{ formatTime(c.remote_modified) }}</div>
|
||||||
|
<div><span class="sync-settings__label">处理策略:</span>{{ c.resolution }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<div class="sync-settings__actions">
|
||||||
|
<a-button @click="showAppleForm = true">
|
||||||
|
<template #icon><AppleOutlined /></template>
|
||||||
|
添加 Apple 日历
|
||||||
|
</a-button>
|
||||||
|
<!-- ponytail: OAuth callback route (/api/v1/calendar/auth/outlook/callback)
|
||||||
|
is deferred — this button only starts the redirect flow; the backend
|
||||||
|
will redirect to Microsoft's consent page and back. -->
|
||||||
|
<a-button @click="gotoOutlookAuth">
|
||||||
|
<template #icon><WindowsOutlined /></template>
|
||||||
|
添加 Outlook
|
||||||
|
</a-button>
|
||||||
|
<a-button :loading="loading" @click="loadConfigs">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-empty
|
||||||
|
v-if="configs.length === 0 && !loading"
|
||||||
|
description="尚未配置外部日历"
|
||||||
|
style="padding: var(--space-6) 0"
|
||||||
|
/>
|
||||||
|
<div v-else class="sync-settings__list">
|
||||||
|
<a-card
|
||||||
|
v-for="cfg in configs"
|
||||||
|
:key="cfg.id"
|
||||||
|
size="small"
|
||||||
|
class="sync-settings__card"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="sync-settings__card-title">
|
||||||
|
<AppleOutlined v-if="cfg.provider === 'caldav'" />
|
||||||
|
<WindowsOutlined v-else />
|
||||||
|
<span>{{ providerLabel(cfg.provider) }}</span>
|
||||||
|
<a-badge status="success" text="已连接" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" :loading="syncingId === cfg.id" @click="onSync(cfg.id)">
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
立即同步
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" :loading="testingId === cfg.id" @click="onTest(cfg.id)">
|
||||||
|
<template #icon><ApiOutlined /></template>
|
||||||
|
测试连接
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm title="确认移除该外部日历?" @confirm="onRemove(cfg.id)">
|
||||||
|
<a-button size="small" danger>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
移除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="sync-settings__card-body">
|
||||||
|
<div class="sync-settings__row">
|
||||||
|
<span class="sync-settings__label">上次同步</span>
|
||||||
|
<span>{{ cfg.last_sync ? formatTime(cfg.last_sync) : '从未同步' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sync-settings__row">
|
||||||
|
<span class="sync-settings__label">同步范围</span>
|
||||||
|
<!-- ponytail: no PATCH endpoint for sync_scope yet — edits are
|
||||||
|
local-only until the backend adds an update route. -->
|
||||||
|
<a-select
|
||||||
|
v-model:value="cfg.sync_scope"
|
||||||
|
mode="multiple"
|
||||||
|
style="flex: 1; min-width: 200px"
|
||||||
|
placeholder="选择要同步的事件类型(留空同步全部)"
|
||||||
|
:options="scopeOptions"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
|
||||||
|
<!-- Apple Calendar (CalDAV) add form -->
|
||||||
|
<a-modal
|
||||||
|
:open="showAppleForm"
|
||||||
|
title="添加 Apple 日历 (CalDAV)"
|
||||||
|
:confirm-loading="submitting"
|
||||||
|
ok-text="添加"
|
||||||
|
cancel-text="取消"
|
||||||
|
@ok="submitApple"
|
||||||
|
@cancel="showAppleForm = false"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="CalDAV URL" required>
|
||||||
|
<a-input v-model:value="appleForm.url" placeholder="https://caldav.icloud.com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Apple ID" required>
|
||||||
|
<a-input v-model:value="appleForm.username" placeholder="apple@example.com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="App 专用密码" required>
|
||||||
|
<a-input-password v-model:value="appleForm.password" placeholder="应用专用密码" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="同步范围">
|
||||||
|
<a-select
|
||||||
|
v-model:value="appleForm.scope"
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="留空则同步全部"
|
||||||
|
:options="scopeOptions"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { notification } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
AppleOutlined,
|
||||||
|
WindowsOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import { calendarApi } from '@/api/calendar'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import type { IExternalCalendarConfig } from '@/api/calendar'
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const configs = ref<IExternalCalendarConfig[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncingId = ref<string | null>(null)
|
||||||
|
const testingId = ref<string | null>(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const showAppleForm = ref(false)
|
||||||
|
const appleForm = ref({
|
||||||
|
url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
scope: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ponytail: OAuth callback route is deferred — this URL starts the redirect
|
||||||
|
// flow only; the backend redirects to Microsoft's consent page.
|
||||||
|
const OUTLOOK_AUTH_URL = '/api/v1/calendar/auth/outlook'
|
||||||
|
|
||||||
|
const scopeOptions = computed(() =>
|
||||||
|
store.eventTypes.map((t) => ({ label: t.name, value: t.name })),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadConfigs(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.listExternalConfigs()
|
||||||
|
configs.value = resp.configs || []
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '加载外部日历配置失败' })
|
||||||
|
console.warn('listExternalConfigs failed:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSync(id: string): Promise<void> {
|
||||||
|
syncingId.value = id
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.syncNow(id)
|
||||||
|
if (resp.synced) {
|
||||||
|
notification.success({ message: '同步已触发' })
|
||||||
|
await loadConfigs()
|
||||||
|
} else {
|
||||||
|
notification.warning({ message: '同步失败', description: resp.error })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '同步失败' })
|
||||||
|
console.warn('syncNow failed:', err)
|
||||||
|
} finally {
|
||||||
|
syncingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTest(id: string): Promise<void> {
|
||||||
|
testingId.value = id
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.testExternalConnection(id)
|
||||||
|
if (resp.connected) {
|
||||||
|
notification.success({ message: '连接正常' })
|
||||||
|
} else {
|
||||||
|
notification.warning({ message: '连接失败', description: resp.error })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '测试连接失败' })
|
||||||
|
console.warn('testExternalConnection failed:', err)
|
||||||
|
} finally {
|
||||||
|
testingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await calendarApi.deleteExternalConfig(id)
|
||||||
|
configs.value = configs.value.filter((c) => c.id !== id)
|
||||||
|
notification.success({ message: '已移除外部日历' })
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '移除失败' })
|
||||||
|
console.warn('deleteExternalConfig failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitApple(): Promise<void> {
|
||||||
|
const f = appleForm.value
|
||||||
|
if (!f.url || !f.username || !f.password) {
|
||||||
|
notification.warning({ message: '请填写完整的 CalDAV 信息' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const credentials = JSON.stringify({
|
||||||
|
url: f.url,
|
||||||
|
username: f.username,
|
||||||
|
password: f.password,
|
||||||
|
})
|
||||||
|
const resp = await calendarApi.createExternalConfig({
|
||||||
|
provider: 'caldav',
|
||||||
|
credentials,
|
||||||
|
sync_scope: f.scope,
|
||||||
|
})
|
||||||
|
configs.value.push(resp.config)
|
||||||
|
showAppleForm.value = false
|
||||||
|
appleForm.value = { url: '', username: '', password: '', scope: [] }
|
||||||
|
notification.success({ message: 'Apple 日历已添加' })
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '添加失败' })
|
||||||
|
console.warn('createExternalConfig failed:', err)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoOutlookAuth(): void {
|
||||||
|
window.location.href = OUTLOOK_AUTH_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(p: IExternalCalendarConfig['provider']): string {
|
||||||
|
return p === 'caldav' ? 'Apple 日历 (CalDAV)' : 'Outlook'
|
||||||
|
}
|
||||||
|
|
||||||
|
// KTD-11: format ISO timestamps in the user's local timezone.
|
||||||
|
const dtf = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return dtf.format(new Date(iso))
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConfigs()
|
||||||
|
if (store.eventTypes.length === 0) {
|
||||||
|
store.loadEventTypes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sync-settings {
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__conflict {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
min-width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -74,6 +74,9 @@
|
||||||
<template #skills>
|
<template #skills>
|
||||||
<SkillsView />
|
<SkillsView />
|
||||||
</template>
|
</template>
|
||||||
|
<template #calendar>
|
||||||
|
<CalendarTab />
|
||||||
|
</template>
|
||||||
<template #settings>
|
<template #settings>
|
||||||
<SettingsView />
|
<SettingsView />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -104,6 +107,7 @@ import {
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import TopNav from './TopNav.vue'
|
import TopNav from './TopNav.vue'
|
||||||
|
|
@ -121,6 +125,7 @@ const KnowledgeBaseView = defineAsyncComponent(() => import('@/views/KnowledgeBa
|
||||||
const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue'))
|
const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue'))
|
||||||
const SkillsView = defineAsyncComponent(() => import('@/views/SkillsView.vue'))
|
const SkillsView = defineAsyncComponent(() => import('@/views/SkillsView.vue'))
|
||||||
const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue'))
|
const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue'))
|
||||||
|
const CalendarTab = defineAsyncComponent(() => import('./tabs/CalendarTab.vue'))
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
|
@ -152,6 +157,7 @@ const topRightTabs: QuadrantTab[] = [
|
||||||
const bottomRightTabs: QuadrantTab[] = [
|
const bottomRightTabs: QuadrantTab[] = [
|
||||||
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component },
|
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component },
|
||||||
{ key: 'skills', label: '技能', icon: AppstoreOutlined as Component },
|
{ key: 'skills', label: '技能', icon: AppstoreOutlined as Component },
|
||||||
|
{ key: 'calendar', label: '日历', icon: CalendarOutlined as Component },
|
||||||
{ key: 'settings', label: '设置', icon: SettingOutlined as Component },
|
{ key: 'settings', label: '设置', icon: SettingOutlined as Component },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<template>
|
||||||
|
<CalendarPanel />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import CalendarPanel from '@/components/calendar/CalendarPanel.vue'
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
/**
|
||||||
|
* Pinia store for calendar feature — events, event types, tags,
|
||||||
|
* invitations, and WebSocket event dispatch.
|
||||||
|
*
|
||||||
|
* ponytail: stores/chat.ts handleWsMessage dispatch is deferred — the
|
||||||
|
* orchestrator will wire the 4 calendar_* WS cases to call handleWsEvent().
|
||||||
|
* This store owns the calendar-specific state mutations + notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { notification } from 'ant-design-vue'
|
||||||
|
import { calendarApi, isCalendarEvent } from '@/api/calendar'
|
||||||
|
import type {
|
||||||
|
ICalendarEvent,
|
||||||
|
IEventType,
|
||||||
|
ITag,
|
||||||
|
IInvitation,
|
||||||
|
ICreateEventRequest,
|
||||||
|
IUpdateEventRequest,
|
||||||
|
ICreateTagRequest,
|
||||||
|
IUserSearchResult,
|
||||||
|
} from '@/api/calendar'
|
||||||
|
import type { WsServerMessage, ICalendarSyncConflictData } from '@/api/types'
|
||||||
|
|
||||||
|
export type CalendarViewMode = 'calendar' | 'card' | 'list'
|
||||||
|
|
||||||
|
export const useCalendarStore = defineStore('calendar', () => {
|
||||||
|
// --- State ---
|
||||||
|
const events = ref<ICalendarEvent[]>([])
|
||||||
|
const eventTypes = ref<IEventType[]>([])
|
||||||
|
const tags = ref<ITag[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const selectedEvent = ref<ICalendarEvent | null>(null)
|
||||||
|
const viewMode = ref<CalendarViewMode>('calendar')
|
||||||
|
const dateRange = ref<{ start: string | null; end: string | null }>({
|
||||||
|
start: null,
|
||||||
|
end: null,
|
||||||
|
})
|
||||||
|
const pendingInvitations = ref<IInvitation[]>([])
|
||||||
|
const syncConflicts = ref<ICalendarSyncConflictData[]>([])
|
||||||
|
|
||||||
|
// --- Getters ---
|
||||||
|
const upcomingEvents = computed(() => {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
return events.value
|
||||||
|
.filter((e) => e.start_time >= now)
|
||||||
|
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
/** Load events with current dateRange filter */
|
||||||
|
async function loadEvents(): Promise<void> {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.listEvents(
|
||||||
|
dateRange.value.start ?? undefined,
|
||||||
|
dateRange.value.end ?? undefined,
|
||||||
|
)
|
||||||
|
events.value = resp.events || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '加载日程失败'
|
||||||
|
console.warn('Failed to load calendar events:', err)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new event and add it to state */
|
||||||
|
async function createEvent(data: ICreateEventRequest): Promise<ICalendarEvent | null> {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.createEvent(data)
|
||||||
|
if (resp.event) {
|
||||||
|
events.value.push(resp.event)
|
||||||
|
}
|
||||||
|
return resp.event ?? null
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '创建日程失败'
|
||||||
|
console.error('Failed to create event:', err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing event in state */
|
||||||
|
async function updateEvent(id: string, data: IUpdateEventRequest): Promise<void> {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.updateEvent(id, data)
|
||||||
|
if (resp.event) {
|
||||||
|
const idx = events.value.findIndex((e) => e.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
events.value[idx] = resp.event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '更新日程失败'
|
||||||
|
console.error('Failed to update event:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete an event from server and state */
|
||||||
|
async function deleteEvent(id: string): Promise<void> {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await calendarApi.deleteEvent(id)
|
||||||
|
events.value = events.value.filter((e) => e.id !== id)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '删除日程失败'
|
||||||
|
console.error('Failed to delete event:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load event types for the current user */
|
||||||
|
async function loadEventTypes(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.listEventTypes()
|
||||||
|
eventTypes.value = resp.event_types || []
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load event types:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load tags for the current user */
|
||||||
|
async function loadTags(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.listTags()
|
||||||
|
tags.value = resp.tags || []
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load tags:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new tag and add it to state */
|
||||||
|
async function createTag(data: ICreateTagRequest): Promise<ITag | null> {
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.createTag(data)
|
||||||
|
if (resp.tag) {
|
||||||
|
tags.value.push(resp.tag)
|
||||||
|
}
|
||||||
|
return resp.tag ?? null
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create tag:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Set the calendar view mode */
|
||||||
|
function setViewMode(mode: CalendarViewMode): void {
|
||||||
|
viewMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load pending invitations for the current user */
|
||||||
|
async function loadInvitations(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.listInvitations()
|
||||||
|
pendingInvitations.value = (resp.invitations || []).filter(
|
||||||
|
(i) => i.status === 'pending',
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load invitations:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Respond to an invitation and remove it from pending list */
|
||||||
|
async function respondToInvitation(
|
||||||
|
id: string,
|
||||||
|
status: 'accepted' | 'declined' | 'tentative',
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await calendarApi.respondToInvitation(id, status)
|
||||||
|
pendingInvitations.value = pendingInvitations.value.filter((i) => i.id !== id)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '回复邀请失败'
|
||||||
|
console.error('Failed to respond to invitation:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search users by username or email (G5/A3) */
|
||||||
|
async function searchUsers(q: string): Promise<IUserSearchResult[]> {
|
||||||
|
if (!q.trim()) return []
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.searchUsers(q)
|
||||||
|
return resp.users || []
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to search users:', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch calendar WS messages to the appropriate handler.
|
||||||
|
* Handles 4 message types: calendar_event_created, calendar_reminder,
|
||||||
|
* calendar_invitation (G6), calendar_sync_conflict (G4).
|
||||||
|
* Non-calendar message types are silently ignored.
|
||||||
|
*/
|
||||||
|
function handleWsEvent(msg: WsServerMessage): void {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'calendar_event_created': {
|
||||||
|
const event = msg.data.event
|
||||||
|
if (isCalendarEvent(event)) {
|
||||||
|
// Avoid duplicates if the event was created locally
|
||||||
|
if (!events.value.some((e) => e.id === event.id)) {
|
||||||
|
events.value.push(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'calendar_reminder': {
|
||||||
|
const d = msg.data
|
||||||
|
notification.warning({
|
||||||
|
message: '日程提醒',
|
||||||
|
description: `${d.title} · ${d.start_time}`,
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'calendar_invitation': {
|
||||||
|
const d = msg.data
|
||||||
|
if (d.invitation) {
|
||||||
|
pendingInvitations.value.push(d.invitation)
|
||||||
|
}
|
||||||
|
notification.info({
|
||||||
|
message: '收到日程邀请',
|
||||||
|
description: `${d.inviter_name} 邀请你参加「${d.event_title}」`,
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'calendar_sync_conflict': {
|
||||||
|
const d = msg.data
|
||||||
|
syncConflicts.value.push(d)
|
||||||
|
notification.warning({
|
||||||
|
message: '日历同步冲突',
|
||||||
|
description: `「${d.event_title}」与 ${d.provider} 同步冲突,已按 ${d.resolution} 策略处理`,
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
events,
|
||||||
|
eventTypes,
|
||||||
|
tags,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
selectedEvent,
|
||||||
|
viewMode,
|
||||||
|
dateRange,
|
||||||
|
pendingInvitations,
|
||||||
|
syncConflicts,
|
||||||
|
// Getters
|
||||||
|
upcomingEvents,
|
||||||
|
// Actions
|
||||||
|
loadEvents,
|
||||||
|
createEvent,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
loadEventTypes,
|
||||||
|
loadTags,
|
||||||
|
createTag,
|
||||||
|
setViewMode,
|
||||||
|
loadInvitations,
|
||||||
|
respondToInvitation,
|
||||||
|
searchUsers,
|
||||||
|
handleWsEvent,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,451 @@
|
||||||
|
"""REST API routes for calendar operations (U2).
|
||||||
|
|
||||||
|
Thin wrapper over CalendarService. All business logic lives in the
|
||||||
|
service layer — routes handle HTTP concerns (auth, request validation).
|
||||||
|
|
||||||
|
Endpoints (all under /api/v1/calendar):
|
||||||
|
- POST /calendar/events — create event
|
||||||
|
- GET /calendar/events — list with filters (start, end, type_id, tag_id)
|
||||||
|
- GET /calendar/events/{event_id} — get single event
|
||||||
|
- PATCH /calendar/events/{event_id} — update event
|
||||||
|
- DELETE /calendar/events/{event_id} — delete event
|
||||||
|
- POST /calendar/events/{event_id}/invitations — invite user by email
|
||||||
|
- POST /calendar/invitations/{invitation_id}/respond — accept/decline/tentative
|
||||||
|
- GET /calendar/invitations — list invitations for current user
|
||||||
|
- GET /calendar/users/search?q=xxx — search users (G5/A3)
|
||||||
|
- GET /calendar/event-types — list event types
|
||||||
|
- POST /calendar/event-types — create event type
|
||||||
|
- PATCH /calendar/event-types/{type_id} — update event type
|
||||||
|
- GET /calendar/tags — list tags
|
||||||
|
- POST /calendar/tags — create tag
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from agentkit.calendar.sync.ics_provider import ICSProvider
|
||||||
|
from agentkit.server.auth.dependencies import require_authenticated
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/calendar", tags=["calendar"])
|
||||||
|
|
||||||
|
_VALID_INVITATION_STATUSES = {"accepted", "declined", "tentative"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service accessor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_calendar_service(request: Request):
|
||||||
|
"""Get CalendarService from app.state. Raises 503 if not initialized."""
|
||||||
|
service = getattr(request.app.state, "calendar_service", None)
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Calendar service not available. Server may not have initialized it.",
|
||||||
|
)
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request / response models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CreateEventRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
start_time: str
|
||||||
|
end_time: str
|
||||||
|
description: str = ""
|
||||||
|
location: str = ""
|
||||||
|
is_all_day: bool = False
|
||||||
|
event_type_id: str | None = None
|
||||||
|
rrule: str | None = None
|
||||||
|
tag_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateEventRequest(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
start_time: str | None = None
|
||||||
|
end_time: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
is_all_day: bool | None = None
|
||||||
|
event_type_id: str | None = None
|
||||||
|
rrule: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateEventTypeRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
color: str = "#4A90D9"
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateEventTypeRequest(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
is_default: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTagRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvitationRequest(BaseModel):
|
||||||
|
invitee_email: str
|
||||||
|
|
||||||
|
|
||||||
|
class RespondInvitationRequest(BaseModel):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events")
|
||||||
|
async def create_event(
|
||||||
|
body: CreateEventRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new calendar event."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
event = await service.create_event(
|
||||||
|
user_id=user["user_id"],
|
||||||
|
title=body.title,
|
||||||
|
start_time=body.start_time,
|
||||||
|
end_time=body.end_time,
|
||||||
|
description=body.description,
|
||||||
|
location=body.location,
|
||||||
|
is_all_day=body.is_all_day,
|
||||||
|
event_type_id=body.event_type_id,
|
||||||
|
rrule=body.rrule,
|
||||||
|
source="manual",
|
||||||
|
tag_ids=body.tag_ids,
|
||||||
|
)
|
||||||
|
return {"success": True, "event": event.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def list_events(
|
||||||
|
request: Request,
|
||||||
|
start: str | None = Query(None),
|
||||||
|
end: str | None = Query(None),
|
||||||
|
type_id: str | None = Query(None),
|
||||||
|
tag_id: str | None = Query(None),
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List events for the current user with optional filters."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
events = await service.list_events(
|
||||||
|
user_id=user["user_id"],
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
event_type_id=type_id,
|
||||||
|
tag_id=tag_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"events": [e.to_dict() for e in events],
|
||||||
|
"count": len(events),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/{event_id}")
|
||||||
|
async def get_event(
|
||||||
|
event_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get a single event by id."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
event = await service.get_event(event_id)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if event.user_id != user["user_id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
return {"success": True, "event": event.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/events/{event_id}")
|
||||||
|
async def update_event(
|
||||||
|
event_id: str,
|
||||||
|
body: UpdateEventRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update specific fields of an event."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
event = await service.get_event(event_id)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if event.user_id != user["user_id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
# Build fields dict from non-None values
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
name: value
|
||||||
|
for name, value in body.model_dump(exclude_unset=True).items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
return {"success": True, "event": event.to_dict(), "updated": False}
|
||||||
|
|
||||||
|
updated = await service.update_event(event_id, fields)
|
||||||
|
refreshed = await service.get_event(event_id)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"event": refreshed.to_dict() if refreshed else event.to_dict(),
|
||||||
|
"updated": updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/events/{event_id}")
|
||||||
|
async def delete_event(
|
||||||
|
event_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delete an event."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
event = await service.get_event(event_id)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if event.user_id != user["user_id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
deleted = await service.delete_event(event_id)
|
||||||
|
return {"success": True, "deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invitation endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/{event_id}/invitations")
|
||||||
|
async def create_invitation(
|
||||||
|
event_id: str,
|
||||||
|
body: CreateInvitationRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Invite a user to an event by email."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
event = await service.get_event(event_id)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if event.user_id != user["user_id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the event owner can invite")
|
||||||
|
|
||||||
|
invitation = await service.create_invitation(
|
||||||
|
event_id=event_id,
|
||||||
|
inviter_user_id=user["user_id"],
|
||||||
|
invitee_email=body.invitee_email,
|
||||||
|
)
|
||||||
|
return {"success": True, "invitation": invitation.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invitations/{invitation_id}/respond")
|
||||||
|
async def respond_to_invitation(
|
||||||
|
invitation_id: str,
|
||||||
|
body: RespondInvitationRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Respond to an invitation (accept/decline/tentative)."""
|
||||||
|
if body.status not in _VALID_INVITATION_STATUSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid status. Must be one of: {sorted(_VALID_INVITATION_STATUSES)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
invitation = await service.get_invitation(invitation_id)
|
||||||
|
if invitation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
email = await service.get_user_email(user["user_id"])
|
||||||
|
if email is None or invitation.invitee_email != email:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the invitee can respond")
|
||||||
|
updated = await service.respond_to_invitation(invitation_id, body.status)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
return {"success": True, "status": body.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/invitations")
|
||||||
|
async def list_invitations(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List invitations for the current user (by email)."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
email = await service.get_user_email(user["user_id"])
|
||||||
|
if email is None:
|
||||||
|
return {"success": True, "invitations": [], "count": 0}
|
||||||
|
invitations = await service.list_invitations(email)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"invitations": [inv.to_dict() for inv in invitations],
|
||||||
|
"count": len(invitations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User search endpoint (G5/A3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/search")
|
||||||
|
async def search_users(
|
||||||
|
request: Request,
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Search users by username or email. Returns top 10 matches.
|
||||||
|
|
||||||
|
Only username and email are returned — never user_id or password
|
||||||
|
fields (least-privilege, G5/A3).
|
||||||
|
"""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
users = await service.search_users(q)
|
||||||
|
return {"success": True, "users": users, "count": len(users)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event Type endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/event-types")
|
||||||
|
async def list_event_types(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List all event types for the current user."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
types = await service.list_event_types(user["user_id"])
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"event_types": [t.to_dict() for t in types],
|
||||||
|
"count": len(types),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/event-types")
|
||||||
|
async def create_event_type(
|
||||||
|
body: CreateEventTypeRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new event type."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
et = await service.create_event_type(
|
||||||
|
user_id=user["user_id"],
|
||||||
|
name=body.name,
|
||||||
|
color=body.color,
|
||||||
|
)
|
||||||
|
return {"success": True, "event_type": et.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/event-types/{type_id}")
|
||||||
|
async def update_event_type(
|
||||||
|
type_id: str,
|
||||||
|
body: UpdateEventTypeRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update specific fields of an event type."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
et = await service.get_event_type(type_id)
|
||||||
|
if et is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event type not found")
|
||||||
|
if et.user_id != user["user_id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
|
for name, value in body.model_dump(exclude_unset=True).items():
|
||||||
|
if value is not None:
|
||||||
|
fields[name] = value
|
||||||
|
if not fields:
|
||||||
|
return {"success": True, "updated": False}
|
||||||
|
updated = await service.update_event_type(type_id, fields)
|
||||||
|
return {"success": True, "updated": updated}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tag endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
async def list_tags(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List all tags for the current user."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
tags = await service.list_tags(user["user_id"])
|
||||||
|
return {"success": True, "tags": [t.to_dict() for t in tags], "count": len(tags)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tags")
|
||||||
|
async def create_tag(
|
||||||
|
body: CreateTagRequest,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new tag."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
tag = await service.create_tag(user_id=user["user_id"], name=body.name)
|
||||||
|
return {"success": True, "tag": tag.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ICS import/export (U8)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-ics")
|
||||||
|
async def import_ics(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Import events from an uploaded .ics file."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
content = await file.read()
|
||||||
|
provider = ICSProvider(service)
|
||||||
|
try:
|
||||||
|
result = await provider.import_ics(content, user["user_id"])
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
return {"success": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export-ics")
|
||||||
|
async def export_ics(
|
||||||
|
request: Request,
|
||||||
|
start: str | None = Query(None),
|
||||||
|
end: str | None = Query(None),
|
||||||
|
user: dict = Depends(require_authenticated),
|
||||||
|
) -> Response:
|
||||||
|
"""Export the current user's events to a downloadable .ics file."""
|
||||||
|
service = _get_calendar_service(request)
|
||||||
|
events = await service.list_events(user_id=user["user_id"], start=start, end=end)
|
||||||
|
provider = ICSProvider(service)
|
||||||
|
ics_bytes = provider.export_ics(events)
|
||||||
|
return Response(
|
||||||
|
content=ics_bytes,
|
||||||
|
media_type="text/calendar",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="calendar.ics"'},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""CalendarTool — Agent tool for calendar event CRUD via ReAct integration.
|
||||||
|
|
||||||
|
Wraps CalendarService so the LLM can create, query, update, and delete
|
||||||
|
calendar events via function calling. The tool delegates all business logic
|
||||||
|
to CalendarService — it only handles input validation, name→id resolution
|
||||||
|
for event types and tags, and result formatting.
|
||||||
|
|
||||||
|
The tool trusts the caller (the agent framework) to provide the correct
|
||||||
|
user_id; it does not perform auth (same pattern as DocumentTool).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
from agentkit.tools.base import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarTool(Tool):
|
||||||
|
"""Agent tool for calendar event management.
|
||||||
|
|
||||||
|
Actions: create_event, query_events, update_event, delete_event.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, calendar_service: CalendarService):
|
||||||
|
super().__init__(
|
||||||
|
name="calendar",
|
||||||
|
description=(
|
||||||
|
"Create, query, update, and delete calendar events. "
|
||||||
|
"Actions: create_event, query_events, update_event, delete_event."
|
||||||
|
),
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"create_event",
|
||||||
|
"query_events",
|
||||||
|
"update_event",
|
||||||
|
"delete_event",
|
||||||
|
],
|
||||||
|
"description": "Calendar operation to perform.",
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "User ID owning the calendar events.",
|
||||||
|
},
|
||||||
|
"event_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event ID (for update_event and delete_event).",
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event title (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event start time, ISO 8601 UTC (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"end_time": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event end time, ISO 8601 UTC (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event description (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event location (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"is_all_day": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the event is all-day (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"event_type_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event type name; looked up or created if missing (create_event).",
|
||||||
|
},
|
||||||
|
"tag_names": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Tag names; each looked up or created if missing (create_event).",
|
||||||
|
},
|
||||||
|
"rrule": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "RFC 5545 RRULE recurrence string, e.g. FREQ=WEEKLY;BYDAY=MO;COUNT=10 (create_event).",
|
||||||
|
},
|
||||||
|
"conversation_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Conversation ID to associate with the event (create_event).",
|
||||||
|
},
|
||||||
|
"start_date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Range start, ISO 8601 UTC (query_events).",
|
||||||
|
},
|
||||||
|
"end_date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Range end, ISO 8601 UTC (query_events).",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of events to return (query_events).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["action", "user_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._service = calendar_service
|
||||||
|
|
||||||
|
async def execute(self, **kwargs) -> dict[str, Any]:
|
||||||
|
action = kwargs.get("action")
|
||||||
|
|
||||||
|
if action == "create_event":
|
||||||
|
return await self._create_event(**kwargs)
|
||||||
|
if action == "query_events":
|
||||||
|
return await self._query_events(**kwargs)
|
||||||
|
if action == "update_event":
|
||||||
|
return await self._update_event(**kwargs)
|
||||||
|
if action == "delete_event":
|
||||||
|
return await self._delete_event(**kwargs)
|
||||||
|
return {"success": False, "error": f"Unknown action: {action!r}"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# create_event
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _create_event(self, **kwargs) -> dict[str, Any]:
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
title = kwargs.get("title")
|
||||||
|
start_time = kwargs.get("start_time")
|
||||||
|
end_time = kwargs.get("end_time")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return {"success": False, "error": "Missing required field: user_id"}
|
||||||
|
if not title:
|
||||||
|
return {"success": False, "error": "Missing required field: title"}
|
||||||
|
if not start_time:
|
||||||
|
return {"success": False, "error": "Missing required field: start_time"}
|
||||||
|
if not end_time:
|
||||||
|
return {"success": False, "error": "Missing required field: end_time"}
|
||||||
|
|
||||||
|
description = kwargs.get("description", "")
|
||||||
|
location = kwargs.get("location", "")
|
||||||
|
is_all_day = kwargs.get("is_all_day", False)
|
||||||
|
rrule = kwargs.get("rrule")
|
||||||
|
conversation_id = kwargs.get("conversation_id")
|
||||||
|
|
||||||
|
# Resolve event_type_name → event_type_id (look up or create)
|
||||||
|
event_type_id: str | None = None
|
||||||
|
event_type_name = kwargs.get("event_type_name")
|
||||||
|
if event_type_name:
|
||||||
|
event_type_id = await self._resolve_event_type_id(user_id, event_type_name)
|
||||||
|
|
||||||
|
# Resolve tag_names → tag_ids (look up or create each)
|
||||||
|
tag_ids: list[str] | None = None
|
||||||
|
tag_names = kwargs.get("tag_names")
|
||||||
|
if tag_names:
|
||||||
|
tag_ids = await self._resolve_tag_ids(user_id, tag_names)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = await self._service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
description=description,
|
||||||
|
location=location,
|
||||||
|
is_all_day=is_all_day,
|
||||||
|
event_type_id=event_type_id,
|
||||||
|
rrule=rrule,
|
||||||
|
source="agent",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
tag_ids=tag_ids,
|
||||||
|
)
|
||||||
|
return {"success": True, "event": event.to_dict()}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"create_event failed: {e}"}
|
||||||
|
|
||||||
|
async def _resolve_event_type_id(self, user_id: str, name: str) -> str | None:
|
||||||
|
"""Look up an event type by name for the user; create if not found."""
|
||||||
|
existing = await self._service.list_event_types(user_id)
|
||||||
|
for et in existing:
|
||||||
|
if et.name == name:
|
||||||
|
return et.id
|
||||||
|
et = await self._service.create_event_type(user_id, name)
|
||||||
|
return et.id
|
||||||
|
|
||||||
|
async def _resolve_tag_ids(self, user_id: str, names: list[str]) -> list[str]:
|
||||||
|
"""Look up tags by name for the user; create each if not found."""
|
||||||
|
existing = await self._service.list_tags(user_id)
|
||||||
|
existing_by_name = {t.name: t.id for t in existing}
|
||||||
|
tag_ids: list[str] = []
|
||||||
|
for name in names:
|
||||||
|
if name in existing_by_name:
|
||||||
|
tag_ids.append(existing_by_name[name])
|
||||||
|
else:
|
||||||
|
tag = await self._service.create_tag(user_id, name)
|
||||||
|
tag_ids.append(tag.id)
|
||||||
|
return tag_ids
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# query_events
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _query_events(self, **kwargs) -> dict[str, Any]:
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return {"success": False, "error": "Missing required field: user_id"}
|
||||||
|
|
||||||
|
start = kwargs.get("start_date")
|
||||||
|
end = kwargs.get("end_date")
|
||||||
|
limit = kwargs.get("limit")
|
||||||
|
|
||||||
|
try:
|
||||||
|
events = await self._service.list_events(
|
||||||
|
user_id=user_id,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
events = events[:limit]
|
||||||
|
return {"success": True, "events": [e.to_dict() for e in events]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"query_events failed: {e}"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# update_event
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _update_event(self, **kwargs) -> dict[str, Any]:
|
||||||
|
event_id = kwargs.get("event_id")
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
if not event_id:
|
||||||
|
return {"success": False, "error": "Missing required field: event_id"}
|
||||||
|
if not user_id:
|
||||||
|
return {"success": False, "error": "Missing required field: user_id"}
|
||||||
|
|
||||||
|
# Ownership check
|
||||||
|
event = await self._service.get_event(event_id)
|
||||||
|
if event is None:
|
||||||
|
return {"success": False, "error": "Event not found"}
|
||||||
|
if event.user_id != user_id:
|
||||||
|
return {"success": False, "error": "Permission denied"}
|
||||||
|
|
||||||
|
# Build fields dict from updatable params (only those explicitly provided)
|
||||||
|
updatable = ["title", "description", "start_time", "end_time", "location", "is_all_day"]
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
|
for key in updatable:
|
||||||
|
if key in kwargs and kwargs[key] is not None:
|
||||||
|
fields[key] = kwargs[key]
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
return {"success": False, "error": "No fields to update"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = await self._service.update_event(event_id, fields)
|
||||||
|
if not updated:
|
||||||
|
return {"success": False, "error": f"Event not found: {event_id}"}
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"update_event failed: {e}"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# delete_event
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _delete_event(self, **kwargs) -> dict[str, Any]:
|
||||||
|
event_id = kwargs.get("event_id")
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
if not event_id:
|
||||||
|
return {"success": False, "error": "Missing required field: event_id"}
|
||||||
|
if not user_id:
|
||||||
|
return {"success": False, "error": "Missing required field: user_id"}
|
||||||
|
|
||||||
|
# Ownership check
|
||||||
|
event = await self._service.get_event(event_id)
|
||||||
|
if event is None:
|
||||||
|
return {"success": False, "error": "Event not found"}
|
||||||
|
if event.user_id != user_id:
|
||||||
|
return {"success": False, "error": "Permission denied"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = await self._service.delete_event(event_id)
|
||||||
|
if not deleted:
|
||||||
|
return {"success": False, "error": f"Event not found: {event_id}"}
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"delete_event failed: {e}"}
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
"""Tests for calendar DB CRUD (U1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
add_tag_to_event,
|
||||||
|
delete_event,
|
||||||
|
delete_event_type,
|
||||||
|
get_event,
|
||||||
|
get_event_tags,
|
||||||
|
init_calendar_db,
|
||||||
|
insert_event,
|
||||||
|
insert_event_type,
|
||||||
|
insert_external_config,
|
||||||
|
insert_invitation,
|
||||||
|
insert_reminder_rule,
|
||||||
|
insert_tag,
|
||||||
|
list_event_types,
|
||||||
|
list_events,
|
||||||
|
list_external_configs,
|
||||||
|
list_invitations,
|
||||||
|
list_reminder_rules_for_event,
|
||||||
|
list_reminder_rules_for_type,
|
||||||
|
list_tags,
|
||||||
|
update_event,
|
||||||
|
update_event_type,
|
||||||
|
update_invitation_status,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import (
|
||||||
|
CalendarEvent,
|
||||||
|
EventType,
|
||||||
|
ExternalCalendarConfig,
|
||||||
|
Invitation,
|
||||||
|
ReminderRule,
|
||||||
|
Tag,
|
||||||
|
_now_iso,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(
|
||||||
|
event_id: str = "evt-1",
|
||||||
|
user_id: str = "user-1",
|
||||||
|
title: str = "Test Event",
|
||||||
|
start: str = "2026-07-01T10:00:00+00:00",
|
||||||
|
end: str = "2026-07-01T11:00:00+00:00",
|
||||||
|
**kwargs,
|
||||||
|
) -> CalendarEvent:
|
||||||
|
now = _now_iso()
|
||||||
|
return CalendarEvent(
|
||||||
|
id=event_id,
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
start_time=start,
|
||||||
|
end_time=end,
|
||||||
|
last_modified=now,
|
||||||
|
created_at=now,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init_calendar_db
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_calendar_db_creates_all_tables(db_path: Path) -> None:
|
||||||
|
"""init_calendar_db creates all 8 tables."""
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
async def check():
|
||||||
|
async with aiosqlite.connect(str(db_path)) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||||
|
)
|
||||||
|
tables = {row[0] for row in await cursor.fetchall()}
|
||||||
|
return tables
|
||||||
|
|
||||||
|
tables = asyncio.run(check())
|
||||||
|
expected = {
|
||||||
|
"calendar_events",
|
||||||
|
"calendar_event_types",
|
||||||
|
"calendar_tags",
|
||||||
|
"calendar_event_tags",
|
||||||
|
"calendar_reminder_rules",
|
||||||
|
"calendar_reminder_deliveries",
|
||||||
|
"calendar_external_configs",
|
||||||
|
"calendar_invitations",
|
||||||
|
}
|
||||||
|
assert expected.issubset(tables), f"Missing tables: {expected - tables}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_and_get_event_roundtrip(db_path: Path) -> None:
|
||||||
|
"""Insert event, fetch by id, all fields preserved including is_invited."""
|
||||||
|
event = _make_event(is_invited=True, source="agent", conversation_id="conv-1")
|
||||||
|
asyncio.run(insert_event(event, db_path))
|
||||||
|
|
||||||
|
fetched = asyncio.run(get_event("evt-1", db_path))
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.id == "evt-1"
|
||||||
|
assert fetched.title == "Test Event"
|
||||||
|
assert fetched.is_invited is True
|
||||||
|
assert fetched.source == "agent"
|
||||||
|
assert fetched.conversation_id == "conv-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_events_by_user_filtered_by_date_range(db_path: Path) -> None:
|
||||||
|
"""Insert 3 events, query range covering 2."""
|
||||||
|
for i, day in enumerate([1, 15, 28]):
|
||||||
|
evt = _make_event(
|
||||||
|
event_id=f"evt-{i}",
|
||||||
|
start=f"2026-07-{day:02d}T10:00:00+00:00",
|
||||||
|
end=f"2026-07-{day:02d}T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
asyncio.run(insert_event(evt, db_path))
|
||||||
|
|
||||||
|
events = asyncio.run(
|
||||||
|
list_events(
|
||||||
|
"user-1",
|
||||||
|
start="2026-07-10T00:00:00+00:00",
|
||||||
|
end="2026-07-20T00:00:00+00:00",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].id == "evt-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_event_modifies_fields(db_path: Path) -> None:
|
||||||
|
"""Insert, update title, verify."""
|
||||||
|
asyncio.run(insert_event(_make_event(), db_path))
|
||||||
|
updated = asyncio.run(update_event("evt-1", {"title": "Updated"}, db_path))
|
||||||
|
assert updated is True
|
||||||
|
|
||||||
|
fetched = asyncio.run(get_event("evt-1", db_path))
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.title == "Updated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_event_removes_record(db_path: Path) -> None:
|
||||||
|
"""Insert, delete, verify gone."""
|
||||||
|
asyncio.run(insert_event(_make_event(), db_path))
|
||||||
|
deleted = asyncio.run(delete_event("evt-1", db_path))
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
fetched = asyncio.run(get_event("evt-1", db_path))
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event Type CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_type_crud(db_path: Path) -> None:
|
||||||
|
"""Create/list/update/delete event types."""
|
||||||
|
et = EventType(id="type-1", user_id="user-1", name="会议", color="#FF0000")
|
||||||
|
asyncio.run(insert_event_type(et, db_path))
|
||||||
|
|
||||||
|
types = asyncio.run(list_event_types("user-1", db_path))
|
||||||
|
assert len(types) == 1
|
||||||
|
assert types[0].name == "会议"
|
||||||
|
assert types[0].color == "#FF0000"
|
||||||
|
|
||||||
|
asyncio.run(update_event_type("type-1", {"name": "Meeting"}, db_path))
|
||||||
|
types = asyncio.run(list_event_types("user-1", db_path))
|
||||||
|
assert types[0].name == "Meeting"
|
||||||
|
|
||||||
|
deleted = asyncio.run(delete_event_type("type-1", db_path))
|
||||||
|
assert deleted is True
|
||||||
|
types = asyncio.run(list_event_types("user-1", db_path))
|
||||||
|
assert len(types) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tag CRUD + many-to-many
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_many_to_many(db_path: Path) -> None:
|
||||||
|
"""Event with 3 tags, query by tag returns event."""
|
||||||
|
asyncio.run(insert_event(_make_event(), db_path))
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
tag = Tag(id=f"tag-{i}", user_id="user-1", name=f"Tag{i}")
|
||||||
|
asyncio.run(insert_tag(tag, db_path))
|
||||||
|
asyncio.run(add_tag_to_event("evt-1", f"tag-{i}", db_path))
|
||||||
|
|
||||||
|
# Get tags for event
|
||||||
|
tags = asyncio.run(get_event_tags("evt-1", db_path))
|
||||||
|
assert len(tags) == 3
|
||||||
|
|
||||||
|
# List events filtered by tag
|
||||||
|
events = asyncio.run(list_events("user-1", tag_id="tag-1", db_path=db_path))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].id == "evt-1"
|
||||||
|
|
||||||
|
# List all tags
|
||||||
|
all_tags = asyncio.run(list_tags("user-1", db_path))
|
||||||
|
assert len(all_tags) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reminder Rule CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_rule_crud(db_path: Path) -> None:
|
||||||
|
"""Create rule for event, create default rule for type."""
|
||||||
|
asyncio.run(insert_event(_make_event(), db_path))
|
||||||
|
asyncio.run(
|
||||||
|
insert_event_type(EventType(id="type-1", user_id="user-1", name="Meeting"), db_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Event-level rule
|
||||||
|
rule1 = ReminderRule(
|
||||||
|
id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client", "email"]
|
||||||
|
)
|
||||||
|
asyncio.run(insert_reminder_rule(rule1, db_path))
|
||||||
|
|
||||||
|
# Type-level default rule
|
||||||
|
rule2 = ReminderRule(
|
||||||
|
id="rule-2", event_type_id="type-1", offset_minutes=-1440, channels=["email"]
|
||||||
|
)
|
||||||
|
asyncio.run(insert_reminder_rule(rule2, db_path))
|
||||||
|
|
||||||
|
event_rules = asyncio.run(list_reminder_rules_for_event("evt-1", db_path))
|
||||||
|
assert len(event_rules) == 1
|
||||||
|
assert event_rules[0].offset_minutes == -15
|
||||||
|
assert event_rules[0].channels == ["client", "email"]
|
||||||
|
|
||||||
|
type_rules = asyncio.run(list_reminder_rules_for_type("type-1", db_path))
|
||||||
|
assert len(type_rules) == 1
|
||||||
|
assert type_rules[0].offset_minutes == -1440
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# External Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_config_stores_encrypted_credentials(db_path: Path) -> None:
|
||||||
|
"""Insert config, verify credentials field is opaque (stored as-is)."""
|
||||||
|
config = ExternalCalendarConfig(
|
||||||
|
id="cfg-1",
|
||||||
|
user_id="user-1",
|
||||||
|
provider="caldav",
|
||||||
|
credentials='{"url":"https://caldav.icloud.com","user":"alice","pass":"xxx"}',
|
||||||
|
sync_frequency=15,
|
||||||
|
sync_scope=["type-1", "type-2"],
|
||||||
|
)
|
||||||
|
asyncio.run(insert_external_config(config, db_path))
|
||||||
|
|
||||||
|
configs = asyncio.run(list_external_configs("user-1", db_path))
|
||||||
|
assert len(configs) == 1
|
||||||
|
assert configs[0].provider == "caldav"
|
||||||
|
assert configs[0].sync_frequency == 15
|
||||||
|
assert configs[0].sync_scope == ["type-1", "type-2"]
|
||||||
|
# Credentials stored as-is (encryption happens at service layer)
|
||||||
|
assert "caldav.icloud.com" in configs[0].credentials
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invitation CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_invitation_crud(db_path: Path) -> None:
|
||||||
|
"""Create invitation, list by email, update status."""
|
||||||
|
asyncio.run(insert_event(_make_event(), db_path))
|
||||||
|
|
||||||
|
inv = Invitation(
|
||||||
|
id="inv-1",
|
||||||
|
event_id="evt-1",
|
||||||
|
inviter_user_id="user-1",
|
||||||
|
invitee_email="alice@example.com",
|
||||||
|
)
|
||||||
|
asyncio.run(insert_invitation(inv, db_path))
|
||||||
|
|
||||||
|
invs = asyncio.run(list_invitations("alice@example.com", db_path))
|
||||||
|
assert len(invs) == 1
|
||||||
|
assert invs[0].status == "pending"
|
||||||
|
|
||||||
|
now = _now_iso()
|
||||||
|
asyncio.run(update_invitation_status("inv-1", "accepted", now, db_path))
|
||||||
|
|
||||||
|
invs = asyncio.run(list_invitations("alice@example.com", db_path))
|
||||||
|
assert invs[0].status == "accepted"
|
||||||
|
assert invs[0].responded_at == now
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
"""Tests for PostProcessingExtractor (U4)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentkit.calendar.db import init_calendar_db
|
||||||
|
from agentkit.calendar.extraction import PostProcessingExtractor
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calendar_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(calendar_db_path: Path) -> CalendarService:
|
||||||
|
return CalendarService(db_path=calendar_db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def extractor(service: CalendarService) -> PostProcessingExtractor:
|
||||||
|
return PostProcessingExtractor(calendar_service=service)
|
||||||
|
|
||||||
|
|
||||||
|
class MockLLMGateway:
|
||||||
|
"""Minimal async mock for the LLM gateway."""
|
||||||
|
|
||||||
|
def __init__(self, response: str) -> None:
|
||||||
|
self.response = response
|
||||||
|
self.called = False
|
||||||
|
self.call_count = 0
|
||||||
|
|
||||||
|
async def acomplete(self, messages, temperature: float = 0.1) -> str:
|
||||||
|
self.called = True
|
||||||
|
self.call_count += 1
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Keyword regex gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyword_regex_matches_chinese_time_words(extractor: PostProcessingExtractor) -> None:
|
||||||
|
"""Chinese time words trigger the keyword gate."""
|
||||||
|
assert extractor._KEYWORD_RE.search("明天下午3点开会") is not None
|
||||||
|
assert extractor._KEYWORD_RE.search("后天截止") is not None
|
||||||
|
assert extractor._KEYWORD_RE.search("下周安排一下") is not None
|
||||||
|
# No time words — should not match
|
||||||
|
assert extractor._KEYWORD_RE.search("继续优化吧") is None
|
||||||
|
assert extractor._KEYWORD_RE.search("好的,没问题") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyword_regex_matches_english_time_words(extractor: PostProcessingExtractor) -> None:
|
||||||
|
"""English time words trigger the keyword gate (case-insensitive)."""
|
||||||
|
assert extractor._KEYWORD_RE.search("deadline tomorrow") is not None
|
||||||
|
assert extractor._KEYWORD_RE.search("Schedule a meeting") is not None
|
||||||
|
assert extractor._KEYWORD_RE.search("set a reminder") is not None
|
||||||
|
# No time words — should not match
|
||||||
|
assert extractor._KEYWORD_RE.search("hello world") is None
|
||||||
|
assert extractor._KEYWORD_RE.search("how are you") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Keyword gate skips LLM
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_keyword_skips_llm_call(
|
||||||
|
extractor: PostProcessingExtractor, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""No keyword in text → LLM gateway never called, returns []."""
|
||||||
|
gateway = MockLLMGateway(response="[]")
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="好的,我们继续优化代码吧",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
assert gateway.called is False
|
||||||
|
assert gateway.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Keyword hit triggers LLM extraction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_keyword_hit_triggers_llm_extraction(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""Keyword present → LLM called → event created with source='post_extract'."""
|
||||||
|
llm_response = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "团队会议",
|
||||||
|
"start_time": "2026-07-01T10:00:00+00:00",
|
||||||
|
"end_time": "2026-07-01T11:00:00+00:00",
|
||||||
|
"description": "周会",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
gateway = MockLLMGateway(response=llm_response)
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="明天下午3点开个会",
|
||||||
|
conversation_id="conv-42",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert gateway.called is True
|
||||||
|
assert gateway.call_count == 1
|
||||||
|
assert len(result) == 1
|
||||||
|
event = result[0]
|
||||||
|
assert event["title"] == "团队会议"
|
||||||
|
assert event["source"] == "post_extract"
|
||||||
|
assert event["start_time"] == "2026-07-01T10:00:00+00:00"
|
||||||
|
assert event["end_time"] == "2026-07-01T11:00:00+00:00"
|
||||||
|
assert event["description"] == "周会"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM returns empty array
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_llm_returns_empty_array_creates_nothing(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""LLM returns [] → no events created."""
|
||||||
|
gateway = MockLLMGateway(response="[]")
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="明天有个安排",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
assert gateway.called is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Malformed LLM response
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_malformed_llm_response_handled_gracefully(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""Invalid JSON response → no crash, returns []."""
|
||||||
|
gateway = MockLLMGateway(response="this is not json at all")
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="明天开会",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_malformed_llm_response_json_object_not_array(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""JSON object (not array) → treated as no events."""
|
||||||
|
gateway = MockLLMGateway(response='{"title": "会议"}')
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="明天开会",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# conversation_id traceability
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extracted_events_have_conversation_id(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""Extracted events carry the conversation_id for traceability."""
|
||||||
|
llm_response = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "评审会",
|
||||||
|
"start_time": "2026-07-01T14:00:00+00:00",
|
||||||
|
"end_time": "2026-07-01T15:00:00+00:00",
|
||||||
|
"description": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
gateway = MockLLMGateway(response=llm_response)
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="后天下午2点评审会",
|
||||||
|
conversation_id="conv-trace-99",
|
||||||
|
user_id="user-7",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["conversation_id"] == "conv-trace-99"
|
||||||
|
assert result[0]["user_id"] == "user-7"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async / non-blocking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extraction_does_not_block_chat_response(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""extract() is awaitable and returns a list (inherent async guarantee)."""
|
||||||
|
gateway = MockLLMGateway(response="[]")
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
# Awaiting must yield a list, not a coroutine or other object.
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="明天deadline",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# No LLM gateway configured
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_llm_gateway_returns_empty(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""llm_gateway=None + keyword hit → returns [] without error."""
|
||||||
|
assert extractor.llm_gateway is None
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="明天开会",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Code-fenced LLM response
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_llm_response_with_code_fences_parsed(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""LLM wraps JSON in ```json ... ``` fences → parsed correctly."""
|
||||||
|
payload = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "站会",
|
||||||
|
"start_time": "2026-07-01T09:00:00+00:00",
|
||||||
|
"end_time": "2026-07-01T09:15:00+00:00",
|
||||||
|
"description": "每日站会",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
fenced = f"```json\n{payload}\n```"
|
||||||
|
gateway = MockLLMGateway(response=fenced)
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="明天上午开站会",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["title"] == "站会"
|
||||||
|
assert result[0]["description"] == "每日站会"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Multiple events
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_multiple_events_extracted(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""LLM returns 3 events → 3 events created."""
|
||||||
|
llm_response = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "会议A",
|
||||||
|
"start_time": "2026-07-01T09:00:00+00:00",
|
||||||
|
"end_time": "2026-07-01T10:00:00+00:00",
|
||||||
|
"description": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "会议B",
|
||||||
|
"start_time": "2026-07-02T14:00:00+00:00",
|
||||||
|
"end_time": "2026-07-02T15:00:00+00:00",
|
||||||
|
"description": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "截止日期",
|
||||||
|
"start_time": "2026-07-05T23:59:00+00:00",
|
||||||
|
"end_time": "2026-07-05T23:59:00+00:00",
|
||||||
|
"description": "提交报告",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
gateway = MockLLMGateway(response=llm_response)
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="本周有几个安排和截止",
|
||||||
|
conversation_id="conv-multi",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
titles = {e["title"] for e in result}
|
||||||
|
assert titles == {"会议A", "会议B", "截止日期"}
|
||||||
|
for event in result:
|
||||||
|
assert event["source"] == "post_extract"
|
||||||
|
assert event["conversation_id"] == "conv-multi"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Items without 'title' key are filtered out
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_items_without_title_filtered(
|
||||||
|
extractor: PostProcessingExtractor,
|
||||||
|
) -> None:
|
||||||
|
"""Dict items missing 'title' are dropped by the parser."""
|
||||||
|
llm_response = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "有效会议",
|
||||||
|
"start_time": "2026-07-01T09:00:00+00:00",
|
||||||
|
"end_time": "2026-07-01T10:00:00+00:00",
|
||||||
|
"description": "",
|
||||||
|
},
|
||||||
|
{"start_time": "2026-07-02T09:00:00+00:00", "end_time": "2026-07-02T10:00:00+00:00"},
|
||||||
|
"not-a-dict",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
gateway = MockLLMGateway(response=llm_response)
|
||||||
|
extractor.llm_gateway = gateway
|
||||||
|
|
||||||
|
result = await extractor.extract(
|
||||||
|
conversation_text="明天开会",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["title"] == "有效会议"
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""Tests for ICSProvider — iCalendar import/export (U8)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from icalendar import Calendar
|
||||||
|
|
||||||
|
from agentkit.calendar.db import init_calendar_db, list_events as db_list_events
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
from agentkit.calendar.sync.ics_provider import ICSProvider
|
||||||
|
from agentkit.server.auth.models import init_auth_db
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calendar_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_auth.db"
|
||||||
|
asyncio.run(init_auth_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService:
|
||||||
|
return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider(service: CalendarService) -> ICSProvider:
|
||||||
|
return ICSProvider(service)
|
||||||
|
|
||||||
|
|
||||||
|
USER_ID = "user-1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ICS sample strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SIMPLE_ICS = (
|
||||||
|
b"BEGIN:VCALENDAR\n"
|
||||||
|
b"VERSION:2.0\n"
|
||||||
|
b"PRODID:-//Test//Test//EN\n"
|
||||||
|
b"BEGIN:VEVENT\n"
|
||||||
|
b"UID:simple-uid@test\n"
|
||||||
|
b"SUMMARY:Test Event\n"
|
||||||
|
b"DTSTART:20260701T100000Z\n"
|
||||||
|
b"DTEND:20260701T110000Z\n"
|
||||||
|
b"DESCRIPTION:Test description\n"
|
||||||
|
b"LOCATION:Room A\n"
|
||||||
|
b"END:VEVENT\n"
|
||||||
|
b"END:VCALENDAR\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
RECURRING_ICS = (
|
||||||
|
b"BEGIN:VCALENDAR\n"
|
||||||
|
b"VERSION:2.0\n"
|
||||||
|
b"PRODID:-//Test//Test//EN\n"
|
||||||
|
b"BEGIN:VEVENT\n"
|
||||||
|
b"UID:recur-uid@test\n"
|
||||||
|
b"SUMMARY:Weekly Meeting\n"
|
||||||
|
b"DTSTART:20260706T100000Z\n"
|
||||||
|
b"DTEND:20260706T110000Z\n"
|
||||||
|
b"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\n"
|
||||||
|
b"END:VEVENT\n"
|
||||||
|
b"END:VCALENDAR\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
ALL_DAY_ICS = (
|
||||||
|
b"BEGIN:VCALENDAR\n"
|
||||||
|
b"VERSION:2.0\n"
|
||||||
|
b"PRODID:-//Test//Test//EN\n"
|
||||||
|
b"BEGIN:VEVENT\n"
|
||||||
|
b"UID:allday-uid@test\n"
|
||||||
|
b"SUMMARY:All Day Event\n"
|
||||||
|
b"DTSTART;VALUE=DATE:20260701\n"
|
||||||
|
b"DTEND;VALUE=DATE:20260702\n"
|
||||||
|
b"END:VEVENT\n"
|
||||||
|
b"END:VCALENDAR\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_simple_ics_creates_event(
|
||||||
|
provider: ICSProvider, calendar_db_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Single VEVENT ICS → event created with correct fields."""
|
||||||
|
result = await provider.import_ics(SIMPLE_ICS, USER_ID)
|
||||||
|
|
||||||
|
assert result["imported"] == 1
|
||||||
|
assert result["skipped"] == 0
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
events = await db_list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
assert event.title == "Test Event"
|
||||||
|
assert event.description == "Test description"
|
||||||
|
assert event.location == "Room A"
|
||||||
|
assert event.start_time == "2026-07-01T10:00:00+00:00"
|
||||||
|
assert event.end_time == "2026-07-01T11:00:00+00:00"
|
||||||
|
assert event.is_all_day is False
|
||||||
|
assert event.external_id == "simple-uid@test"
|
||||||
|
assert event.external_provider == "ics"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_recurring_ics_preserves_rrule(
|
||||||
|
provider: ICSProvider, calendar_db_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""VEVENT with RRULE → rrule field set on the created event."""
|
||||||
|
result = await provider.import_ics(RECURRING_ICS, USER_ID)
|
||||||
|
|
||||||
|
assert result["imported"] == 1
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
events = await db_list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
assert event.rrule is not None
|
||||||
|
assert "FREQ=WEEKLY" in event.rrule
|
||||||
|
assert "BYDAY=MO" in event.rrule
|
||||||
|
assert "COUNT=4" in event.rrule
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_all_day_event(provider: ICSProvider, calendar_db_path: Path) -> None:
|
||||||
|
"""DTSTART is date (not datetime) → is_all_day=True, ISO date stored."""
|
||||||
|
result = await provider.import_ics(ALL_DAY_ICS, USER_ID)
|
||||||
|
|
||||||
|
assert result["imported"] == 1
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
events = await db_list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
assert event.is_all_day is True
|
||||||
|
assert event.start_time == "2026-07-01"
|
||||||
|
assert event.end_time == "2026-07-02"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_skips_duplicate_uid(provider: ICSProvider, calendar_db_path: Path) -> None:
|
||||||
|
"""Importing the same ICS twice → second import skips the duplicate UID."""
|
||||||
|
first = await provider.import_ics(SIMPLE_ICS, USER_ID)
|
||||||
|
assert first["imported"] == 1
|
||||||
|
assert first["skipped"] == 0
|
||||||
|
|
||||||
|
second = await provider.import_ics(SIMPLE_ICS, USER_ID)
|
||||||
|
assert second["imported"] == 0
|
||||||
|
assert second["skipped"] == 1
|
||||||
|
|
||||||
|
events = await db_list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
assert len(events) == 1 # Still only one event
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_malformed_ics_raises_error(provider: ICSProvider) -> None:
|
||||||
|
"""Invalid ICS bytes → ValueError raised (graceful, not a crash)."""
|
||||||
|
with pytest.raises(ValueError, match="Failed to parse ICS"):
|
||||||
|
await provider.import_ics(b"this is definitely not valid ics at all", USER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_produces_valid_ics(provider: ICSProvider, service: CalendarService) -> None:
|
||||||
|
"""Create event, export, parse result with icalendar → roundtrip."""
|
||||||
|
await service.create_event(
|
||||||
|
user_id=USER_ID,
|
||||||
|
title="Export Test",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
description="Desc",
|
||||||
|
location="Room B",
|
||||||
|
)
|
||||||
|
|
||||||
|
events = await service.list_events(USER_ID)
|
||||||
|
ics_bytes = provider.export_ics(events)
|
||||||
|
|
||||||
|
# Parse the exported ICS back
|
||||||
|
cal = Calendar.from_ical(ics_bytes)
|
||||||
|
vevents = list(cal.walk("VEVENT"))
|
||||||
|
assert len(vevents) == 1
|
||||||
|
vevent = vevents[0]
|
||||||
|
assert str(vevent.get("SUMMARY")) == "Export Test"
|
||||||
|
assert str(vevent.get("DESCRIPTION")) == "Desc"
|
||||||
|
assert str(vevent.get("LOCATION")) == "Room B"
|
||||||
|
# DTSTART should be a datetime (not all-day)
|
||||||
|
dtstart = vevent.get("DTSTART").dt
|
||||||
|
assert dtstart.year == 2026
|
||||||
|
assert dtstart.month == 7
|
||||||
|
assert dtstart.day == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_includes_recurrence(provider: ICSProvider, service: CalendarService) -> None:
|
||||||
|
"""Event with rrule → exported ICS contains RRULE line."""
|
||||||
|
await service.create_event(
|
||||||
|
user_id=USER_ID,
|
||||||
|
title="Recurring Export",
|
||||||
|
start_time="2026-07-06T10:00:00+00:00",
|
||||||
|
end_time="2026-07-06T11:00:00+00:00",
|
||||||
|
rrule="FREQ=WEEKLY;BYDAY=MO;COUNT=4",
|
||||||
|
)
|
||||||
|
|
||||||
|
events = await service.list_events(USER_ID)
|
||||||
|
ics_bytes = provider.export_ics(events)
|
||||||
|
|
||||||
|
cal = Calendar.from_ical(ics_bytes)
|
||||||
|
vevents = list(cal.walk("VEVENT"))
|
||||||
|
assert len(vevents) == 1
|
||||||
|
vevent = vevents[0]
|
||||||
|
rrule = vevent.get("RRULE")
|
||||||
|
assert rrule is not None
|
||||||
|
rrule_str = rrule.to_ical().decode("utf-8")
|
||||||
|
assert "FREQ=WEEKLY" in rrule_str
|
||||||
|
assert "BYDAY=MO" in rrule_str
|
||||||
|
assert "COUNT=4" in rrule_str
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_date_range_filter(provider: ICSProvider, service: CalendarService) -> None:
|
||||||
|
"""3 events, export range covering 2 → only 2 in ICS output."""
|
||||||
|
# Event 1: July 1
|
||||||
|
await service.create_event(
|
||||||
|
user_id=USER_ID,
|
||||||
|
title="Event 1",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
# Event 2: July 5
|
||||||
|
await service.create_event(
|
||||||
|
user_id=USER_ID,
|
||||||
|
title="Event 2",
|
||||||
|
start_time="2026-07-05T10:00:00+00:00",
|
||||||
|
end_time="2026-07-05T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
# Event 3: July 20
|
||||||
|
await service.create_event(
|
||||||
|
user_id=USER_ID,
|
||||||
|
title="Event 3",
|
||||||
|
start_time="2026-07-20T10:00:00+00:00",
|
||||||
|
end_time="2026-07-20T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Export range: July 1 – July 10 (covers Event 1 and Event 2)
|
||||||
|
events = await service.list_events(
|
||||||
|
USER_ID,
|
||||||
|
start="2026-07-01T00:00:00+00:00",
|
||||||
|
end="2026-07-10T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
ics_bytes = provider.export_ics(events)
|
||||||
|
|
||||||
|
cal = Calendar.from_ical(ics_bytes)
|
||||||
|
vevents = list(cal.walk("VEVENT"))
|
||||||
|
assert len(vevents) == 2
|
||||||
|
summaries = {str(v.get("SUMMARY")) for v in vevents}
|
||||||
|
assert summaries == {"Event 1", "Event 2"}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Tests for RRULE recurrence expansion (U1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agentkit.calendar.recurrence import expand_rrule
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_rrule_weekly_count() -> None:
|
||||||
|
"""FREQ=WEEKLY;BYDAY=MO;COUNT=4 from Monday → 4 occurrences."""
|
||||||
|
result = expand_rrule(
|
||||||
|
"FREQ=WEEKLY;BYDAY=MO;COUNT=4",
|
||||||
|
"2026-07-06T10:00:00+00:00", # Monday
|
||||||
|
)
|
||||||
|
assert len(result) == 4
|
||||||
|
# All should be Mondays
|
||||||
|
for occ in result:
|
||||||
|
# 2026-07-06 is Monday, 07-13, 07-20, 07-27
|
||||||
|
assert "T10:00:00+00:00" in occ
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_rrule_daily_range_filter() -> None:
|
||||||
|
"""FREQ=DAILY starting Jan 1, range Jan 3–Jan 5 → 3 occurrences."""
|
||||||
|
result = expand_rrule(
|
||||||
|
"FREQ=DAILY",
|
||||||
|
"2026-01-01T00:00:00+00:00",
|
||||||
|
range_start="2026-01-03T00:00:00+00:00",
|
||||||
|
range_end="2026-01-06T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert len(result) == 3 # Jan 3, 4, 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_rrule_until_clause() -> None:
|
||||||
|
"""FREQ=DAILY;UNTIL=20260131 → occurrences stop at Jan 31."""
|
||||||
|
result = expand_rrule(
|
||||||
|
"FREQ=DAILY;UNTIL=20260131T235959Z",
|
||||||
|
"2026-01-29T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert len(result) == 3 # Jan 29, 30, 31
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_rrule_no_rrule_returns_single() -> None:
|
||||||
|
"""rrule=None → returns [start_time] only."""
|
||||||
|
result = expand_rrule(None, "2026-07-01T10:00:00+00:00")
|
||||||
|
assert result == ["2026-07-01T10:00:00+00:00"]
|
||||||
|
|
||||||
|
result = expand_rrule("", "2026-07-01T10:00:00+00:00")
|
||||||
|
assert result == ["2026-07-01T10:00:00+00:00"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_rrule_all_day_event() -> None:
|
||||||
|
"""All-day event with RRULE, verify date expansion (no time component issues)."""
|
||||||
|
result = expand_rrule(
|
||||||
|
"FREQ=DAILY;COUNT=3",
|
||||||
|
"2026-07-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].startswith("2026-07-01")
|
||||||
|
assert result[1].startswith("2026-07-02")
|
||||||
|
assert result[2].startswith("2026-07-03")
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_rrule_dst_boundary() -> None:
|
||||||
|
"""Event crossing DST transition, verify UTC consistency.
|
||||||
|
|
||||||
|
March 8, 2026 is US DST spring-forward. A daily event starting
|
||||||
|
March 7 should produce correct UTC occurrences across the boundary.
|
||||||
|
"""
|
||||||
|
result = expand_rrule(
|
||||||
|
"FREQ=DAILY;COUNT=3",
|
||||||
|
"2026-03-07T10:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert len(result) == 3
|
||||||
|
# All should be at 10:00 UTC regardless of DST
|
||||||
|
for occ in result:
|
||||||
|
assert "T10:00:00+00:00" in occ
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""Tests for ReminderDispatcher (U5)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from agentkit.calendar.models import CalendarEvent, _now_iso
|
||||||
|
from agentkit.calendar.reminders import ReminderDispatcher, SmtpConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event() -> CalendarEvent:
|
||||||
|
now = _now_iso()
|
||||||
|
return CalendarEvent(
|
||||||
|
id="evt-1",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Test Meeting",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
last_modified=now,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Client channel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_channel_sends_ws_message() -> None:
|
||||||
|
"""Mock WS sender callback, verify called with correct payload."""
|
||||||
|
ws_sender = AsyncMock()
|
||||||
|
dispatcher = ReminderDispatcher(ws_sender=ws_sender)
|
||||||
|
|
||||||
|
event = _make_event()
|
||||||
|
result = await dispatcher.dispatch("client", event, "user-1")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
ws_sender.assert_called_once()
|
||||||
|
call_args = ws_sender.call_args
|
||||||
|
assert call_args.args[0] == "user-1"
|
||||||
|
assert call_args.args[1]["type"] == "calendar_reminder"
|
||||||
|
assert call_args.args[1]["data"]["title"] == "Test Meeting"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_channel_returns_false_without_sender() -> None:
|
||||||
|
"""No ws_sender configured → returns False."""
|
||||||
|
dispatcher = ReminderDispatcher()
|
||||||
|
result = await dispatcher.dispatch("client", _make_event(), "user-1")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Email channel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_channel_sends_smtp() -> None:
|
||||||
|
"""Mock aiosmtplib via sys.modules injection, verify send called."""
|
||||||
|
mock_aiosmtplib = MagicMock()
|
||||||
|
mock_aiosmtplib.send = AsyncMock()
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {"aiosmtplib": mock_aiosmtplib}):
|
||||||
|
dispatcher = ReminderDispatcher(
|
||||||
|
smtp_config=SmtpConfig(host="smtp.example.com", port=587),
|
||||||
|
get_user_email=AsyncMock(return_value="user@example.com"),
|
||||||
|
)
|
||||||
|
event = _make_event()
|
||||||
|
result = await dispatcher.dispatch("email", event, "user-1")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_aiosmtplib.send.assert_called_once()
|
||||||
|
call_kwargs = mock_aiosmtplib.send.call_args.kwargs
|
||||||
|
assert call_kwargs["hostname"] == "smtp.example.com"
|
||||||
|
assert call_kwargs["port"] == 587
|
||||||
|
# Message body contains event title and recipient
|
||||||
|
message_body = mock_aiosmtplib.send.call_args.args[0]
|
||||||
|
assert "user@example.com" in message_body
|
||||||
|
assert "Test Meeting" in message_body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_channel_returns_false_without_config() -> None:
|
||||||
|
"""No smtp_config → returns False."""
|
||||||
|
dispatcher = ReminderDispatcher()
|
||||||
|
result = await dispatcher.dispatch("email", _make_event(), "user-1")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_channel_returns_false_when_user_has_no_email() -> None:
|
||||||
|
"""get_user_email returns None → returns False."""
|
||||||
|
dispatcher = ReminderDispatcher(
|
||||||
|
smtp_config=SmtpConfig(),
|
||||||
|
get_user_email=AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
result = await dispatcher.dispatch("email", _make_event(), "user-1")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook channel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_channel_posts_to_url() -> None:
|
||||||
|
"""Mock httpx.AsyncClient, verify POST called with event payload."""
|
||||||
|
dispatcher = ReminderDispatcher(webhook_url="https://example.com/hook")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
event = _make_event()
|
||||||
|
result = await dispatcher.dispatch("webhook", event, "user-1")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_client.post.assert_called_once()
|
||||||
|
call_kwargs = mock_client.post.call_args.kwargs
|
||||||
|
assert call_kwargs["json"]["event"]["title"] == "Test Meeting"
|
||||||
|
assert call_kwargs["json"]["user_id"] == "user-1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_channel_returns_false_on_4xx() -> None:
|
||||||
|
"""Webhook returns 500 → returns False."""
|
||||||
|
dispatcher = ReminderDispatcher(webhook_url="https://example.com/hook")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
result = await dispatcher.dispatch("webhook", _make_event(), "user-1")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_channel_returns_false_without_url() -> None:
|
||||||
|
"""No webhook_url configured → returns False."""
|
||||||
|
dispatcher = ReminderDispatcher()
|
||||||
|
result = await dispatcher.dispatch("webhook", _make_event(), "user-1")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unknown channel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_channel_returns_false() -> None:
|
||||||
|
"""Unknown channel name → returns False, no crash."""
|
||||||
|
dispatcher = ReminderDispatcher()
|
||||||
|
result = await dispatcher.dispatch("sms", _make_event(), "user-1")
|
||||||
|
assert result is False
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
"""Tests for calendar REST API routes (U2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from agentkit.calendar.db import init_calendar_db
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
from agentkit.server.auth.dependencies import require_authenticated
|
||||||
|
from agentkit.server.auth.models import init_auth_db
|
||||||
|
from agentkit.server.routes import calendar as calendar_routes
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
TEST_USER_ID = "test-user-id"
|
||||||
|
TEST_USER_EMAIL = "testuser@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test_user() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"user_id": TEST_USER_ID,
|
||||||
|
"username": "testuser",
|
||||||
|
"role": "member",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_auth_user(auth_db_path: Path) -> None:
|
||||||
|
"""Seed the test user into the auth DB so get_user_email works."""
|
||||||
|
async with aiosqlite.connect(str(auth_db_path)) as db:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO users (id, username, email, password_hash, role, is_active, "
|
||||||
|
"is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
TEST_USER_ID,
|
||||||
|
"testuser",
|
||||||
|
TEST_USER_EMAIL,
|
||||||
|
"fake-hash",
|
||||||
|
"member",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"2026-01-01T00:00:00+00:00",
|
||||||
|
"2026-01-01T00:00:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_searchable_users(auth_db_path: Path) -> None:
|
||||||
|
"""Seed extra users for search tests."""
|
||||||
|
for name in ("alice", "bob", "charlie"):
|
||||||
|
async with aiosqlite.connect(str(auth_db_path)) as db:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO users (id, username, email, password_hash, role, is_active, "
|
||||||
|
"is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
uuid.uuid4().hex,
|
||||||
|
name,
|
||||||
|
f"{name}@example.com",
|
||||||
|
"fake-hash",
|
||||||
|
"member",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"2026-01-01T00:00:00+00:00",
|
||||||
|
"2026-01-01T00:00:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calendar_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_auth.db"
|
||||||
|
asyncio.run(init_auth_db(path))
|
||||||
|
asyncio.run(_seed_auth_user(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(calendar_db_path: Path, auth_db_path: Path) -> FastAPI:
|
||||||
|
"""Create a test app with CalendarService and mock auth."""
|
||||||
|
service = CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.calendar_service = service
|
||||||
|
app.state.auth_db_path = str(auth_db_path)
|
||||||
|
app.include_router(calendar_routes.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
# Override auth dependency to return a test user
|
||||||
|
app.dependency_overrides[require_authenticated] = lambda: _make_test_user()
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app: FastAPI) -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unauth_app(calendar_db_path: Path, auth_db_path: Path) -> FastAPI:
|
||||||
|
"""App without auth override — simulates unauthenticated requests."""
|
||||||
|
service = CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path)
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.calendar_service = service
|
||||||
|
app.state.auth_db_path = str(auth_db_path)
|
||||||
|
app.include_router(calendar_routes.router, prefix="/api/v1")
|
||||||
|
# No dependency override → require_authenticated will see no current_user
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unauth_client(unauth_app: FastAPI) -> TestClient:
|
||||||
|
return TestClient(unauth_app)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth requirement
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_create_event_requires_auth(unauth_client: TestClient) -> None:
|
||||||
|
"""No auth → 401."""
|
||||||
|
resp = unauth_client.post(
|
||||||
|
"/api/v1/calendar/events",
|
||||||
|
json={
|
||||||
|
"title": "Test",
|
||||||
|
"start_time": "2026-07-01T10:00:00+00:00",
|
||||||
|
"end_time": "2026-07-01T11:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create event
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_create_event_success(client: TestClient) -> None:
|
||||||
|
"""Create event via API returns 200 with event data."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/calendar/events",
|
||||||
|
json={
|
||||||
|
"title": "Sprint Planning",
|
||||||
|
"start_time": "2026-07-01T10:00:00+00:00",
|
||||||
|
"end_time": "2026-07-01T11:00:00+00:00",
|
||||||
|
"description": "Bi-weekly sprint planning",
|
||||||
|
"location": "Room A",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
event = data["event"]
|
||||||
|
assert event["title"] == "Sprint Planning"
|
||||||
|
assert event["start_time"] == "2026-07-01T10:00:00+00:00"
|
||||||
|
assert event["description"] == "Bi-weekly sprint planning"
|
||||||
|
assert event["location"] == "Room A"
|
||||||
|
assert event["source"] == "manual"
|
||||||
|
assert "id" in event
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List events
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_list_events_returns_events(client: TestClient) -> None:
|
||||||
|
"""Create events, list via API returns them."""
|
||||||
|
for i in range(3):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/calendar/events",
|
||||||
|
json={
|
||||||
|
"title": f"Event {i}",
|
||||||
|
"start_time": f"2026-07-{i + 1:02d}T10:00:00+00:00",
|
||||||
|
"end_time": f"2026-07-{i + 1:02d}T11:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/calendar/events")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["count"] == 3
|
||||||
|
titles = {e["title"] for e in data["events"]}
|
||||||
|
assert titles == {"Event 0", "Event 1", "Event 2"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tag filter via API (G2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_list_events_filters_by_tag(client: TestClient) -> None:
|
||||||
|
"""G2 tag filter via API — only tagged events returned."""
|
||||||
|
# Create a tag first
|
||||||
|
tag_resp = client.post("/api/v1/calendar/tags", json={"name": "important"})
|
||||||
|
assert tag_resp.status_code == 200
|
||||||
|
tag_id = tag_resp.json()["tag"]["id"]
|
||||||
|
|
||||||
|
# Event 1: with tag
|
||||||
|
client.post(
|
||||||
|
"/api/v1/calendar/events",
|
||||||
|
json={
|
||||||
|
"title": "Tagged Event",
|
||||||
|
"start_time": "2026-07-01T10:00:00+00:00",
|
||||||
|
"end_time": "2026-07-01T11:00:00+00:00",
|
||||||
|
"tag_ids": [tag_id],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Event 2: without tag
|
||||||
|
client.post(
|
||||||
|
"/api/v1/calendar/events",
|
||||||
|
json={
|
||||||
|
"title": "Untagged Event",
|
||||||
|
"start_time": "2026-07-02T10:00:00+00:00",
|
||||||
|
"end_time": "2026-07-02T11:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# List with tag filter
|
||||||
|
resp = client.get("/api/v1/calendar/events", params={"tag_id": tag_id})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["count"] == 1
|
||||||
|
assert data["events"][0]["title"] == "Tagged Event"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User search via API (G5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_search_users(client: TestClient, auth_db_path: Path) -> None:
|
||||||
|
"""G5 user search via API returns matching users."""
|
||||||
|
asyncio.run(_seed_searchable_users(auth_db_path))
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/calendar/users/search", params={"q": "ali"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["count"] == 1
|
||||||
|
assert data["users"][0]["username"] == "alice"
|
||||||
|
assert data["users"][0]["email"] == "alice@example.com"
|
||||||
|
# Must not expose sensitive fields
|
||||||
|
assert "id" not in data["users"][0]
|
||||||
|
assert "password_hash" not in data["users"][0]
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""Tests for ReminderScheduler (U5)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
get_pending_deliveries,
|
||||||
|
init_calendar_db,
|
||||||
|
insert_event,
|
||||||
|
insert_reminder_rule,
|
||||||
|
list_reminder_rules_for_event,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ReminderRule, _now_iso
|
||||||
|
from agentkit.calendar.reminders import ReminderDispatcher
|
||||||
|
from agentkit.calendar.scheduler import ReminderScheduler
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_db_path(tmp_path: Path) -> Path:
|
||||||
|
from agentkit.server.auth.models import init_auth_db
|
||||||
|
|
||||||
|
path = tmp_path / "test_auth.db"
|
||||||
|
asyncio.run(init_auth_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(
|
||||||
|
event_id: str,
|
||||||
|
user_id: str,
|
||||||
|
start_time: str,
|
||||||
|
title: str = "Test Event",
|
||||||
|
) -> CalendarEvent:
|
||||||
|
now = _now_iso()
|
||||||
|
return CalendarEvent(
|
||||||
|
id=event_id,
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=start_time,
|
||||||
|
last_modified=now,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_dispatcher(return_value: bool = True) -> ReminderDispatcher:
|
||||||
|
"""Create a dispatcher with a mocked dispatch method."""
|
||||||
|
dispatcher = ReminderDispatcher()
|
||||||
|
dispatcher.dispatch = AsyncMock(return_value=return_value) # type: ignore
|
||||||
|
return dispatcher
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scheduler scan logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduler_finds_event_within_reminder_window(db_path: Path) -> None:
|
||||||
|
"""Event 10 min away, rule offset -15min → reminder_time 5 min ago → found."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
event_start = (now + timedelta(minutes=10)).isoformat()
|
||||||
|
event = _make_event("evt-1", "user-1", event_start, "Meeting")
|
||||||
|
await insert_event(event, db_path)
|
||||||
|
|
||||||
|
rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"])
|
||||||
|
await insert_reminder_rule(rule, db_path)
|
||||||
|
|
||||||
|
dispatcher = _mock_dispatcher(return_value=True)
|
||||||
|
scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher)
|
||||||
|
|
||||||
|
count = await scheduler.scan_once()
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert dispatcher.dispatch.call_count == 1 # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduler_skips_event_outside_window(db_path: Path) -> None:
|
||||||
|
"""Event 2 hours away, rule offset -15min → reminder_time 1hr45min away → not found."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
event_start = (now + timedelta(hours=2)).isoformat()
|
||||||
|
event = _make_event("evt-1", "user-1", event_start, "Meeting")
|
||||||
|
await insert_event(event, db_path)
|
||||||
|
|
||||||
|
rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"])
|
||||||
|
await insert_reminder_rule(rule, db_path)
|
||||||
|
|
||||||
|
dispatcher = _mock_dispatcher(return_value=True)
|
||||||
|
scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher)
|
||||||
|
|
||||||
|
count = await scheduler.scan_once()
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert dispatcher.dispatch.call_count == 0 # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
async def test_idempotent_delivery_no_duplicate(db_path: Path) -> None:
|
||||||
|
"""Scheduler runs twice, only one delivery record created."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
event_start = (now + timedelta(minutes=10)).isoformat()
|
||||||
|
event = _make_event("evt-1", "user-1", event_start, "Meeting")
|
||||||
|
await insert_event(event, db_path)
|
||||||
|
|
||||||
|
rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"])
|
||||||
|
await insert_reminder_rule(rule, db_path)
|
||||||
|
|
||||||
|
dispatcher = _mock_dispatcher(return_value=True)
|
||||||
|
scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher)
|
||||||
|
|
||||||
|
count1 = await scheduler.scan_once()
|
||||||
|
assert count1 == 1
|
||||||
|
|
||||||
|
count2 = await scheduler.scan_once()
|
||||||
|
assert count2 == 0
|
||||||
|
|
||||||
|
deliveries = await get_pending_deliveries("evt-1", "rule-1", db_path)
|
||||||
|
assert len(deliveries) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failed_delivery_retries_up_to_3_times(db_path: Path) -> None:
|
||||||
|
"""Mock channel to fail, verify 3 attempts and delivery status=failed."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
event_start = (now + timedelta(minutes=10)).isoformat()
|
||||||
|
event = _make_event("evt-1", "user-1", event_start, "Meeting")
|
||||||
|
await insert_event(event, db_path)
|
||||||
|
|
||||||
|
rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"])
|
||||||
|
await insert_reminder_rule(rule, db_path)
|
||||||
|
|
||||||
|
dispatcher = _mock_dispatcher(return_value=False)
|
||||||
|
scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher, retry_base_delay=0)
|
||||||
|
|
||||||
|
await scheduler.scan_once()
|
||||||
|
|
||||||
|
assert dispatcher.dispatch.call_count == 3 # type: ignore
|
||||||
|
|
||||||
|
deliveries = await get_pending_deliveries("evt-1", "rule-1", db_path, status="failed")
|
||||||
|
assert len(deliveries) == 1
|
||||||
|
assert deliveries[0].attempts == 3
|
||||||
|
assert deliveries[0].status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduler_dispatches_multiple_channels(db_path: Path) -> None:
|
||||||
|
"""Rule with 2 channels creates 2 delivery records."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
event_start = (now + timedelta(minutes=10)).isoformat()
|
||||||
|
event = _make_event("evt-1", "user-1", event_start, "Meeting")
|
||||||
|
await insert_event(event, db_path)
|
||||||
|
|
||||||
|
rule = ReminderRule(
|
||||||
|
id="rule-1",
|
||||||
|
event_id="evt-1",
|
||||||
|
offset_minutes=-15,
|
||||||
|
channels=["client", "email"],
|
||||||
|
)
|
||||||
|
await insert_reminder_rule(rule, db_path)
|
||||||
|
|
||||||
|
dispatcher = _mock_dispatcher(return_value=True)
|
||||||
|
scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher)
|
||||||
|
|
||||||
|
count = await scheduler.scan_once()
|
||||||
|
assert count == 2
|
||||||
|
assert dispatcher.dispatch.call_count == 2 # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Start/stop lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduler_start_stop_lifecycle(db_path: Path) -> None:
|
||||||
|
"""start() creates task, stop() cancels it."""
|
||||||
|
scheduler = ReminderScheduler(db_path=db_path, interval_seconds=1)
|
||||||
|
|
||||||
|
assert scheduler._task is None
|
||||||
|
|
||||||
|
await scheduler.start()
|
||||||
|
assert scheduler._task is not None
|
||||||
|
assert not scheduler._task.done()
|
||||||
|
|
||||||
|
await scheduler.stop()
|
||||||
|
assert scheduler._task is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduler_start_idempotent(db_path: Path) -> None:
|
||||||
|
"""Calling start() twice does not create a second task."""
|
||||||
|
scheduler = ReminderScheduler(db_path=db_path, interval_seconds=1)
|
||||||
|
|
||||||
|
await scheduler.start()
|
||||||
|
task1 = scheduler._task
|
||||||
|
await scheduler.start()
|
||||||
|
assert scheduler._task is task1
|
||||||
|
|
||||||
|
await scheduler.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default reminders inherited from event type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_default_reminders_inherited_from_event_type(
|
||||||
|
db_path: Path, auth_db_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Create event with type that has default rules, verify rules cloned to event."""
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
|
||||||
|
service = CalendarService(db_path=db_path, auth_db_path=auth_db_path)
|
||||||
|
|
||||||
|
# Create event type
|
||||||
|
et = await service.create_event_type("user-1", "Meeting")
|
||||||
|
|
||||||
|
# Add a default reminder rule at the type level
|
||||||
|
type_rule = ReminderRule(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
event_type_id=et.id,
|
||||||
|
offset_minutes=-30,
|
||||||
|
channels=["email"],
|
||||||
|
)
|
||||||
|
await insert_reminder_rule(type_rule, db_path)
|
||||||
|
|
||||||
|
# Create an event with this type
|
||||||
|
event = await service.create_event(
|
||||||
|
user_id="user-1",
|
||||||
|
title="Sprint Planning",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
event_type_id=et.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the type-level rule was cloned to the event
|
||||||
|
event_rules = await list_reminder_rules_for_event(event.id, db_path)
|
||||||
|
assert len(event_rules) == 1
|
||||||
|
assert event_rules[0].event_id == event.id
|
||||||
|
assert event_rules[0].event_type_id is None
|
||||||
|
assert event_rules[0].offset_minutes == -30
|
||||||
|
assert event_rules[0].channels == ["email"]
|
||||||
|
# Cloned rule has a new ID
|
||||||
|
assert event_rules[0].id != type_rule.id
|
||||||
|
|
@ -0,0 +1,441 @@
|
||||||
|
"""Tests for CalendarService (U2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
get_event_tags,
|
||||||
|
init_calendar_db,
|
||||||
|
insert_reminder_rule,
|
||||||
|
list_reminder_rules_for_event,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import ReminderRule
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
from agentkit.server.auth.models import init_auth_db
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calendar_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_auth.db"
|
||||||
|
asyncio.run(init_auth_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService:
|
||||||
|
return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_user(
|
||||||
|
auth_db_path: Path,
|
||||||
|
user_id: str | None = None,
|
||||||
|
username: str = "testuser",
|
||||||
|
email: str = "test@example.com",
|
||||||
|
) -> str:
|
||||||
|
"""Insert a user into the auth DB and return its id."""
|
||||||
|
uid = user_id or uuid.uuid4().hex
|
||||||
|
async with aiosqlite.connect(str(auth_db_path)) as db:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO users (id, username, email, password_hash, role, is_active, "
|
||||||
|
"is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
uid,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
"fake-hash",
|
||||||
|
"member",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"2026-01-01T00:00:00+00:00",
|
||||||
|
"2026-01-01T00:00:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event creation with type and tags
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_with_type_and_tags(
|
||||||
|
service: CalendarService, calendar_db_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Create event with type_id and 2 tags, verify all linked."""
|
||||||
|
user_id = "user-1"
|
||||||
|
|
||||||
|
# Create event type
|
||||||
|
et = await service.create_event_type(user_id, "Meeting", color="#FF0000")
|
||||||
|
|
||||||
|
# Create tags
|
||||||
|
tag1 = await service.create_tag(user_id, "urgent")
|
||||||
|
tag2 = await service.create_tag(user_id, "work")
|
||||||
|
|
||||||
|
# Create event with type and tags
|
||||||
|
event = await service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title="Sprint Planning",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
event_type_id=et.id,
|
||||||
|
tag_ids=[tag1.id, tag2.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify event was created
|
||||||
|
fetched = await service.get_event(event.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.title == "Sprint Planning"
|
||||||
|
assert fetched.event_type_id == et.id
|
||||||
|
|
||||||
|
# Verify tags are linked
|
||||||
|
tags = await get_event_tags(event.id, calendar_db_path)
|
||||||
|
tag_names = {t.name for t in tags}
|
||||||
|
assert tag_names == {"urgent", "work"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Date range filtering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_events_filters_by_date_range(service: CalendarService) -> None:
|
||||||
|
"""3 events across days, filter returns correct subset."""
|
||||||
|
user_id = "user-1"
|
||||||
|
for i, day in enumerate([1, 15, 28]):
|
||||||
|
await service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title=f"Event Day {day}",
|
||||||
|
start_time=f"2026-07-{day:02d}T10:00:00+00:00",
|
||||||
|
end_time=f"2026-07-{day:02d}T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Range covering only day 15
|
||||||
|
events = await service.list_events(
|
||||||
|
user_id=user_id,
|
||||||
|
start="2026-07-10T00:00:00+00:00",
|
||||||
|
end="2026-07-20T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].title == "Event Day 15"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Type and tag filter combination (G2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_events_filters_by_type_and_tag(
|
||||||
|
service: CalendarService, calendar_db_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Filter by both type_id and tag_id returns only matching events."""
|
||||||
|
user_id = "user-1"
|
||||||
|
|
||||||
|
et_meeting = await service.create_event_type(user_id, "Meeting")
|
||||||
|
et_personal = await service.create_event_type(user_id, "Personal")
|
||||||
|
|
||||||
|
tag_work = await service.create_tag(user_id, "work")
|
||||||
|
tag_family = await service.create_tag(user_id, "family")
|
||||||
|
|
||||||
|
# Event 1: Meeting + work
|
||||||
|
e1 = await service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title="Standup",
|
||||||
|
start_time="2026-07-01T09:00:00+00:00",
|
||||||
|
end_time="2026-07-01T09:30:00+00:00",
|
||||||
|
event_type_id=et_meeting.id,
|
||||||
|
tag_ids=[tag_work.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Event 2: Personal + family
|
||||||
|
await service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title="Birthday",
|
||||||
|
start_time="2026-07-02T18:00:00+00:00",
|
||||||
|
end_time="2026-07-02T21:00:00+00:00",
|
||||||
|
event_type_id=et_personal.id,
|
||||||
|
tag_ids=[tag_family.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Event 3: Meeting + family (cross combination)
|
||||||
|
await service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title="Team Dinner",
|
||||||
|
start_time="2026-07-03T19:00:00+00:00",
|
||||||
|
end_time="2026-07-03T21:00:00+00:00",
|
||||||
|
event_type_id=et_meeting.id,
|
||||||
|
tag_ids=[tag_family.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by type=Meeting AND tag=work → only Event 1
|
||||||
|
events = await service.list_events(
|
||||||
|
user_id=user_id,
|
||||||
|
event_type_id=et_meeting.id,
|
||||||
|
tag_id=tag_work.id,
|
||||||
|
)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].id == e1.id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tag-only filter (G2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_events_filters_by_tag_only(
|
||||||
|
service: CalendarService, calendar_db_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""5 events, 2 with tag X, filter tag_id=X returns only 2."""
|
||||||
|
user_id = "user-1"
|
||||||
|
tag_x = await service.create_tag(user_id, "X")
|
||||||
|
|
||||||
|
tagged_ids: list[str] = []
|
||||||
|
for i in range(5):
|
||||||
|
tag_ids = [tag_x.id] if i < 2 else None
|
||||||
|
event = await service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title=f"Event {i}",
|
||||||
|
start_time=f"2026-07-{i + 1:02d}T10:00:00+00:00",
|
||||||
|
end_time=f"2026-07-{i + 1:02d}T11:00:00+00:00",
|
||||||
|
tag_ids=tag_ids,
|
||||||
|
)
|
||||||
|
if i < 2:
|
||||||
|
tagged_ids.append(event.id)
|
||||||
|
|
||||||
|
events = await service.list_events(user_id=user_id, tag_id=tag_x.id)
|
||||||
|
assert len(events) == 2
|
||||||
|
returned_ids = {e.id for e in events}
|
||||||
|
assert returned_ids == set(tagged_ids)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Recurring event expansion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_events_expands_recurring(service: CalendarService) -> None:
|
||||||
|
"""Event with FREQ=DAILY;COUNT=3, list range covering 2 days → 2 occurrences."""
|
||||||
|
user_id = "user-1"
|
||||||
|
await service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title="Daily Standup",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T10:15:00+00:00",
|
||||||
|
rrule="FREQ=DAILY;COUNT=3",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Range covers Jul 1 and Jul 2 (half-open [Jul 1, Jul 3))
|
||||||
|
events = await service.list_events(
|
||||||
|
user_id=user_id,
|
||||||
|
start="2026-07-01T00:00:00+00:00",
|
||||||
|
end="2026-07-03T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert len(events) == 2
|
||||||
|
# First occurrence on Jul 1
|
||||||
|
assert events[0].start_time.startswith("2026-07-01")
|
||||||
|
assert events[0].end_time.startswith("2026-07-01")
|
||||||
|
# Second occurrence on Jul 2
|
||||||
|
assert events[1].start_time.startswith("2026-07-02")
|
||||||
|
assert events[1].end_time.startswith("2026-07-02")
|
||||||
|
# Duration preserved (15 minutes)
|
||||||
|
assert "T10:00:00" in events[0].start_time
|
||||||
|
assert "T10:15:00" in events[0].end_time
|
||||||
|
assert "T10:00:00" in events[1].start_time
|
||||||
|
assert "T10:15:00" in events[1].end_time
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Partial update
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_event_partial_fields(service: CalendarService) -> None:
|
||||||
|
"""PATCH only title, other fields unchanged."""
|
||||||
|
event = await service.create_event(
|
||||||
|
user_id="user-1",
|
||||||
|
title="Original Title",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
description="Original description",
|
||||||
|
location="Room A",
|
||||||
|
)
|
||||||
|
|
||||||
|
original = await service.get_event(event.id)
|
||||||
|
assert original is not None
|
||||||
|
|
||||||
|
updated = await service.update_event(event.id, {"title": "New Title"})
|
||||||
|
assert updated is True
|
||||||
|
|
||||||
|
refreshed = await service.get_event(event.id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.title == "New Title"
|
||||||
|
# Other fields unchanged
|
||||||
|
assert refreshed.description == "Original description"
|
||||||
|
assert refreshed.location == "Room A"
|
||||||
|
assert refreshed.start_time == original.start_time
|
||||||
|
assert refreshed.end_time == original.end_time
|
||||||
|
# last_modified should be updated
|
||||||
|
assert refreshed.last_modified != original.last_modified
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delete cascade
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_event_cascades_reminders_and_tags(
|
||||||
|
service: CalendarService, calendar_db_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Delete event, verify reminder rules and junction rows removed."""
|
||||||
|
user_id = "user-1"
|
||||||
|
tag = await service.create_tag(user_id, "work")
|
||||||
|
event = await service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title="To Delete",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
tag_ids=[tag.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add a reminder rule directly to the event
|
||||||
|
rule = ReminderRule(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
event_id=event.id,
|
||||||
|
offset_minutes=-30,
|
||||||
|
channels=["email"],
|
||||||
|
)
|
||||||
|
await insert_reminder_rule(rule, calendar_db_path)
|
||||||
|
|
||||||
|
# Verify rule and tag exist
|
||||||
|
rules_before = await list_reminder_rules_for_event(event.id, calendar_db_path)
|
||||||
|
assert len(rules_before) == 1
|
||||||
|
tags_before = await get_event_tags(event.id, calendar_db_path)
|
||||||
|
assert len(tags_before) == 1
|
||||||
|
|
||||||
|
# Delete the event
|
||||||
|
deleted = await service.delete_event(event.id)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
# Verify event is gone
|
||||||
|
assert await service.get_event(event.id) is None
|
||||||
|
|
||||||
|
# Verify reminder rules are gone (cascade)
|
||||||
|
rules_after = await list_reminder_rules_for_event(event.id, calendar_db_path)
|
||||||
|
assert len(rules_after) == 0
|
||||||
|
|
||||||
|
# Verify junction rows are gone
|
||||||
|
tags_after = await get_event_tags(event.id, calendar_db_path)
|
||||||
|
assert len(tags_after) == 0
|
||||||
|
|
||||||
|
# Tag itself should still exist (only the junction is removed)
|
||||||
|
all_tags = await service.list_tags(user_id)
|
||||||
|
assert len(all_tags) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invitation flow
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_invitation_and_respond(service: CalendarService) -> None:
|
||||||
|
"""Invite, respond 'accepted', verify status + responded_at set."""
|
||||||
|
event = await service.create_event(
|
||||||
|
user_id="user-1",
|
||||||
|
title="Team Meeting",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
invitation = await service.create_invitation(
|
||||||
|
event_id=event.id,
|
||||||
|
inviter_user_id="user-1",
|
||||||
|
invitee_email="alice@example.com",
|
||||||
|
)
|
||||||
|
assert invitation.status == "pending"
|
||||||
|
assert invitation.responded_at is None
|
||||||
|
|
||||||
|
updated = await service.respond_to_invitation(invitation.id, "accepted")
|
||||||
|
assert updated is True
|
||||||
|
|
||||||
|
invitations = await service.list_invitations("alice@example.com")
|
||||||
|
assert len(invitations) == 1
|
||||||
|
assert invitations[0].status == "accepted"
|
||||||
|
assert invitations[0].responded_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User search (G5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_users_by_username(service: CalendarService, auth_db_path: Path) -> None:
|
||||||
|
"""Seed users in auth DB, search returns matches."""
|
||||||
|
await _seed_user(auth_db_path, username="alice", email="alice@example.com")
|
||||||
|
await _seed_user(auth_db_path, username="bob", email="bob@example.com")
|
||||||
|
await _seed_user(auth_db_path, username="charlie", email="charlie@example.com")
|
||||||
|
|
||||||
|
results = await service.search_users("ali")
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["username"] == "alice"
|
||||||
|
assert results[0]["email"] == "alice@example.com"
|
||||||
|
# Must NOT return user_id or password fields
|
||||||
|
assert "id" not in results[0]
|
||||||
|
assert "password_hash" not in results[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_users_no_match_returns_empty(
|
||||||
|
service: CalendarService, auth_db_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Search 'zzz' returns []."""
|
||||||
|
await _seed_user(auth_db_path, username="alice", email="alice@example.com")
|
||||||
|
results = await service.search_users("zzz")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_users_returns_max_10(service: CalendarService, auth_db_path: Path) -> None:
|
||||||
|
"""Seed 15 matching users, verify only 10 returned."""
|
||||||
|
for i in range(15):
|
||||||
|
await _seed_user(
|
||||||
|
auth_db_path,
|
||||||
|
username=f"user{i:02d}",
|
||||||
|
email=f"user{i:02d}@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await service.search_users("user")
|
||||||
|
assert len(results) == 10
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event type color persistence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_event_type_default_color_persistence(service: CalendarService) -> None:
|
||||||
|
"""Create type with color, verify roundtrip."""
|
||||||
|
et = await service.create_event_type("user-1", "Important", color="#00FF00")
|
||||||
|
assert et.color == "#00FF00"
|
||||||
|
|
||||||
|
types = await service.list_event_types("user-1")
|
||||||
|
assert len(types) == 1
|
||||||
|
assert types[0].color == "#00FF00"
|
||||||
|
assert types[0].name == "Important"
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""Tests for CalDAVSyncProvider — bidirectional Apple Calendar sync (U6).
|
||||||
|
|
||||||
|
The ``caldav`` library is not installed in the test environment, so we inject
|
||||||
|
a mock module into ``sys.modules`` before importing the provider. All CalDAV
|
||||||
|
interactions are mocked via the ``client_factory`` injection point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# caldav is not installed in the test env — inject a mock module so that
|
||||||
|
# `import caldav` in caldav_provider.py succeeds at import time.
|
||||||
|
if "caldav" not in sys.modules: # pragma: no cover
|
||||||
|
sys.modules["caldav"] = MagicMock()
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
get_event_by_external_id,
|
||||||
|
init_calendar_db,
|
||||||
|
insert_event,
|
||||||
|
list_events,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig
|
||||||
|
from agentkit.calendar.sync.caldav_provider import CalDAVSyncProvider
|
||||||
|
|
||||||
|
USER_ID = "user-1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ICS + Mock helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_ics(
|
||||||
|
uid: str,
|
||||||
|
summary: str,
|
||||||
|
start: str = "20260701T100000Z",
|
||||||
|
end: str = "20260701T110000Z",
|
||||||
|
description: str = "",
|
||||||
|
location: str = "",
|
||||||
|
rrule: str | None = None,
|
||||||
|
last_modified: str = "20260601T000000Z",
|
||||||
|
) -> bytes:
|
||||||
|
"""Build a minimal valid ICS bytes payload for a single VEVENT."""
|
||||||
|
lines = [
|
||||||
|
b"BEGIN:VCALENDAR",
|
||||||
|
b"VERSION:2.0",
|
||||||
|
b"PRODID:-//Test//Test//EN",
|
||||||
|
b"BEGIN:VEVENT",
|
||||||
|
f"UID:{uid}".encode(),
|
||||||
|
f"SUMMARY:{summary}".encode(),
|
||||||
|
f"DTSTART:{start}".encode(),
|
||||||
|
f"DTEND:{end}".encode(),
|
||||||
|
]
|
||||||
|
if description:
|
||||||
|
lines.append(f"DESCRIPTION:{description}".encode())
|
||||||
|
if location:
|
||||||
|
lines.append(f"LOCATION:{location}".encode())
|
||||||
|
if rrule:
|
||||||
|
lines.append(f"RRULE:{rrule}".encode())
|
||||||
|
if last_modified:
|
||||||
|
lines.append(f"LAST-MODIFIED:{last_modified}".encode())
|
||||||
|
lines.extend([b"END:VEVENT", b"END:VCALENDAR"])
|
||||||
|
return b"\r\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class MockCaldavEvent:
|
||||||
|
"""Mock caldav.Event — exposes .data returning ICS bytes."""
|
||||||
|
|
||||||
|
def __init__(self, ics_data: bytes) -> None:
|
||||||
|
self.data = ics_data
|
||||||
|
|
||||||
|
|
||||||
|
def make_mock_client(
|
||||||
|
remote_events: list[bytes],
|
||||||
|
saved_uid: str | None = None,
|
||||||
|
raise_on_connect: Exception | None = None,
|
||||||
|
) -> tuple[Any, Any]:
|
||||||
|
"""Create a mock CalDAV client and its mock calendar.
|
||||||
|
|
||||||
|
Returns ``(client, mock_calendar)``. ``mock_calendar`` is None when
|
||||||
|
``raise_on_connect`` is set (connection fails before calendar is reached).
|
||||||
|
"""
|
||||||
|
client = MagicMock()
|
||||||
|
if raise_on_connect is not None:
|
||||||
|
client.principal.side_effect = raise_on_connect
|
||||||
|
return client, None
|
||||||
|
|
||||||
|
principal = MagicMock()
|
||||||
|
calendar = MagicMock()
|
||||||
|
calendar.date_search.return_value = [MockCaldavEvent(d) for d in remote_events]
|
||||||
|
saved_ics = make_ics(saved_uid or "saved-uid", "Saved Event")
|
||||||
|
calendar.save_event.return_value = MockCaldavEvent(saved_ics)
|
||||||
|
principal.calendars.return_value = [calendar]
|
||||||
|
client.principal.return_value = principal
|
||||||
|
return client, calendar
|
||||||
|
|
||||||
|
|
||||||
|
def client_factory_from(client: Any) -> Any:
|
||||||
|
"""Wrap a mock client in a client_factory callable."""
|
||||||
|
|
||||||
|
def factory(config: ExternalCalendarConfig) -> Any:
|
||||||
|
return client
|
||||||
|
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(
|
||||||
|
config_id: str = "config-1",
|
||||||
|
user_id: str = USER_ID,
|
||||||
|
provider: str = "caldav",
|
||||||
|
last_sync: str | None = None,
|
||||||
|
) -> ExternalCalendarConfig:
|
||||||
|
return ExternalCalendarConfig(
|
||||||
|
id=config_id,
|
||||||
|
user_id=user_id,
|
||||||
|
provider=provider,
|
||||||
|
credentials=json.dumps(
|
||||||
|
{"url": "https://caldav.example.com", "username": "user", "password": "pass"}
|
||||||
|
),
|
||||||
|
last_sync=last_sync,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_local_event(
|
||||||
|
event_id: str = "evt-1",
|
||||||
|
title: str = "Local Event",
|
||||||
|
external_id: str | None = None,
|
||||||
|
last_modified: str = "2026-01-01T00:00:00+00:00",
|
||||||
|
rrule: str | None = None,
|
||||||
|
) -> CalendarEvent:
|
||||||
|
return CalendarEvent(
|
||||||
|
id=event_id,
|
||||||
|
user_id=USER_ID,
|
||||||
|
title=title,
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
external_id=external_id,
|
||||||
|
external_provider="caldav" if external_id else None,
|
||||||
|
last_modified=last_modified,
|
||||||
|
created_at=last_modified,
|
||||||
|
rrule=rrule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calendar_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pull tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_provider_pull_creates_local_events(calendar_db_path: Path) -> None:
|
||||||
|
"""Mock returns 2 events → 2 local events created with external_id set."""
|
||||||
|
ics1 = make_ics("uid-1", "Remote Event 1")
|
||||||
|
ics2 = make_ics("uid-2", "Remote Event 2")
|
||||||
|
client, _ = make_mock_client([ics1, ics2])
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
|
||||||
|
assert len(pulled) == 2
|
||||||
|
events = await list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
assert len(events) == 2
|
||||||
|
titles = {e.title for e in events}
|
||||||
|
assert titles == {"Remote Event 1", "Remote Event 2"}
|
||||||
|
for event in events:
|
||||||
|
assert event.external_id is not None
|
||||||
|
assert event.external_provider == "caldav"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_provider_pull_updates_existing_event(calendar_db_path: Path) -> None:
|
||||||
|
"""Local event exists (matched by external_id), remote is newer → local updated."""
|
||||||
|
local = make_local_event(
|
||||||
|
event_id="evt-1",
|
||||||
|
title="Old Title",
|
||||||
|
external_id="uid-1",
|
||||||
|
last_modified="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
ics_remote = make_ics("uid-1", "Updated Title", last_modified="20260601T120000Z")
|
||||||
|
client, _ = make_mock_client([ics_remote])
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
|
||||||
|
assert len(pulled) == 1
|
||||||
|
updated = await get_event_by_external_id("uid-1", "caldav", USER_ID, calendar_db_path)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.title == "Updated Title"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_provider_push_creates_remote_event(calendar_db_path: Path) -> None:
|
||||||
|
"""Local event with no external_id → push creates remote, external_id stored."""
|
||||||
|
local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
client, _ = make_mock_client([], saved_uid="remote-uid-1")
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
events = await list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
pushed = await provider.push_changes(config, events)
|
||||||
|
|
||||||
|
assert len(pushed) == 1
|
||||||
|
assert pushed[0].external_id == "remote-uid-1"
|
||||||
|
assert pushed[0].external_provider == "caldav"
|
||||||
|
db_event = await get_event_by_external_id("remote-uid-1", "caldav", USER_ID, calendar_db_path)
|
||||||
|
assert db_event is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_provider_push_updates_remote_event(calendar_db_path: Path) -> None:
|
||||||
|
"""Local event modified, has external_id → push updates remote."""
|
||||||
|
local = make_local_event(
|
||||||
|
event_id="evt-1",
|
||||||
|
title="Updated Local Event",
|
||||||
|
external_id="existing-uid",
|
||||||
|
last_modified="2026-06-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
client, mock_calendar = make_mock_client([], saved_uid="existing-uid")
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
events = await list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
pushed = await provider.push_changes(config, events)
|
||||||
|
|
||||||
|
assert len(pushed) == 1
|
||||||
|
assert pushed[0].external_id == "existing-uid"
|
||||||
|
# Verify save_event was called with ICS containing the existing UID
|
||||||
|
saved_ics = mock_calendar.save_event.call_args[0][0]
|
||||||
|
assert b"UID:existing-uid" in saved_ics
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conflict tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_conflict_last_write_wins(calendar_db_path: Path) -> None:
|
||||||
|
"""Both sides modified, local is newer → local wins, local not updated."""
|
||||||
|
local = make_local_event(
|
||||||
|
event_id="evt-1",
|
||||||
|
title="Local Updated",
|
||||||
|
external_id="uid-1",
|
||||||
|
last_modified="2026-06-15T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
ics_remote = make_ics("uid-1", "Remote Older", last_modified="20260601T000000Z")
|
||||||
|
client, _ = make_mock_client([ics_remote])
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
|
||||||
|
# Local wins → no update, pulled is empty
|
||||||
|
assert len(pulled) == 0
|
||||||
|
db_event = await get_event_by_external_id("uid-1", "caldav", USER_ID, calendar_db_path)
|
||||||
|
assert db_event is not None
|
||||||
|
assert db_event.title == "Local Updated"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_conflict_sends_ws_notification(calendar_db_path: Path) -> None:
|
||||||
|
"""Conflict detected → WS callback called with calendar_sync_conflict type (G4)."""
|
||||||
|
notifications: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
async def notify(event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
notifications.append((event_type, payload))
|
||||||
|
|
||||||
|
local = make_local_event(
|
||||||
|
event_id="evt-1",
|
||||||
|
title="Local Updated",
|
||||||
|
external_id="uid-1",
|
||||||
|
last_modified="2026-06-15T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
ics_remote = make_ics("uid-1", "Remote Older", last_modified="20260601T000000Z")
|
||||||
|
client, _ = make_mock_client([ics_remote])
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path,
|
||||||
|
client_factory=client_factory_from(client),
|
||||||
|
notify_callback=notify,
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
await provider.pull_changes(config)
|
||||||
|
|
||||||
|
assert len(notifications) == 1
|
||||||
|
event_type, payload = notifications[0]
|
||||||
|
assert event_type == "calendar_sync_conflict"
|
||||||
|
assert payload["event_id"] == "evt-1"
|
||||||
|
assert payload["winner"] == "local"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RRULE roundtrip test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_rrule_roundtrip(calendar_db_path: Path) -> None:
|
||||||
|
"""Event with RRULE synced → RRULE preserved in both pull and push."""
|
||||||
|
rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=4"
|
||||||
|
ics_remote = make_ics("uid-rrule", "Weekly Meeting", rrule=rrule)
|
||||||
|
client, _ = make_mock_client([ics_remote])
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
# Pull: verify RRULE preserved
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
assert len(pulled) == 1
|
||||||
|
assert pulled[0].rrule is not None
|
||||||
|
assert "FREQ=WEEKLY" in pulled[0].rrule
|
||||||
|
assert "BYDAY=MO" in pulled[0].rrule
|
||||||
|
assert "COUNT=4" in pulled[0].rrule
|
||||||
|
|
||||||
|
# Push: verify RRULE in ICS sent to remote
|
||||||
|
events = await list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
client2, mock_calendar2 = make_mock_client([], saved_uid="uid-rrule")
|
||||||
|
provider2 = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client2)
|
||||||
|
)
|
||||||
|
await provider2.push_changes(config, events)
|
||||||
|
|
||||||
|
saved_ics = mock_calendar2.save_event.call_args[0][0]
|
||||||
|
assert b"RRULE:FREQ=WEEKLY" in saved_ics
|
||||||
|
assert b"BYDAY=MO" in saved_ics
|
||||||
|
assert b"COUNT=4" in saved_ics
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_connection tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_test_connection_success(calendar_db_path: Path) -> None:
|
||||||
|
"""Mock returns calendars → test_connection() returns (True, "")."""
|
||||||
|
client, _ = make_mock_client([])
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
ok, msg = await provider.test_connection(config)
|
||||||
|
assert ok is True
|
||||||
|
assert msg == ""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caldav_test_connection_failure(calendar_db_path: Path) -> None:
|
||||||
|
"""Mock raises → test_connection() returns (False, error_msg)."""
|
||||||
|
client, _ = make_mock_client([], raise_on_connect=ConnectionError("Auth failed"))
|
||||||
|
provider = CalDAVSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
ok, msg = await provider.test_connection(config)
|
||||||
|
assert ok is False
|
||||||
|
assert "Auth failed" in msg
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""Tests for SyncManager — orchestrates external calendar sync (U6, G8).
|
||||||
|
|
||||||
|
Uses a mock ``AbstractSyncProvider`` to test SyncManager in isolation,
|
||||||
|
verifying provider iteration, last_sync updates, and conflict WS push.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# caldav is not installed — inject mock so SyncManager's import of
|
||||||
|
# CalDAVSyncProvider (which imports caldav) does not fail.
|
||||||
|
if "caldav" not in sys.modules: # pragma: no cover
|
||||||
|
sys.modules["caldav"] = MagicMock()
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
init_calendar_db,
|
||||||
|
insert_external_config,
|
||||||
|
list_external_configs,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig
|
||||||
|
from agentkit.calendar.sync.base import AbstractSyncProvider
|
||||||
|
from agentkit.calendar.sync.manager import SyncManager
|
||||||
|
|
||||||
|
USER_ID = "user-1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock provider + helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MockSyncProvider(AbstractSyncProvider):
|
||||||
|
"""In-memory mock provider that records calls for assertion."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.pull_calls: int = 0
|
||||||
|
self.push_calls: int = 0
|
||||||
|
self.pull_configs: list[ExternalCalendarConfig] = []
|
||||||
|
self.push_configs: list[ExternalCalendarConfig] = []
|
||||||
|
|
||||||
|
async def pull_changes(
|
||||||
|
self, config: ExternalCalendarConfig, since: str | None = None
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
self.pull_calls += 1
|
||||||
|
self.pull_configs.append(config)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def push_changes(
|
||||||
|
self, config: ExternalCalendarConfig, events: list[CalendarEvent]
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
self.push_calls += 1
|
||||||
|
self.push_configs.append(config)
|
||||||
|
return events
|
||||||
|
|
||||||
|
async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(
|
||||||
|
config_id: str = "config-1",
|
||||||
|
user_id: str = USER_ID,
|
||||||
|
provider: str = "caldav",
|
||||||
|
last_sync: str | None = None,
|
||||||
|
) -> ExternalCalendarConfig:
|
||||||
|
return ExternalCalendarConfig(
|
||||||
|
id=config_id,
|
||||||
|
user_id=user_id,
|
||||||
|
provider=provider,
|
||||||
|
credentials=json.dumps(
|
||||||
|
{"url": "https://caldav.example.com", "username": "user", "password": "pass"}
|
||||||
|
),
|
||||||
|
last_sync=last_sync,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_event(
|
||||||
|
event_id: str = "evt-1",
|
||||||
|
title: str = "Test Event",
|
||||||
|
last_modified: str = "2026-06-01T00:00:00+00:00",
|
||||||
|
external_id: str | None = None,
|
||||||
|
) -> CalendarEvent:
|
||||||
|
return CalendarEvent(
|
||||||
|
id=event_id,
|
||||||
|
user_id=USER_ID,
|
||||||
|
title=title,
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
external_id=external_id,
|
||||||
|
external_provider="caldav" if external_id else None,
|
||||||
|
last_modified=last_modified,
|
||||||
|
created_at=last_modified,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calendar_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sync_all tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_manager_sync_all_iterates_providers(
|
||||||
|
calendar_db_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""2 configs → both providers called (G8)."""
|
||||||
|
config1 = make_config(config_id="config-1")
|
||||||
|
config2 = make_config(config_id="config-2")
|
||||||
|
await insert_external_config(config1, calendar_db_path)
|
||||||
|
await insert_external_config(config2, calendar_db_path)
|
||||||
|
|
||||||
|
mock_provider = MockSyncProvider()
|
||||||
|
manager = SyncManager(db_path=calendar_db_path, providers={"caldav": mock_provider})
|
||||||
|
|
||||||
|
result = await manager.sync_all(USER_ID)
|
||||||
|
|
||||||
|
assert result["synced"] == 2
|
||||||
|
assert result["errors"] == []
|
||||||
|
assert mock_provider.pull_calls == 2
|
||||||
|
assert mock_provider.push_calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sync_provider tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_manager_sync_provider_updates_last_sync(
|
||||||
|
calendar_db_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Sync completes → config.last_sync updated (G8)."""
|
||||||
|
config = make_config(config_id="config-1", last_sync=None)
|
||||||
|
await insert_external_config(config, calendar_db_path)
|
||||||
|
|
||||||
|
mock_provider = MockSyncProvider()
|
||||||
|
manager = SyncManager(db_path=calendar_db_path, providers={"caldav": mock_provider})
|
||||||
|
|
||||||
|
await manager.sync_provider("config-1")
|
||||||
|
|
||||||
|
configs = await list_external_configs(USER_ID, calendar_db_path)
|
||||||
|
assert len(configs) == 1
|
||||||
|
assert configs[0].last_sync is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_conflict tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_manager_resolve_conflict_notifies_user(
|
||||||
|
calendar_db_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Conflict → WS callback called with calendar_sync_conflict type (G8/G4)."""
|
||||||
|
notifications: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
async def notify(event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
notifications.append((event_type, payload))
|
||||||
|
|
||||||
|
manager = SyncManager(db_path=calendar_db_path, notify_callback=notify)
|
||||||
|
|
||||||
|
local = make_event(
|
||||||
|
event_id="evt-1",
|
||||||
|
title="Local Version",
|
||||||
|
last_modified="2026-06-15T00:00:00+00:00",
|
||||||
|
external_id="uid-1",
|
||||||
|
)
|
||||||
|
remote = make_event(
|
||||||
|
event_id="evt-remote",
|
||||||
|
title="Remote Version",
|
||||||
|
last_modified="2026-06-01T00:00:00+00:00",
|
||||||
|
external_id="uid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
winner = await manager.resolve_conflict(local, remote)
|
||||||
|
|
||||||
|
assert len(notifications) == 1
|
||||||
|
event_type, payload = notifications[0]
|
||||||
|
assert event_type == "calendar_sync_conflict"
|
||||||
|
assert payload["winner"] == "local"
|
||||||
|
assert winner is local # Local is newer → local wins
|
||||||
|
|
@ -0,0 +1,556 @@
|
||||||
|
"""Tests for OutlookSyncProvider — bidirectional Outlook Calendar sync (U7).
|
||||||
|
|
||||||
|
All Microsoft Graph API interactions are mocked via the ``client_factory``
|
||||||
|
injection point. No real HTTP calls are made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentkit.calendar.db import (
|
||||||
|
get_event_by_external_id,
|
||||||
|
init_calendar_db,
|
||||||
|
insert_event,
|
||||||
|
list_events,
|
||||||
|
)
|
||||||
|
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig
|
||||||
|
from agentkit.calendar.sync.outlook_provider import OutlookSyncProvider
|
||||||
|
|
||||||
|
USER_ID = "user-1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
"""Minimal mock of an httpx.Response."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int = 200, json_data: Any = None) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self._json = json_data
|
||||||
|
self.text = json.dumps(json_data) if json_data is not None else ""
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
return self._json if self._json is not None else {}
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise RuntimeError(f"HTTP {self.status_code}")
|
||||||
|
|
||||||
|
|
||||||
|
class MockOutlookClient:
|
||||||
|
"""Mock httpx.AsyncClient — records requests, returns queued responses."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[dict[str, Any]] = []
|
||||||
|
self._responses: list[MockResponse] = []
|
||||||
|
|
||||||
|
def add_response(self, response: MockResponse) -> None:
|
||||||
|
self._responses.append(response)
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: Any = None,
|
||||||
|
json: Any = None,
|
||||||
|
params: Any = None,
|
||||||
|
data: Any = None,
|
||||||
|
) -> MockResponse:
|
||||||
|
self.requests.append(
|
||||||
|
{
|
||||||
|
"method": method,
|
||||||
|
"url": url,
|
||||||
|
"headers": headers,
|
||||||
|
"json": json,
|
||||||
|
"params": params,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if self._responses:
|
||||||
|
return self._responses.pop(0)
|
||||||
|
return MockResponse(status_code=200, json_data={})
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_outlook_event(
|
||||||
|
eid: str = "outlook-1",
|
||||||
|
subject: str = "Outlook Event",
|
||||||
|
start: str = "2026-07-01T10:00:00",
|
||||||
|
end: str = "2026-07-01T11:00:00",
|
||||||
|
is_all_day: bool = False,
|
||||||
|
description: str = "",
|
||||||
|
location: str = "",
|
||||||
|
recurrence: dict[str, Any] | None = None,
|
||||||
|
last_modified: str = "2026-06-01T00:00:00Z",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a minimal Graph event JSON dict."""
|
||||||
|
event: dict[str, Any] = {
|
||||||
|
"id": eid,
|
||||||
|
"subject": subject,
|
||||||
|
"start": {"dateTime": start, "timeZone": "UTC"},
|
||||||
|
"end": {"dateTime": end, "timeZone": "UTC"},
|
||||||
|
"isAllDay": is_all_day,
|
||||||
|
"lastModifiedDateTime": last_modified,
|
||||||
|
}
|
||||||
|
if description:
|
||||||
|
event["body"] = {"contentType": "Text", "content": description}
|
||||||
|
if location:
|
||||||
|
event["location"] = {"displayName": location}
|
||||||
|
if recurrence:
|
||||||
|
event["recurrence"] = recurrence
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(
|
||||||
|
config_id: str = "config-1",
|
||||||
|
user_id: str = USER_ID,
|
||||||
|
provider: str = "outlook",
|
||||||
|
last_sync: str | None = None,
|
||||||
|
sync_token: str | None = None,
|
||||||
|
access_token: str = "test-token",
|
||||||
|
refresh_token: str = "test-refresh",
|
||||||
|
) -> ExternalCalendarConfig:
|
||||||
|
return ExternalCalendarConfig(
|
||||||
|
id=config_id,
|
||||||
|
user_id=user_id,
|
||||||
|
provider=provider,
|
||||||
|
credentials=json.dumps(
|
||||||
|
{
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": "test-client-id",
|
||||||
|
"expires_at": "2026-12-31T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
last_sync=last_sync,
|
||||||
|
sync_token=sync_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_local_event(
|
||||||
|
event_id: str = "evt-1",
|
||||||
|
title: str = "Local Event",
|
||||||
|
external_id: str | None = None,
|
||||||
|
last_modified: str = "2026-01-01T00:00:00+00:00",
|
||||||
|
rrule: str | None = None,
|
||||||
|
) -> CalendarEvent:
|
||||||
|
return CalendarEvent(
|
||||||
|
id=event_id,
|
||||||
|
user_id=USER_ID,
|
||||||
|
title=title,
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
external_id=external_id,
|
||||||
|
external_provider="outlook" if external_id else None,
|
||||||
|
last_modified=last_modified,
|
||||||
|
created_at=last_modified,
|
||||||
|
rrule=rrule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def client_factory_from(client: MockOutlookClient):
|
||||||
|
"""Wrap a mock client in a client_factory callable."""
|
||||||
|
|
||||||
|
def factory() -> MockOutlookClient:
|
||||||
|
return client
|
||||||
|
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calendar_db_path(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "test_calendar.db"
|
||||||
|
asyncio.run(init_calendar_db(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pull tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outlook_provider_pull_delta_initial_sync(
|
||||||
|
calendar_db_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Mock returns 3 events → 3 local events created with external_id set."""
|
||||||
|
events = [
|
||||||
|
make_outlook_event(eid="outlook-1", subject="Event 1"),
|
||||||
|
make_outlook_event(eid="outlook-2", subject="Event 2"),
|
||||||
|
make_outlook_event(eid="outlook-3", subject="Event 3"),
|
||||||
|
]
|
||||||
|
response = MockResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"value": events,
|
||||||
|
"@odata.deltaLink": (
|
||||||
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=initial-token"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_client = MockOutlookClient()
|
||||||
|
mock_client.add_response(response)
|
||||||
|
|
||||||
|
provider = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
|
||||||
|
assert len(pulled) == 3
|
||||||
|
db_events = await list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
assert len(db_events) == 3
|
||||||
|
titles = {e.title for e in db_events}
|
||||||
|
assert titles == {"Event 1", "Event 2", "Event 3"}
|
||||||
|
for event in db_events:
|
||||||
|
assert event.external_id is not None
|
||||||
|
assert event.external_provider == "outlook"
|
||||||
|
# Delta token stored for next incremental sync
|
||||||
|
assert config.sync_token == "initial-token"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outlook_provider_pull_delta_incremental(
|
||||||
|
calendar_db_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Config has sync_token → incremental sync. 1 new + 1 updated processed."""
|
||||||
|
# Pre-insert a local event that will be updated by the remote
|
||||||
|
local = make_local_event(
|
||||||
|
event_id="evt-1",
|
||||||
|
title="Old Title",
|
||||||
|
external_id="outlook-existing",
|
||||||
|
last_modified="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
new_event = make_outlook_event(eid="outlook-new", subject="New Event")
|
||||||
|
updated_event = make_outlook_event(
|
||||||
|
eid="outlook-existing",
|
||||||
|
subject="Updated Title",
|
||||||
|
last_modified="2026-06-15T00:00:00Z", # newer than local
|
||||||
|
)
|
||||||
|
response = MockResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"value": [new_event, updated_event],
|
||||||
|
"@odata.deltaLink": (
|
||||||
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=new-token"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_client = MockOutlookClient()
|
||||||
|
mock_client.add_response(response)
|
||||||
|
|
||||||
|
provider = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
|
||||||
|
)
|
||||||
|
config = make_config(sync_token="previous-token")
|
||||||
|
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
|
||||||
|
assert len(pulled) == 2 # 1 new + 1 updated
|
||||||
|
|
||||||
|
# Verify the request URL contains the delta token (incremental sync)
|
||||||
|
assert "$deltaToken=previous-token" in mock_client.requests[0]["url"]
|
||||||
|
|
||||||
|
# New event was created
|
||||||
|
new_db = await get_event_by_external_id("outlook-new", "outlook", USER_ID, calendar_db_path)
|
||||||
|
assert new_db is not None
|
||||||
|
assert new_db.title == "New Event"
|
||||||
|
|
||||||
|
# Existing event was updated (remote was newer)
|
||||||
|
updated_db = await get_event_by_external_id(
|
||||||
|
"outlook-existing", "outlook", USER_ID, calendar_db_path
|
||||||
|
)
|
||||||
|
assert updated_db is not None
|
||||||
|
assert updated_db.title == "Updated Title"
|
||||||
|
|
||||||
|
# Delta token updated
|
||||||
|
assert config.sync_token == "new-token"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outlook_provider_push_creates_remote_event(
|
||||||
|
calendar_db_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Local event with no external_id → POST creates remote, ID stored."""
|
||||||
|
local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
mock_client = MockOutlookClient()
|
||||||
|
mock_client.add_response(
|
||||||
|
MockResponse(201, {"id": "remote-uid-1", "subject": "New Local Event"})
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
events = await list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
pushed = await provider.push_changes(config, events)
|
||||||
|
|
||||||
|
assert len(pushed) == 1
|
||||||
|
assert pushed[0].external_id == "remote-uid-1"
|
||||||
|
assert pushed[0].external_provider == "outlook"
|
||||||
|
|
||||||
|
# Verify POST was called to /me/events
|
||||||
|
req = mock_client.requests[0]
|
||||||
|
assert req["method"] == "POST"
|
||||||
|
assert "/me/events" in req["url"]
|
||||||
|
assert req["json"]["subject"] == "New Local Event"
|
||||||
|
|
||||||
|
# DB updated with external_id
|
||||||
|
db_event = await get_event_by_external_id("remote-uid-1", "outlook", USER_ID, calendar_db_path)
|
||||||
|
assert db_event is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outlook_provider_push_updates_remote_event(
|
||||||
|
calendar_db_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Local event with external_id → PATCH updates remote."""
|
||||||
|
local = make_local_event(
|
||||||
|
event_id="evt-1",
|
||||||
|
title="Updated Local Event",
|
||||||
|
external_id="existing-uid",
|
||||||
|
last_modified="2026-06-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
mock_client = MockOutlookClient()
|
||||||
|
mock_client.add_response(MockResponse(200, {"id": "existing-uid"}))
|
||||||
|
|
||||||
|
provider = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
events = await list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
pushed = await provider.push_changes(config, events)
|
||||||
|
|
||||||
|
assert len(pushed) == 1
|
||||||
|
assert pushed[0].external_id == "existing-uid"
|
||||||
|
|
||||||
|
# Verify PATCH was called to /me/events/{id}
|
||||||
|
req = mock_client.requests[0]
|
||||||
|
assert req["method"] == "PATCH"
|
||||||
|
assert "/me/events/existing-uid" in req["url"]
|
||||||
|
assert req["json"]["subject"] == "Updated Local Event"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token refresh test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outlook_token_refresh_on_401(calendar_db_path: Path) -> None:
|
||||||
|
"""Mock 401 → refresh token used → request retried with new token."""
|
||||||
|
mock_client = MockOutlookClient()
|
||||||
|
# 1. First GET → 401 (token expired)
|
||||||
|
mock_client.add_response(MockResponse(401, {"error": "token expired"}))
|
||||||
|
# 2. Token refresh POST → 200 with new tokens
|
||||||
|
mock_client.add_response(
|
||||||
|
MockResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"access_token": "new-token",
|
||||||
|
"refresh_token": "new-refresh",
|
||||||
|
"expires_in": 3600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 3. Retry GET → 200 with events
|
||||||
|
mock_client.add_response(
|
||||||
|
MockResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"value": [make_outlook_event(eid="outlook-1", subject="Refreshed Event")],
|
||||||
|
"@odata.deltaLink": (
|
||||||
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta"
|
||||||
|
"?$deltaToken=after-refresh"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
|
||||||
|
)
|
||||||
|
config = make_config(access_token="expired-token")
|
||||||
|
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
|
||||||
|
assert len(pulled) == 1
|
||||||
|
assert pulled[0].title == "Refreshed Event"
|
||||||
|
|
||||||
|
# 3 requests: GET (401), POST (refresh), GET (retry)
|
||||||
|
assert len(mock_client.requests) == 3
|
||||||
|
assert mock_client.requests[0]["method"] == "GET"
|
||||||
|
assert mock_client.requests[1]["method"] == "POST"
|
||||||
|
assert mock_client.requests[2]["method"] == "GET"
|
||||||
|
|
||||||
|
# Token refresh hit the Azure AD token endpoint
|
||||||
|
assert "login.microsoftonline.com" in mock_client.requests[1]["url"]
|
||||||
|
assert mock_client.requests[1]["data"]["grant_type"] == "refresh_token"
|
||||||
|
assert mock_client.requests[1]["data"]["refresh_token"] == "test-refresh"
|
||||||
|
|
||||||
|
# Credentials updated in config
|
||||||
|
creds = json.loads(config.credentials)
|
||||||
|
assert creds["access_token"] == "new-token"
|
||||||
|
assert creds["refresh_token"] == "new-refresh"
|
||||||
|
|
||||||
|
# Retry used the new access token
|
||||||
|
retry_headers = mock_client.requests[2]["headers"]
|
||||||
|
assert retry_headers["Authorization"] == "Bearer new-token"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conflict test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outlook_conflict_last_write_wins(calendar_db_path: Path) -> None:
|
||||||
|
"""Both sides modified, local is newer → local wins, local not updated."""
|
||||||
|
local = make_local_event(
|
||||||
|
event_id="evt-1",
|
||||||
|
title="Local Updated",
|
||||||
|
external_id="outlook-1",
|
||||||
|
last_modified="2026-06-15T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await insert_event(local, calendar_db_path)
|
||||||
|
|
||||||
|
remote = make_outlook_event(
|
||||||
|
eid="outlook-1",
|
||||||
|
subject="Remote Older",
|
||||||
|
last_modified="2026-06-01T00:00:00Z", # older than local
|
||||||
|
)
|
||||||
|
response = MockResponse(200, {"value": [remote]})
|
||||||
|
mock_client = MockOutlookClient()
|
||||||
|
mock_client.add_response(response)
|
||||||
|
|
||||||
|
provider = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
|
||||||
|
# Local wins → no update, pulled is empty
|
||||||
|
assert len(pulled) == 0
|
||||||
|
db_event = await get_event_by_external_id("outlook-1", "outlook", USER_ID, calendar_db_path)
|
||||||
|
assert db_event is not None
|
||||||
|
assert db_event.title == "Local Updated"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RRULE roundtrip test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outlook_rrule_roundtrip(calendar_db_path: Path) -> None:
|
||||||
|
"""Recurring event synced → RRULE preserved in both pull and push."""
|
||||||
|
recurrence = {
|
||||||
|
"pattern": {
|
||||||
|
"type": "weekly",
|
||||||
|
"interval": 1,
|
||||||
|
"daysOfWeek": ["monday"],
|
||||||
|
"numberOfOccurrences": 4,
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"type": "numbered",
|
||||||
|
"startDate": "2026-07-01",
|
||||||
|
"numberOfOccurrences": 4,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
remote = make_outlook_event(
|
||||||
|
eid="outlook-rrule", subject="Weekly Meeting", recurrence=recurrence
|
||||||
|
)
|
||||||
|
response = MockResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"value": [remote],
|
||||||
|
"@odata.deltaLink": (
|
||||||
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=rrule-token"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_client = MockOutlookClient()
|
||||||
|
mock_client.add_response(response)
|
||||||
|
|
||||||
|
provider = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
# Pull: verify RRULE preserved
|
||||||
|
pulled = await provider.pull_changes(config)
|
||||||
|
assert len(pulled) == 1
|
||||||
|
assert pulled[0].rrule is not None
|
||||||
|
assert "FREQ=WEEKLY" in pulled[0].rrule
|
||||||
|
assert "BYDAY=MO" in pulled[0].rrule
|
||||||
|
assert "COUNT=4" in pulled[0].rrule
|
||||||
|
|
||||||
|
# Push: verify recurrence pattern in request body sent to remote
|
||||||
|
events = await list_events(USER_ID, db_path=calendar_db_path)
|
||||||
|
mock_client2 = MockOutlookClient()
|
||||||
|
mock_client2.add_response(
|
||||||
|
MockResponse(201, {"id": "outlook-rrule", "subject": "Weekly Meeting"})
|
||||||
|
)
|
||||||
|
provider2 = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client2)
|
||||||
|
)
|
||||||
|
await provider2.push_changes(config, events)
|
||||||
|
|
||||||
|
req = mock_client2.requests[0]
|
||||||
|
assert req["method"] == "PATCH"
|
||||||
|
assert "/me/events/outlook-rrule" in req["url"]
|
||||||
|
body = req["json"]
|
||||||
|
assert "recurrence" in body
|
||||||
|
assert body["recurrence"]["pattern"]["type"] == "weekly"
|
||||||
|
assert body["recurrence"]["pattern"]["daysOfWeek"] == ["monday"]
|
||||||
|
assert body["recurrence"]["pattern"]["numberOfOccurrences"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_connection tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outlook_test_connection_success(calendar_db_path: Path) -> None:
|
||||||
|
"""Mock Graph /me returns user profile → test_connection() returns (True, '')."""
|
||||||
|
mock_client = MockOutlookClient()
|
||||||
|
mock_client.add_response(MockResponse(200, {"id": "user-id", "displayName": "Test User"}))
|
||||||
|
|
||||||
|
provider = OutlookSyncProvider(
|
||||||
|
db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
|
||||||
|
)
|
||||||
|
config = make_config()
|
||||||
|
|
||||||
|
ok, msg = await provider.test_connection(config)
|
||||||
|
assert ok is True
|
||||||
|
assert msg == ""
|
||||||
|
|
||||||
|
# Verify GET /me was called
|
||||||
|
req = mock_client.requests[0]
|
||||||
|
assert req["method"] == "GET"
|
||||||
|
assert "/me" in req["url"]
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
"""Tests for CalendarTool — Agent tool wrapper for ReAct integration (U3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentkit.calendar.db import get_event_tags, init_calendar_db
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
from agentkit.tools.calendar_tool import CalendarTool
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(tmp_path: Path) -> CalendarService:
|
||||||
|
"""Provide a CalendarService backed by a temp DB."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
asyncio.run(init_calendar_db(db_path))
|
||||||
|
return CalendarService(db_path=db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tool(service: CalendarService) -> CalendarTool:
|
||||||
|
return CalendarTool(calendar_service=service)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_event
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_action_returns_success(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""create_event returns success and persists the event with source='agent'."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Sprint Planning",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
description="Bi-weekly sprint planning",
|
||||||
|
location="Room A",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
event_dict = result["event"]
|
||||||
|
assert event_dict["title"] == "Sprint Planning"
|
||||||
|
assert event_dict["source"] == "agent"
|
||||||
|
assert event_dict["user_id"] == "user-1"
|
||||||
|
|
||||||
|
# Verify persisted in DB
|
||||||
|
fetched = await service.get_event(event_dict["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.title == "Sprint Planning"
|
||||||
|
assert fetched.source == "agent"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_with_recurrence_sets_rrule(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""rrule param is stored correctly on the event."""
|
||||||
|
rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=10"
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Weekly Standup",
|
||||||
|
start_time="2026-07-06T09:00:00+00:00",
|
||||||
|
end_time="2026-07-06T09:30:00+00:00",
|
||||||
|
rrule=rrule,
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["event"]["rrule"] == rrule
|
||||||
|
|
||||||
|
fetched = await service.get_event(result["event"]["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.rrule == rrule
|
||||||
|
|
||||||
|
|
||||||
|
async def test_query_events_returns_list(tool: CalendarTool) -> None:
|
||||||
|
"""create 2 events, query, verify both returned."""
|
||||||
|
await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Event A",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Event B",
|
||||||
|
start_time="2026-07-02T14:00:00+00:00",
|
||||||
|
end_time="2026-07-02T15:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await tool.execute(
|
||||||
|
action="query_events",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
events = result["events"]
|
||||||
|
assert len(events) == 2
|
||||||
|
titles = {e["title"] for e in events}
|
||||||
|
assert titles == {"Event A", "Event B"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_event_action_modifies_fields(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""create then update title; verify the field is modified."""
|
||||||
|
create_result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Original Title",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert create_result["success"] is True
|
||||||
|
event_id = create_result["event"]["id"]
|
||||||
|
|
||||||
|
update_result = await tool.execute(
|
||||||
|
action="update_event",
|
||||||
|
user_id="user-1",
|
||||||
|
event_id=event_id,
|
||||||
|
title="Updated Title",
|
||||||
|
)
|
||||||
|
assert update_result["success"] is True
|
||||||
|
|
||||||
|
fetched = await service.get_event(event_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.title == "Updated Title"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_event_action_removes_record(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""create then delete; verify the record is gone."""
|
||||||
|
create_result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="To Be Deleted",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert create_result["success"] is True
|
||||||
|
event_id = create_result["event"]["id"]
|
||||||
|
|
||||||
|
delete_result = await tool.execute(
|
||||||
|
action="delete_event",
|
||||||
|
user_id="user-1",
|
||||||
|
event_id=event_id,
|
||||||
|
)
|
||||||
|
assert delete_result["success"] is True
|
||||||
|
|
||||||
|
fetched = await service.get_event(event_id)
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# error paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_action_returns_error(tool: CalendarTool) -> None:
|
||||||
|
"""Unknown action returns success=False with error message."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="frobnicate",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Unknown action" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_required_field_returns_error(tool: CalendarTool) -> None:
|
||||||
|
"""create_event without title returns success=False."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Missing required field" in result["error"]
|
||||||
|
assert "title" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# conversation_id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_created_event_has_conversation_id(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""conversation_id is set from context when provided."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Chat-Initiated Event",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
conversation_id="conv-abc-123",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["event"]["conversation_id"] == "conv-abc-123"
|
||||||
|
|
||||||
|
fetched = await service.get_event(result["event"]["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.conversation_id == "conv-abc-123"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# event_type_name resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_with_event_type_name(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""create event with event_type_name='Meeting' creates the type and links it."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Strategy Sync",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
event_type_name="Meeting",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
event_type_id = result["event"]["event_type_id"]
|
||||||
|
assert event_type_id is not None
|
||||||
|
|
||||||
|
# Verify the event type was created
|
||||||
|
types = await service.list_event_types("user-1")
|
||||||
|
meeting_types = [t for t in types if t.name == "Meeting"]
|
||||||
|
assert len(meeting_types) == 1
|
||||||
|
assert meeting_types[0].id == event_type_id
|
||||||
|
|
||||||
|
# Verify the event references the type
|
||||||
|
fetched = await service.get_event(result["event"]["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.event_type_id == event_type_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_reuses_existing_event_type(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""If event_type_name matches an existing type, it is reused (not duplicated)."""
|
||||||
|
# Pre-create the type
|
||||||
|
existing = await service.create_event_type("user-1", "Meeting")
|
||||||
|
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Second Meeting",
|
||||||
|
start_time="2026-07-02T10:00:00+00:00",
|
||||||
|
end_time="2026-07-02T11:00:00+00:00",
|
||||||
|
event_type_name="Meeting",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["event"]["event_type_id"] == existing.id
|
||||||
|
|
||||||
|
# No duplicate type created
|
||||||
|
types = await service.list_event_types("user-1")
|
||||||
|
meeting_types = [t for t in types if t.name == "Meeting"]
|
||||||
|
assert len(meeting_types) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tag_names resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_with_tag_names(
|
||||||
|
tool: CalendarTool, service: CalendarService, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""create event with tag_names=['urgent', 'work'] creates tags and links them."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Urgent Work Task",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
tag_names=["urgent", "work"],
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
event_id = result["event"]["id"]
|
||||||
|
|
||||||
|
# Verify tags were created
|
||||||
|
tags = await service.list_tags("user-1")
|
||||||
|
tag_names = {t.name for t in tags}
|
||||||
|
assert "urgent" in tag_names
|
||||||
|
assert "work" in tag_names
|
||||||
|
|
||||||
|
# Verify tags are linked to the event
|
||||||
|
linked_tags = await get_event_tags(event_id, service.db_path)
|
||||||
|
linked_names = {t.name for t in linked_tags}
|
||||||
|
assert linked_names == {"urgent", "work"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_reuses_existing_tags(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""If a tag name matches an existing tag, it is reused (not duplicated)."""
|
||||||
|
# Pre-create a tag
|
||||||
|
existing = await service.create_tag("user-1", "urgent")
|
||||||
|
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Tagged Event",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
tag_names=["urgent", "new-tag"],
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
# 'urgent' should not be duplicated, 'new-tag' should be created
|
||||||
|
tags = await service.list_tags("user-1")
|
||||||
|
urgent_tags = [t for t in tags if t.name == "urgent"]
|
||||||
|
assert len(urgent_tags) == 1
|
||||||
|
assert urgent_tags[0].id == existing.id
|
||||||
|
new_tags = [t for t in tags if t.name == "new-tag"]
|
||||||
|
assert len(new_tags) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tool registration / schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_name_and_schema(tool: CalendarTool) -> None:
|
||||||
|
"""Tool has correct name and input_schema."""
|
||||||
|
assert tool.name == "calendar"
|
||||||
|
schema = tool.input_schema
|
||||||
|
assert schema["type"] == "object"
|
||||||
|
assert "action" in schema["properties"]
|
||||||
|
assert "user_id" in schema["properties"]
|
||||||
|
assert schema["properties"]["action"]["enum"] == [
|
||||||
|
"create_event",
|
||||||
|
"query_events",
|
||||||
|
"update_event",
|
||||||
|
"delete_event",
|
||||||
|
]
|
||||||
|
assert "action" in schema["required"]
|
||||||
|
assert "user_id" in schema["required"]
|
||||||
Loading…
Reference in New Issue