/* * AgentMail 鸿蒙客户端 — .ics 文件的选/读/写 * * 为什么单独一个文件:这三件事都带 `@kit.CoreFileKit` 依赖,而**纯逻辑必须没有 SDK 依赖** * (`model/*.ts` 要让 Node 判据直接 import)。文件选择器天然是设备相关的,所以它 * 单独待在这里,不污染任何可以被离线判据执行的东西。 * * 与 WebUI 的分工差异:WebUI 在浏览器里,导出是 `Blob` + ``、 * 导入是 ``,两条都由浏览器实现。鸿蒙没有"下载目录"这个概念, * 必须显式走系统文件选择器 —— 这不是"多此一举",是这个平台上唯一 * 能让用户拿到文件 / 指定文件的路。 */ 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 { 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 { 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; } } /** * 把**二进制**内容存到用户选定的位置(附件下载用)。 * * ★★ 2026-09-20 加。与 `saveIcsText` 是同一套流程(系统文件选择器 → * 用户选路径 → 写文件 → 报回结果),只有**写入的数据类型**不同 * (`ArrayBuffer` vs `string`)。所以没有另起一个文件、也没有把 * `saveIcsText` 改成泛型:两者都是"选择器 + 写 + 结果对象"的短流程, * 合并成一个带 `any` 参数的函数反而丢掉了类型(本仓禁 `any`)。 * * ★ 为什么不让调用点自己 `fileIo` 一遍:`DocumentViewPicker` 的 * `save()` 会**在取消时返回空数组**(不是抛异常)—— 这条容易漏, * 漏了就会把"用户点了取消"报成"写入失败"。放在这里只用守一次。 */ export async function saveBinaryFile( ctx: common.Context, data: ArrayBuffer, filename: string ): Promise { 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; // 用户取消:`ok=false` 且**没有** message(不是失败) } const uri: string = uris[0]; const file = fileIo.openSync(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); try { fileIo.writeSync(file.fd, data); out.ok = true; out.path = uri; } finally { fileIo.closeSync(file); } return out; } catch (e) { const be = e as BusinessError; hilog.error(0x0001, 'IcsFile', 'saveBinary failed: %{public}s', JSON.stringify(be)); out.message = be.message !== undefined && be.message.length > 0 ? be.message : '无法写入文件'; return out; } }