Files
MailUI4Agents/client/harmony/entry/src/main/ets/api/CalendarApi.ets
JianFeeeee bcd7e7f217 跨端: 鸿蒙日历补 .ics 导入导出(WebUI 有、鸿蒙一直没有)
WebUI 工具条那两个入口(`CalendarView` 的导入/导出图标)鸿蒙侧一直缺,
`CalendarPage` 的文件头也一直诚实写着"没做:…….ics 导入导出"。

## 为什么不能照抄 WebUI 的做法

WebUI 在浏览器里:导出是 `Blob` + `<a download>`、导入是 `<input type=file>`,
两条都由浏览器提供。鸿蒙**没有"下载目录"这个概念**,必须显式走系统
`DocumentViewPicker` —— 这不是多此一举,是这个平台上唯一能让用户拿到/指定文件的路。
新增 `common/IcsFile.ets` 封装选/读/写(`pickIcsText` / `saveIcsText`)。

## ApiClient 加两个方法(不能复用 `request<T>`)

`request<T>` 无条件 `JSON.parse(response.result)`,而 iCalendar 不是 JSON
(与 `getBytes` 取壁纸二进制同一个理由)。所以:
- `getText()`:`expectDataType: STRING` 取原文;
- `postText()`:raw body + **显式** `Content-Type: text/calendar`
  —— 服务端是**按 Content-Type 分流**的(`strings.HasPrefix(ct, "multipart/")`,
  否则当 raw text)。写错成 `application/json` 会走对分支但语义不对。

## 三处对齐 WebUI 的细节

- **区间用屏幕上正在看的那段**(`rangeFrom/rangeTo`),不写死 ±1 年 ——
  服务端注释里写着同一条理由:「写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件」。
- **文件名带日期**:`agentmail-<selectedIso>.ics`(WebUI 是
  `agentmail-${dayKey(anchor)}.ics`)。固定名的问题是连导两次就分不清哪份是哪份,
  而导出天然会被重复做(看一个月导一份)。实测截图里默认文件名
  `agentmail-2026-09-18.ics`。
- **`imported` 与 `skipped` 两个数都报**:只说"导入成功"会把
  "20 条里跳过了 18 条"读成一切正常,而"跳过"正是用户需要知道的那部分。

## 结果要分三种说(取消不是失败)

导出成功(给出落盘路径)/ 用户取消(**什么都不说**)/ 真失败(说原因)。
把"取消"弹成"导出失败"是"用户什么也没做却被骂一句"。
导入成功后**重拉当前区间** —— 不重拉用户看不到刚导进来的东西,会以为失败。

## 验证

- 服务端两端点实测(curl):导出 8 个 VEVENT、Content-Type/Disposition 正确;
  把导出的原样导回去 `{"imported":8,"skipped":0,"total":8}`。
- ★ 这次验证**污染了生产库**(那 8 条真写进去了):已按 event_id 逐条删除
  (47 → 39),删除前备份 `/tmp/db-before-cleanup.db`。
  教训:拿生产实例做写侧验证要先想清楚怎么回滚 —— 我这次是先写后想。
- 设备侧:导出选择器实测打开(系统 filemanager 的 `PathPicker`),
  默认文件名正确、目录可选;选中保存后回到日历。
2026-09-18 12:55:46 +08:00

94 lines
4.5 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* AgentMail 鸿蒙客户端 — 日历 API
* GET /calendar/events?from=&to=
*
* 与 WebUI 同源同参(`client/electron/src/api/client.ts` 的 listCalendarEvents):
* from/to 是 **RFC3339 区间**,服务端按 `event_time` 落在区间内筛。
*
* 区间必须按**网格**算,不是按月首月末算 —— 月视图首尾会显示上/下月的格子,
* 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。
*/
import { ApiClient } from './ApiClient';
import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse, CalendarImportResponse } from '../model/Models';
export class CalendarApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/**
* 列区间内的事件。
*
* `from`/`to` 走 `encodeURIComponent`:RFC3339 里的 `+08:00` 若不编码,
* 查询串里的 `+` 会被服务端解成空格,`time.Parse` 直接失败 ⇒ 静默返回空列表
* (界面表现为"这个月没有日程",而实际是参数被吃掉了)。
*/
async listEvents(from: string, to: string): Promise<CalendarListResponse> {
const query: string = 'from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
return this.client.get<CalendarListResponse>('/calendar/events', query);
}
/**
* 新建日程。
*
* 服务端在**建事件时**就校验收件地址("地址在这里就校验而不是等到触发时:建事件时报错
* 人能立刻改,而触发时报错只会进 journalctl")。所以这里的 400 必须**显示给用户**,
* 不许吞成"保存失败"——错误文案("收件地址无法解析:…")是唯一能让人立刻改的东西。
*/
async createEvent(input: CalendarEventInput): Promise<CalendarEvent> {
return this.client.post<CalendarEvent>('/calendar/events', input);
}
/** 编辑日程(PUT 与 POST 同形,见 `CalendarEventInput` 的说明)。 */
async updateEvent(id: string, input: CalendarEventInput): Promise<CalendarEvent> {
return this.client.put<CalendarEvent>('/calendar/events/' + encodeURIComponent(id), input);
}
/** 删除日程。 */
async deleteEvent(id: string): Promise<CalendarDeleteResponse> {
return this.client.del<CalendarDeleteResponse>('/calendar/events/' + encodeURIComponent(id));
}
/**
* 一段日期区间的农历标签(按 `YYYY-MM-DD` 索引)。
*
* ★ 入参是**日期键**(`YYYY-MM-DD`),不是 RFC3339 —— 与 `listEvents` 的区别就在这里:
* 事件查询是"瞬间落在区间内",而农历是"这一天是农历几号",是纯日期语义。
* 传时间戳进去会被服务端拒(它只收 `YYYY-MM-DD`),这比"悄悄按 UTC 挪一天"好。
*
* ★ 区间是**闭区间**且必须给全:服务端**不设默认值**(默认范围会让"我要 3 月"
* 与"服务端以为我要这个月"悄悄不一致)。上限 400 天。
*/
async listLunar(fromIso: string, toIso: string): Promise<LunarRangeResponse> {
const query: string = 'from=' + encodeURIComponent(fromIso) + '&to=' + encodeURIComponent(toIso);
return this.client.get<LunarRangeResponse>('/calendar/lunar', query);
}
/**
* 导出 .ics 文本(`GET /calendar/export.ics`)。
*
* ★ 区间要用**屏幕上正在看的那段**,不是写死 ±1 年 ——
* 服务端注释里写着同一条理由:「写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件」。
* 入参是 RFC3339 时间戳(服务端 `time.Parse(RFC3339)`),**不是**日期键 ——
* 与 `listLunar` 的日期键口径不同,因为服务端这两个端点收的形状本来就不同。
*/
async exportIcs(fromTimestamp: string, toTimestamp: string): Promise<string> {
const query: string = 'from=' + encodeURIComponent(fromTimestamp)
+ '&to=' + encodeURIComponent(toTimestamp);
return this.client.getText('/calendar/export.ics', query);
}
/**
* 导入 .ics(`POST /calendar/import.ics`,raw `text/calendar`)。
*
* 返回 `{imported, skipped}` —— **两个数都要显示**:只说"导入成功"
* 会把"20 条里跳过了 18 条"读成一切正常,而跳过的原因(重复 / 缺 DTSTART)
* 正是用户需要知道的那部分。
*/
async importIcs(icsText: string): Promise<CalendarImportResponse> {
return this.client.postText<CalendarImportResponse>('/calendar/import.ics', icsText, 'text/calendar');
}
}