跨端: 鸿蒙日历补 .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`),
默认文件名正确、目录可选;选中保存后回到日历。
This commit is contained in:
122
client/harmony/entry/src/main/ets/common/IcsFile.ets
Normal file
122
client/harmony/entry/src/main/ets/common/IcsFile.ets
Normal file
@ -0,0 +1,122 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — .ics 文件的选/读/写
|
||||
*
|
||||
* 为什么单独一个文件:这三件事都带 `@kit.CoreFileKit` 依赖,而**纯逻辑必须没有 SDK 依赖**
|
||||
* (`model/*.ts` 要让 Node 判据直接 import)。文件选择器天然是设备相关的,所以它
|
||||
* 单独待在这里,不污染任何可以被离线判据执行的东西。
|
||||
*
|
||||
* 与 WebUI 的分工差异:WebUI 在浏览器里,导出是 `Blob` + `<a download>`、
|
||||
* 导入是 `<input type=file>`,两条都由浏览器实现。鸿蒙没有"下载目录"这个概念,
|
||||
* 必须显式走系统文件选择器 —— 这不是"多此一举",是这个平台上唯一
|
||||
* 能让用户拿到文件 / 指定文件的路。
|
||||
*/
|
||||
import { picker } from '@kit.CoreFileKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
|
||||
/** 导出文件的建议名(与 WebUI/服务端的 `agentmail-calendar.ics` 同名) */
|
||||
export const ICS_DEFAULT_FILENAME: string = 'agentmail-calendar.ics';
|
||||
|
||||
/** 导出成功/失败的结果 —— 调用侧要能说出"存到哪了",而不是一句"已导出" */
|
||||
export class IcsSaveResult {
|
||||
ok: boolean = false;
|
||||
/** 落盘路径(用户可以在文件管理器里找到它) */
|
||||
path: string = '';
|
||||
/** 失败原因(用户取消不算失败,见 `saveIcs`) */
|
||||
message: string = '';
|
||||
}
|
||||
|
||||
export class IcsPickResult {
|
||||
ok: boolean = false;
|
||||
/** 文件内容;ok=false 时为空串 */
|
||||
text: string = '';
|
||||
message: string = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 让用户选一个 .ics 并读出文本。
|
||||
*
|
||||
* `DocumentViewPicker.select` 的 `maxSelectNumber` 必须是 1 —— 多选了之后
|
||||
* "把哪一份导进去"就成了一个我们没打算回答的问题(服务端也只收一份文本)。
|
||||
*
|
||||
* 取消(用户按了返回)**不是错误**:`select` 会正常返回空数组。
|
||||
* 把取消当成失败弹红字,是"用户什么也没做却被骂一句"。
|
||||
*/
|
||||
export async function pickIcsText(ctx: common.Context): Promise<IcsPickResult> {
|
||||
const out = new IcsPickResult();
|
||||
try {
|
||||
const options = new picker.DocumentSelectOptions();
|
||||
options.maxSelectNumber = 1;
|
||||
/*
|
||||
* 只列 .ics。`fileSuffixFilters` 是"给用户看的过滤器",
|
||||
* 不是安全边界 —— 真正的校验在服务端解析时(格式不对它会回 {error})。
|
||||
*/
|
||||
options.fileSuffixFilters = ['.ics'];
|
||||
const docPicker = new picker.DocumentViewPicker(ctx);
|
||||
const uris: string[] = await docPicker.select(options);
|
||||
if (uris === undefined || uris.length === 0) {
|
||||
// 用户取消:ok=false 但 message 为空 —— 调用侧据此**不报错**
|
||||
return out;
|
||||
}
|
||||
const uri: string = uris[0];
|
||||
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
|
||||
try {
|
||||
const stat = fileIo.statSync(file.fd);
|
||||
const buf = new ArrayBuffer(stat.size);
|
||||
fileIo.readSync(file.fd, buf);
|
||||
const decoder = new util.TextDecoder('utf-8');
|
||||
out.text = decoder.decodeToString(new Uint8Array(buf));
|
||||
out.ok = out.text.length > 0;
|
||||
if (!out.ok) {
|
||||
out.message = '文件是空的';
|
||||
}
|
||||
} finally {
|
||||
fileIo.closeSync(file);
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
const be = e as BusinessError;
|
||||
hilog.error(0x0001, 'IcsFile', 'pick failed: %{public}s', JSON.stringify(be));
|
||||
out.message = be.message !== undefined && be.message.length > 0 ? be.message : '无法读取文件';
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让用户选保存位置并写入 .ics 文本。
|
||||
*
|
||||
* `DocumentViewPicker.save` 返回的是**新文件的 uri**;写它之前必须先 `create`
|
||||
* (`save` 在某些版本上只返回 uri、不落文件),这是这个 API 的实际形状。
|
||||
*
|
||||
* 取消同样**不是错误**(用户按返回 ⇒ 返回空数组)。
|
||||
*/
|
||||
export async function saveIcsText(ctx: common.Context, text: string, filename: string): Promise<IcsSaveResult> {
|
||||
const out = new IcsSaveResult();
|
||||
try {
|
||||
const options = new picker.DocumentSaveOptions();
|
||||
options.newFileNames = [filename];
|
||||
const docPicker = new picker.DocumentViewPicker(ctx);
|
||||
const uris: string[] = await docPicker.save(options);
|
||||
if (uris === undefined || uris.length === 0) {
|
||||
return out; // 用户取消
|
||||
}
|
||||
const uri: string = uris[0];
|
||||
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
|
||||
try {
|
||||
fileIo.writeSync(file.fd, text);
|
||||
out.ok = true;
|
||||
out.path = uri;
|
||||
} finally {
|
||||
fileIo.closeSync(file);
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
const be = e as BusinessError;
|
||||
hilog.error(0x0001, 'IcsFile', 'save failed: %{public}s', JSON.stringify(be));
|
||||
out.message = be.message !== undefined && be.message.length > 0 ? be.message : '无法写入文件';
|
||||
return out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user