diff --git a/client/harmony/entry/src/main/ets/api/ApiClient.ets b/client/harmony/entry/src/main/ets/api/ApiClient.ets index 8811aed..382cd47 100644 --- a/client/harmony/entry/src/main/ets/api/ApiClient.ets +++ b/client/harmony/entry/src/main/ets/api/ApiClient.ets @@ -240,6 +240,95 @@ export class ApiClient { return response.result as ArrayBuffer; } + /** + * 取**纯文本**(`.ics` 导出)。 + * + * 为什么不能复用 `request`:它无条件 `JSON.parse(response.result)` —— + * iCalendar 不是 JSON,拿它取会当场炸(与 `getBytes` 同一个理由, + * 那里是取壁纸二进制)。 + * + * 与 `getBytes` 的区别只有 `expectDataType`:这里是 `STRING`,壁纸是 `ARRAY_BUFFER`。 + * 非要合并成一个方法的话就得在调用处传枚举,而这个区别是**返回类型**级别的事, + * 两个方法更清楚。 + */ + async getText(path: string, query?: string): Promise { + const url: string = this.apiBase + path + (query !== undefined && query.length > 0 ? '?' + query : ''); + const httpRequest: http.HttpRequest = this.httpRequest !== null ? this.httpRequest : http.createHttp(); + if (this.httpRequest === null) { + this.httpRequest = httpRequest; + } + const header: Record = {}; + if (this.token.length > 0) { + header['Authorization'] = 'Bearer ' + this.token; + } + hilog.info(DOMAIN, TAG, '→ GET(text) %{public}s', url); + const response = await httpRequest.request(url, { + method: http.RequestMethod.GET, + header: header, + expectDataType: http.HttpDataType.STRING, + connectTimeout: 15000, + readTimeout: 30000 + }); + const code: number = response.responseCode; + const rawText: string = response.result as string; + if (code < 200 || code >= 300) { + throw new ApiError(code, describeFailure(code, 0, '', url).message); + } + return rawText; + } + + /** + * 发**纯文本** body(`.ics` 导入)。 + * + * 服务端 `ImportCalendarICS` 两种形态都收(multipart 与 raw `text/calendar`); + * 鸿蒙走 **raw text/calendar**:`http.request` 的 `extraData` 直接传字符串、 + * header 里写 Content-Type 即可,不需要为一份文本走 multipart 的额外封装。 + * (multipart 是浏览器 `` 的天然形态,而这里文本已经在手上。) + * + * ★ Content-Type 必须显式写 `text/calendar`:`request` 那条路径写死了 JSON, + * 而服务端是**按 Content-Type 分流**的(`strings.HasPrefix(ct, "multipart/")` + * 否则当 raw text)—— 写成 `application/json` 会让它走 raw 分支但语义不对。 + */ + async postText(path: string, text: string, contentType: string): Promise { + const url: string = this.apiBase + path; + const httpRequest: http.HttpRequest = this.httpRequest !== null ? this.httpRequest : http.createHttp(); + if (this.httpRequest === null) { + this.httpRequest = httpRequest; + } + const header: Record = {}; + header['Content-Type'] = contentType; + if (this.token.length > 0) { + header['Authorization'] = 'Bearer ' + this.token; + } + hilog.info(DOMAIN, TAG, '→ POST(text) %{public}s (%{public}d bytes)', url, text.length); + const response = await httpRequest.request(url, { + method: http.RequestMethod.POST, + header: header, + extraData: text, + connectTimeout: 15000, + readTimeout: 30000 + }); + const code: number = response.responseCode; + const rawText: string = response.result as string; + if (code >= 200 && code < 300) { + if (rawText.length === 0) { + const empty = new EmptyResult(); + return empty as T; + } + return JSON.parse(rawText) as T; + } + let serverMessage: string = ''; + try { + const parsed = JSON.parse(rawText) as Record; + if (parsed['error'] !== undefined) { + serverMessage = parsed['error']; + } + } catch (e) { + serverMessage = rawText.length > 0 && rawText.length <= 200 ? rawText : ''; + } + throw new ApiError(code, describeFailure(code, 0, serverMessage, url).message); + } + /** GET 便捷 */ async get(path: string, query?: string): Promise { const opts = new RequestOptions(); diff --git a/client/harmony/entry/src/main/ets/api/CalendarApi.ets b/client/harmony/entry/src/main/ets/api/CalendarApi.ets index fb00ee6..253a98f 100644 --- a/client/harmony/entry/src/main/ets/api/CalendarApi.ets +++ b/client/harmony/entry/src/main/ets/api/CalendarApi.ets @@ -9,7 +9,7 @@ * 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。 */ import { ApiClient } from './ApiClient'; -import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse } from '../model/Models'; +import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse, CalendarImportResponse } from '../model/Models'; export class CalendarApi { private client: ApiClient; @@ -65,4 +65,29 @@ export class CalendarApi { const query: string = 'from=' + encodeURIComponent(fromIso) + '&to=' + encodeURIComponent(toIso); return this.client.get('/calendar/lunar', query); } + + /** + * 导出 .ics 文本(`GET /calendar/export.ics`)。 + * + * ★ 区间要用**屏幕上正在看的那段**,不是写死 ±1 年 —— + * 服务端注释里写着同一条理由:「写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件」。 + * 入参是 RFC3339 时间戳(服务端 `time.Parse(RFC3339)`),**不是**日期键 —— + * 与 `listLunar` 的日期键口径不同,因为服务端这两个端点收的形状本来就不同。 + */ + async exportIcs(fromTimestamp: string, toTimestamp: string): Promise { + 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 { + return this.client.postText('/calendar/import.ics', icsText, 'text/calendar'); + } } diff --git a/client/harmony/entry/src/main/ets/common/IcsFile.ets b/client/harmony/entry/src/main/ets/common/IcsFile.ets new file mode 100644 index 0000000..8a87f3a --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/IcsFile.ets @@ -0,0 +1,122 @@ +/* + * 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; + } +} diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index a52ee7b..1a451f1 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -459,3 +459,15 @@ export class LunarLabels { export class LunarRangeResponse { lunar: Record = {}; } + +/** + * `POST /calendar/import.ics` 的响应。 + * + * ★ 两个数都要显示给用户:只说"导入成功"会把"20 条里跳过了 18 条"读成一切正常, + * 而"跳过"正是用户需要知道的那部分(重复 / 缺 DTSTART)。 + * 服务端返回的就是这两个计数(`ImportCalendarICS`)。 + */ +export class CalendarImportResponse { + imported: number = 0; + skipped: number = 0; +} diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets index e377097..3345564 100644 --- a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets +++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets @@ -5,7 +5,7 @@ * * 做了:月网格(可翻月、可回"今天")、点某一天看当天日程、事件点标记、 * 今天/选中两态、加载失败**说出来**。 - * 没做:新建/编辑/删除事件(写侧)、农历重复、.ics 导入导出、左右滑动翻页(P6 第 3 步)。 + * 没做:农历重复事件、左右滑动翻页(P6 第 3 步)。 * 滑动那条还牵着 `docs/DEBTS.json` 的 `gesture-semantics`("鸿蒙侧出现滑动手势代码时立即建判据") * —— 没写手势就不该先建那条假判据,所以这一版用按钮翻月。 * @@ -49,6 +49,7 @@ import { } from '../model/Calendar'; import { LIST_FADE_LENGTH } from '../model/NavItems'; import { LengthMetrics } from '@kit.ArkUI'; +import { pickIcsText, saveIcsText } from '../common/IcsFile'; /** 一格的高度(vp)。7 列等分 + 固定高度才画得成整齐的网格 */ const CELL_HEIGHT: number = 52; @@ -143,6 +144,8 @@ export struct CalendarPage { * 只有要改的人才点开 —— 点一下展开,再点一下收起。 */ @State pickersOpen: boolean = false; + /** .ics 导入/导出在飞:两个动作都碰文件与网络,防连点 */ + @State icsBusy: boolean = false; private api: CalendarApi | null = null; private offsetMinutes: number = 0; @@ -371,6 +374,99 @@ export struct CalendarPage { this.loadEvents(); } + /** + * 导出 .ics(对齐 WebUI `CalendarView` 的导出图标)。 + * + * ★ 区间用**屏幕上正在看的那段**(`rangeFrom/rangeTo`),不是写死 ±1 年 —— + * 服务端注释里写着同一条理由:「写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件」。 + * + * 三条结果必须分开说:导出成功(给出落盘路径)/ 用户取消(**什么都不说**)/ + * 真失败(说出原因)。把"取消"弹成"导出失败"是"用户什么也没做却被骂一句"。 + */ + private async exportIcs(): Promise { + const a: CalendarApi | null = this.api; + if (a === null || this.icsBusy) { + return; + } + this.icsBusy = true; + const prompt = this.getUIContext().getPromptAction(); + try { + const text: string = await a.exportIcs(this.rangeFrom(), this.rangeTo()); + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + /* + * 文件名带上"导的是哪一天那一屏"(对齐 WebUI `agentmail-${dayKey(anchor)}.ics`)。 + * 固定名 `agentmail-calendar.ics` 的问题是连导两次就分不清哪份是哪份 —— + * 而导出这个动作天然会被重复做(看一个月导一份)。 + * 这里用 `selectedIso`:它就是这个视图的锚点(WebUI 的 `anchor` 同义)。 + */ + const saved = await saveIcsText(ctx, text, 'agentmail-' + this.selectedIso + '.ics'); + if (saved.ok) { + prompt.showToast({ message: '已导出:' + saved.path, duration: 6000 }); + } else if (saved.message.length > 0) { + prompt.showToast({ message: '导出失败:' + saved.message, duration: 6000 }); + } + // saved.message 为空 = 用户取消 ⇒ 刻意什么都不说 + } catch (e) { + const ae = e as ApiError; + prompt.showToast({ + message: ae.message.length > 0 ? ae.message : '导出失败', + duration: 6000 + }); + } finally { + this.icsBusy = false; + } + } + + /** + * 导入 .ics(对齐 WebUI 的导入图标)。 + * + * ★ `imported` 与 `skipped` **两个数都要说**:只说"导入成功"会把 + * "20 条里跳过了 18 条"读成一切正常,而跳过的原因(重复 / 缺 DTSTART) + * 正是用户需要知道的那部分。服务端返回的就是这两个计数。 + * + * 导入成功后**重拉当前区间** —— 不重拉的话用户看不到刚导进来的东西, + * 会以为导入失败(而它其实成功了)。 + */ + private async importIcs(): Promise { + const a: CalendarApi | null = this.api; + if (a === null || this.icsBusy) { + return; + } + this.icsBusy = true; + const prompt = this.getUIContext().getPromptAction(); + try { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + const picked = await pickIcsText(ctx); + if (!picked.ok) { + // 取消(message 空)⇒ 不说话;读文件失败 ⇒ 说原因 + if (picked.message.length > 0) { + prompt.showToast({ message: '导入失败:' + picked.message, duration: 6000 }); + } + return; + } + const resp = await a.importIcs(picked.text); + prompt.showToast({ + message: `已导入 ${resp.imported} 条,跳过 ${resp.skipped} 条`, + duration: 6000 + }); + await this.loadEvents(); + } catch (e) { + const ae = e as ApiError; + prompt.showToast({ + message: ae.message.length > 0 ? ae.message : '导入失败', + duration: 6000 + }); + } finally { + this.icsBusy = false; + } + } + /** * 要显示哪些格子 —— **由视图档决定**(月:整月网格;周:一行 7 天;日:单天)。 * @@ -963,8 +1059,9 @@ export struct CalendarPage { * * 对齐 WebUI `CalendarView.tsx:293-370` 那条工具条的分组与顺序: * ‹ › 今天 | 标题 | 月/周/日 | 新建 - * WebUI 还有导入/导出 .ics 两个图标,鸿蒙这边暂未做(那两个要文件选择器 + .ics 解析, - * 与"月份/星期"这类算术不同,属于一个独立的工作量)。 + * WebUI 工具条还有导入/导出 .ics 两个入口,鸿蒙这边 2026-09-18 补上 + * (走系统文件选择器 —— 鸿蒙没有"下载目录",`DocumentViewPicker` 是 + * 这个平台上唯一能让用户拿到/指定文件的路)。 * 这里是**窄屏**,四段挤一行会溢出 —— 拆成两行: * 上行 = 翻页 + 标题;下行 = 今天 + 视图档 + 新建。 */ @@ -1013,6 +1110,23 @@ export struct CalendarPage { .margin({ left: 4 }) .onClick(() => this.setScale(s)) }, (s: CalendarScale) => `scale${s}`) + /* + * .ics 导入 / 导出(对齐 WebUI 工具条里那两个图标)。 + * + * 用文字而不是图标:窄屏下这两个动作**低频且后果不可见** + * (导出了什么、导进来什么,光看按钮看不出来),文字比一个需要猜的 + * 小图标省一次试错。WebUI 有 title 提示可悬停,手指没有悬停。 + */ + Text('导入') + .fontSize(Theme.fontSmall) + .fontColor(this.icsBusy ? Theme.textSubtle : Theme.accent) + .margin({ left: 8 }) + .onClick(() => { this.importIcs(); }) + Text('导出') + .fontSize(Theme.fontSmall) + .fontColor(this.icsBusy ? Theme.textSubtle : Theme.accent) + .margin({ left: 8 }) + .onClick(() => { this.exportIcs(); }) Button('+') .fontSize(Theme.fontBody) .backgroundColor(Theme.accent)