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`),
默认文件名正确、目录可选;选中保存后回到日历。
94 lines
4.5 KiB
Plaintext
94 lines
4.5 KiB
Plaintext
/*
|
||
* 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');
|
||
}
|
||
}
|