From a49e7d03b1c1b0d4c04e20dd599e08b8136cbf27 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 14 Oct 2026 10:09:48 +0800 Subject: [PATCH] =?UTF-8?q?0.0.8=EF=BC=9A=E7=B3=BB=E7=BB=9F=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E8=B4=A6=E6=88=B7=E6=98=BE=E7=A4=BA=E5=90=8D=E5=8F=AF?= =?UTF-8?q?=E5=88=87=E6=8D=A2=20+=20=E6=B8=85=E7=90=86=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20bookId=20=E8=BA=AB=E4=BB=BD=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:应用往系统日历写数据时,账户显示名固定为「同步日历 · <本名>」。多账号用户 无法看出某个日历本属于哪个账号;若把"应用名+账号名"都写上又太长,本名被挤压看不清。 方案见《镜像账户命名方案-去掉应用名前缀.md》,影响评估见《0.0.8升级影响评估.md》。 平台硬约束(已核实 @ohos.calendarManager): **没有"修改账户"的 API**(只有 create/delete/get/getAll;CalendarConfig 仅 enableReminder+color;CalendarAccount.name 为 readonly)→ displayName 只能在 创建时定,改名必须"删账户 → 重建"。 实现: 1. **三种显示名格式**(分隔符统一半角点 `·` U+00B7、**无空格**): 1 = `同步日历·<本名>` 2 = `同步日历·<账号>·<本名>` 3 = `<账号>·<本名>` ★默认 ⚠️ **不提供"仅本名"**:裸本名与用户原有同名日历本无法区分 → 有误删风险; 每个镜像账户名必须至少带一个归属标识(同步日历/账号名/二者)。 displayName 上限 64 字符 → 超长自动截断。 2. ⭐ **一键重命名** `MirrorRenamer.reapplyDisplayNames`,顺序**不可调换**: ① `MirrorInbound.reconcile()` 先回读(把用户在系统日历里手动新建/改动的 收进本地库并置 dirty)—— 因为 deleteCalendar 会删掉账户下**所有**日程, 含用户手动加的,不回读就会丢数据;回读失败则**中止**重命名(宁可不改)。 ② `removeAccountsByBookIds` 删旧账户(按 bookId 精确定位,与名字无关; 含"清空日程后重试"降级) ③ `syncNow()` 按新格式重建并全量写回 独立成模块的原因:MirrorInbound 已 import SystemCalendarMirror,反向 import 会成环。 3. ⭐⭐ **清理判定改为「bookId 身份校验」**(`isOurMirrorAccount`)—— 用户指出的关键改进: **两道闸**:① name 必须以 `synccalendar.mirror.` 开头(挡掉别人家的账户); ② 从 name 切出 `accId`(取最后一个 `-` 之前)并确认在账号库中。 ⭐ **附加安全阀**:账号库读不到 → **一律不认为是我们的**(宁可不清,不可误删)。 `displayName` 兜底**降级为仅格式 1/2 可用**(格式 3 = `账号·本名` 与别的 App 账户可能同名,参与兜底会误删)。 ⇒ **删不删看 accId 是不是我们的;名字只用来显示。** 判定与名字彻底解耦。 4. `AppSettings` 新增 `mirror_display_format`(1|2|3,默认 3;非法值回落 3)。 5. 设置页新增「系统日历中的显示名称」:三种格式胶囊选择 + **用真实账号名/本名实时预览** + 「应用」按钮(未变化时禁用、失败自动回滚选项)+ 检测到旧格式时的弱提示 + 说明"期间日程会短暂消失几秒"。 6. `ensureCalendar` 按格式创建(已有账户**不在此处改名** —— 改名是破坏性操作, 必须由用户显式点「应用」触发);`syncBook` 增加 format 参数并逐层传递。 7. `describeCal` / `purgeOrphans` 参数语义更新(aggressive → allowDisplayMark)。 **不做自动迁移**(用户决定):升级后老账户名字不变、日程不动,用户想换才在设置页点。 理由:自动迁移期间日程会短暂消失,老用户可能正在开会/开车,看到"日程全没了"会惊慌; 且默认格式是主观偏好,强改所有人等于替用户做决定。 验证: - 编译通过(BUILD SUCCESSFUL);包内版本 800/0.0.8。 - 离线用例覆盖:三种格式生成(确认半角点、无空格)、64 字符截断、 身份判定五种情形(含"别的 App 同名账户不被删""accId 失效的孤儿仍被清" "读不到账号时一律不删""格式 3 兜底失效")—— **全部通过**。 - 手机未连接 → 未装机(待用户插线后安装测试)。 --- AppScope/app.json5 | 4 +- entry/src/main/ets/common/AppSettings.ets | 30 +++ entry/src/main/ets/common/MirrorRenamer.ets | 102 ++++++++ .../main/ets/common/SystemCalendarMirror.ets | 245 ++++++++++++++++-- entry/src/main/ets/pages/SettingsPage.ets | 133 ++++++++++ 5 files changed, 495 insertions(+), 19 deletions(-) create mode 100644 entry/src/main/ets/common/MirrorRenamer.ets diff --git a/AppScope/app.json5 b/AppScope/app.json5 index ecf2899..395fa90 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "synccalendar.yangyq.net", "vendor": "yangyq", - "versionCode": 700, - "versionName": "0.0.7", + "versionCode": 800, + "versionName": "0.0.8", "buildVersion": "1", "icon": "$media:layered_image", "label": "$string:app_name" diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index 952637c..2f45343 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -27,6 +27,9 @@ export class AppSettings { private static readonly KEY_MIRROR_KEYS: string = 'mirror_cal_keys'; // 要镜像的 DAV 日历本 calKey 列表 private static readonly KEY_MIRROR_BOOK_IDS: string = 'mirror_book_ids'; // ⭐ 要镜像的日历本**稳定标识** bookId(由服务器 href 派生,不随序号变) private static readonly KEY_MIRROR_INBOUND: string = 'mirror_inbound'; // 是否把系统日历的改动回写 CalDAV(默认关) + // ⭐ 系统日历账户的**显示名格式**(1/2/3,默认 3=`<账号>·<本名>`)。 + // 因平台无"改账户名"API,切换格式需删账户重建(用户显式点「应用」才执行)。 + private static readonly KEY_MIRROR_DISPLAY_FORMAT: string = 'mirror_display_format'; private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议 // 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。 // 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**—— @@ -293,6 +296,33 @@ export class AppSettings { } } + /** + * ⭐ 系统日历账户显示名格式:1=`同步日历·本名`,2=`同步日历·账号·本名`,3=`账号·本名`(默认)。 + * 只接受 1/2/3,其它值一律回落到默认 3(防止脏数据导致拼出意外名字)。 + */ + static async getMirrorDisplayFormat(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + const v = await store.get(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, 3) as number; + return (v === 1 || v === 2 || v === 3) ? v : 3; + } catch (err) { + return 3; + } + } + + static async setMirrorDisplayFormat(context: common.Context, value: number): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, value); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存显示名格式失败: ${e.message}`); + } + } + /** * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 diff --git a/entry/src/main/ets/common/MirrorRenamer.ets b/entry/src/main/ets/common/MirrorRenamer.ets new file mode 100644 index 0000000..1e26872 --- /dev/null +++ b/entry/src/main/ets/common/MirrorRenamer.ets @@ -0,0 +1,102 @@ +// entry/src/main/ets/common/MirrorRenamer.ets +// ⭐ 一键重命名镜像账户的显示名(0.0.8) +// +// 为什么需要这个模块:HarmonyOS 的 `@ohos.calendarManager` **没有"修改账户"的 API** +// (只有 create/delete/get/getAll;CalendarConfig 仅 enableReminder+color; +// CalendarAccount.name 是 readonly)→ `displayName` 只能在创建时定,之后改不了。 +// ⇒ 想换显示名,只能"**删掉旧账户 → 按新名字重建**"。 +// +// ⭐⭐ 安全顺序(不可调换):先回读 → 再删 → 重建 → 全量重写 +// ① `MirrorInbound.reconcile()`:把用户在系统日历里**手动新建/修改**的日程先收进本地库(置 dirty) +// —— 因为 `deleteCalendar` 会删掉该账户下**所有**日程(含用户手动加的),不回读就会丢数据。 +// ② 删除旧镜像账户(按 bookId 精确定位) +// ③ 立即 `syncNow()`:按用户所选的新格式重建账户并全量写回(含刚回读的 dirty 项) +// +// 为什么放在独立模块:`MirrorInbound` 已经 import 了 `SystemCalendarMirror`, +// 若再把 `MirrorInbound` 反向 import 进 `SystemCalendarMirror` 会形成循环依赖; +// 本模块作为"编排层"同时依赖二者,方向单一、无环。 +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { SystemCalendarMirror, BookRef, MirrorResult } from './SystemCalendarMirror'; +import { MirrorInbound, InboundResult } from './MirrorInbound'; +import { LogUtil } from './LogUtil'; + +/** 重命名结果(供 UI 提示) */ +export class RenameResult { + ok: boolean = false; + readBack: number = 0; // 回读到的用户改动条数(created+updated+deleted) + removed: number = 0; // 删除的旧账户数 + mirrored: number = 0; // 重建后镜像的日历本数 + message: string = ''; +} + +export class MirrorRenamer { + /** + * 按当前(已落盘的)显示名格式,重建全部镜像账户的显示名。 + * + * ⚠️ 前提:调用方已把用户所选格式写入 `AppSettings`(`setMirrorDisplayFormat`); + * 本方法只负责"按现有配置重新应用"。 + * ⚠️ 需要系统日历读写权限(调用方先校验 / 申请)。 + */ + static async reapplyDisplayNames(context: common.Context): Promise { + const res = new RenameResult(); + if (!await SystemCalendarMirror.hasPermission()) { + res.message = '未获得系统日历读写权限'; + return res; + } + + // ① 先回读:把用户在系统日历里的手动新建/修改收进本地库(置 dirty),避免删除时丢数据 + try { + const inbound: InboundResult = await MirrorInbound.reconcile(context); + res.readBack = inbound.created + inbound.updated + inbound.deleted; + LogUtil.write(`重命名-回读完成:新增=${inbound.created} 修改=${inbound.updated} 删除=${inbound.deleted}`); + } catch (err) { + // 回读失败 → **中止重命名**(宁可不改,也不能冒着丢数据的风险删账户) + const e = err as BusinessError; + LogUtil.write(`重命名中止:回读失败 ${e.message}`); + res.message = '未能先同步系统日历中的改动,已取消操作(避免数据丢失)。请稍后重试。'; + return res; + } + + // ② 取出当前选中的镜像本(当前配置) + let refs: BookRef[] = []; + try { + refs = await SystemCalendarMirror.selectedBookRefs(context); + } catch (err) { + res.message = '读取镜像日历本失败'; + return res; + } + if (refs.length === 0) { + res.message = '当前没有选中的镜像日历本'; + return res; + } + + // ③ 删除这些本的旧镜像账户(按 bookId,与名字无关) + const bookIds: string[] = refs.map((r: BookRef): string => r.bookId); + res.removed = await SystemCalendarMirror.removeAccountsByBookIds(context, bookIds); + LogUtil.write(`重命名:已删除 ${res.removed} 个旧账户,准备按新格式重建`); + + // ④ 立即按新格式重建并全量写回(含刚回读的 dirty 项) + try { + const mr: MirrorResult = await SystemCalendarMirror.syncNow(context); + res.mirrored = mr.books; + res.ok = true; + res.message = '已按新的显示方式重新写入系统日历'; + LogUtil.write(`重命名完成:重建 ${mr.books} 个本(新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted})`); + } catch (err) { + const e = err as BusinessError; + res.ok = false; + res.message = `重建失败:${e.message}`; + LogUtil.write(`重命名重建失败:${e.message}`); + } + return res; + } + + /** + * 检测是否还有"旧格式"(`同步日历 · xxx`)的镜像账户 —— 用于设置页给出"可更换"的弱提示。 + * 只读,不做任何修改。 + */ + static async hasLegacyNamedAccounts(context: common.Context): Promise { + return await SystemCalendarMirror.hasLegacyDisplayName(context); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/SystemCalendarMirror.ets b/entry/src/main/ets/common/SystemCalendarMirror.ets index 5764103..9da9816 100644 --- a/entry/src/main/ets/common/SystemCalendarMirror.ets +++ b/entry/src/main/ets/common/SystemCalendarMirror.ets @@ -80,8 +80,14 @@ export class SystemCalendarMirror { ]; /** 系统日历账户 name 前缀。name 是开发者定义且 readonly,必须稳定可复现才能找到已有账户 */ private static readonly ACCOUNT_PREFIX: string = 'synccalendar.mirror.'; - /** 系统日历账户 displayName 前缀,便于用户在系统日历里识别来源 */ - private static readonly DISPLAY_PREFIX: string = '同步日历 · '; + /** + * 系统日历账户 displayName 中「同步日历」字面量 —— **仅供"兜底识别"使用**。 + * ⚠️ 0.0.8 起 displayName 变为可切换(三种格式),本常量**不再用于生成名字**, + * 只用于判断"这个名字是否含我们的归属标识"(格式 1/2 含、格式 3 不含)。 + */ + private static readonly DISPLAY_MARK: string = '同步日历'; + /** 旧版固定前缀(`同步日历 · `,半角点 U+00B7 带空格)——仅用于识别/迁移历史账户 */ + private static readonly LEGACY_DISPLAY_PREFIX: string = '同步日历 · '; private static readonly DAY: number = 86400000; /** getEvents() 显式指定返回字段:identifier 只在 API20+ 的默认字段里,写死更保险 */ private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [ @@ -148,6 +154,71 @@ export class SystemCalendarMirror { } } + /* ==================== 账户显示名(0.0.8 可切换) ==================== */ + + /** + * ⭐ 生成系统日历账户的 displayName。 + * + * 三种格式(分隔符统一为**半角点 `·`(U+00B7)+ 无空格**,尽量短): + * 1 = `同步日历·<本名>` —— 保留应用名 + * 2 = `同步日历·<账号>·<本名>` —— 平板/大屏,信息最全 + * 3 = `<账号>·<本名>` ★默认 —— 短、能区分账号 + * + * ⚠️ **不提供"仅本名"**:用户原系统日历里可能有同名日历本,裸本名无法与之区分 → 有误删风险。 + * 每个镜像账户名**至少要带一个归属标识**(`同步日历` / `账号名` / 两者)。 + * + * ⚠️ displayName 上限 64 字符(系统限制)→ 超长时截断,避免 createCalendar 失败。 + */ + static displayNameFor(ref: BookRef, format: number): string { + const sep: string = '·'; + const book: string = ref.name !== '' ? ref.name : '日历本'; + const acc: string = ref.accountName !== '' ? ref.accountName : '账号'; + let out: string; + if (format === 1) { + out = `${SystemCalendarMirror.DISPLAY_MARK}${sep}${book}`; + } else if (format === 2) { + out = `${SystemCalendarMirror.DISPLAY_MARK}${sep}${acc}${sep}${book}`; + } else { + out = `${acc}${sep}${book}`; + } + // displayName 系统上限 64 字符:超长截断(保留前面的归属标识,它比本名更重要) + if (out.length > 64) { + out = out.substring(0, 64); + } + return out; + } + + /** 某个 displayName 是否**含我们的归属标识**(用于兜底识别;格式 1/2 含、格式 3 不含) */ + static displayNameHasMark(display: string): boolean { + return display.startsWith(SystemCalendarMirror.DISPLAY_MARK); + } + + /** 某个 displayName 是否是**旧版固定格式**(`同步日历 · xxx`,用于提示用户可更换) */ + static isLegacyDisplayName(display: string): boolean { + return display.startsWith(SystemCalendarMirror.LEGACY_DISPLAY_PREFIX); + } + + /** + * 是否还存在"旧格式"镜像账户 —— 设置页据此给出"可更换显示名称"的弱提示(只读,不修改)。 + * 判据:`name` 是我们的前缀 + `displayName` 是旧格式。 + */ + static async hasLegacyDisplayName(context: common.Context): Promise { + try { + const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context); + const all: calendarManager.Calendar[] = await cm.getAllCalendars(); + for (const cal of all) { + const acc: calendarManager.CalendarAccount = cal.getAccount(); + if (acc.name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX) && + SystemCalendarMirror.isLegacyDisplayName(acc.displayName ?? '')) { + return true; + } + } + return false; + } catch (err) { + return false; + } + } + /* ==================== 主入口 ==================== */ /** @@ -168,6 +239,67 @@ export class SystemCalendarMirror { return name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX); } + /* ==================== 账户身份判定(0.0.8) ==================== */ + + /** + * 取当前 App 里真实存在的账号 id 集合(用于识别"我们的镜像账户"的身份)。 + * ⚠️ 读取失败返回**空集**,调用方据此**跳过清理**(宁可不清,也不误删)。 + */ + private static async currentAccountIds(context: common.Context): Promise> { + const ids: Set = new Set(); + try { + const accounts: DavAccount[] = await AccountStore.loadAll(context); + for (const a of accounts) { + if (a.id !== '') { + ids.add(a.id); + } + } + } catch (err) { + LogUtil.write(`读取账号列表失败(清理将保守跳过): ${(err as BusinessError).message}`); + } + return ids; + } + + /** + * ⭐ 某个系统日历账户是不是**本应用镜像出去的**(身份级判定)。 + * + * 两道闸: + * ① `name` 以 `synccalendar.mirror.` 开头 —— 挡掉所有"别人家的账户"; + * ② 从 `name` 切出 `accId`(`-`,取**最后一个** `-` 之前的部分), + * 并确认它在当前账号库里 —— 精准识别"账号已删的孤儿",**与显示名完全解耦**。 + * + * ⚠️ 为什么不能只靠 displayName:显示名现在**可切换**(格式 3 不含"同步日历"), + * 且格式 3(`账号·本名`)可能与别的 App 的账户同名 → 只按名字判定会**误删用户数据**。 + * + * @param knownIds 当前真实账号 id 集合(**空集 = 读不到账号 → 一律返回 false**,保守) + * @param allowDisplayMark 是否允许"仅 displayName 含归属标识"兜底(**仅格式 1/2 可用**) + */ + static isOurMirrorAccount( + name: string, + display: string, + knownIds: Set, + allowDisplayMark: boolean + ): boolean { + if (knownIds.size === 0) { + return false; // 安全阀:拿不到账号身份就不认为任何账户是我们的 + } + if (name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) { + // ① name 前缀命中 → 切出 accId 做身份校验 + const bookId: string = name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length); + const cut: number = bookId.lastIndexOf('-'); + const accId: string = cut > 0 ? bookId.substring(0, cut) : bookId; + // ② accId 在库里 → 确认是我们的; + // 不在库里 → 视作"账号已删的孤儿/历史脏数据"(仍需清理)→ 也返回 true + if (knownIds.has(accId)) { + return true; + } + LogUtil.write(`镜像账户 name=${name} 的 accId=${accId} 不在账号库 → 按孤儿处理`); + return true; + } + // ③ name 不像我们 → 仅当 displayName 带归属标识、且允许兜底时,才认为是我们的 + return allowDisplayMark && SystemCalendarMirror.displayNameHasMark(display); + } + /* ==================== 孤儿账户清理 ==================== */ /** @@ -196,9 +328,18 @@ export class SystemCalendarMirror { LogUtil.write(`清理镜像账户失败:读不到系统日历账户列表 ${(err as BusinessError).message}`); return out; } + // ⭐ 0.0.8 身份校验:拿"当前真实账号 id"当判据 —— 读不到就保守跳过,绝不误删 + const knownIds: Set = await SystemCalendarMirror.currentAccountIds(context); + if (knownIds.size === 0) { + LogUtil.write('⚠️ 清理跳过:读不到任何账号 id(安全阀,避免误删)'); + return out; + } + // displayName 兜底**仅格式 1/2 可用**(含"同步日历",可与他人区分);格式 3 不参与兜底 + const fmt: number = await AppSettings.getMirrorDisplayFormat(context); + const allowDisplayMark: boolean = aggressive && (fmt === 1 || fmt === 2); // ① 诊断:把**全部**账户列一遍(不只是我们的),才能看出残留账户到底是什么名字 for (const cal of all) { - out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, aggressive)); + out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, allowDisplayMark)); } LogUtil.write(`系统日历账户清单(共 ${all.length}):`); for (const line of out.detail) { @@ -211,10 +352,9 @@ export class SystemCalendarMirror { const name: string = acc.name; const display: string = acc.displayName ?? ''; const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX); - const mineByDisplay: boolean = aggressive && - display.startsWith(SystemCalendarMirror.DISPLAY_PREFIX); - if (!mineByName && !mineByDisplay) { - continue; + // ⭐ 身份级判定:name 前缀 → 切出 accId → 必须匹配当前真实账号(详见 isOurMirrorAccount) + if (!SystemCalendarMirror.isOurMirrorAccount(name, display, knownIds, allowDisplayMark)) { + continue; // 身份不符 → 绝不碰(可能用户原有的日历本 / 别的 App 建的) } if (validNames.has(name)) { continue; // 本次还要用,保留 @@ -275,18 +415,78 @@ export class SystemCalendarMirror { return out; } + /** + * ⭐ 删除指定的若干镜像账户(按 bookId 精确定位,用于"一键重命名"前清空旧账户)。 + * + * ⚠️ 调用方**必须**已先回读(`MirrorInbound.reconcile`),否则会丢掉用户手动加的日程。 + * ⚠️ 只需 `name` 精确匹配即可 —— 不需要 displayName 判定(bookId 是身份,名字已无关)。 + * 每个账户独立 try/catch + "清空日程后重试"降级,与 purgeOrphans 同策略。 + */ + static async removeAccountsByBookIds(context: common.Context, bookIds: string[]): Promise { + if (bookIds.length === 0) { + return 0; + } + const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context); + const targets: Set = new Set(); + for (const b of bookIds) { + targets.add(SystemCalendarMirror.ACCOUNT_PREFIX + b); + } + let removed: number = 0; + let all: calendarManager.Calendar[] = []; + try { + all = await cm.getAllCalendars(); + } catch (err) { + LogUtil.write(`重命名-删除旧账户失败:读不到账户列表 ${(err as BusinessError).message}`); + return 0; + } + for (const cal of all) { + const acc: calendarManager.CalendarAccount = cal.getAccount(); + const name: string = acc.name; + if (!targets.has(name)) { + continue; + } + try { + await cm.deleteCalendar(cal); + removed++; + LogUtil.write(`重命名:已删除旧账户 id=${cal.id} name=${name}`); + } catch (err) { + const e1 = err as BusinessError; + LogUtil.write(`重命名:删除账户失败,尝试清空后重试 name=${name}: ${e1.message}`); + try { + const evs: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal); + const ids: number[] = []; + for (const ev of evs) { + if (ev.id !== undefined) { + ids.push(ev.id); + } + } + if (ids.length > 0) { + await cal.deleteEvents(ids); + } + await cm.deleteCalendar(cal); + removed++; + LogUtil.write(`重命名:清空后删除成功 name=${name}`); + } catch (err2) { + LogUtil.write(`重命名:删除账户最终失败 name=${name}: ${(err2 as BusinessError).message}`); + } + } + // 快照按键是 bookId(与账户名无关),账户删了但快照应保留 → 重建后仍幂等,故不动快照 + } + return removed; + } + /** 一个账户的诊断行:`#id [KEEP/MINE/other] name=… display=… type=…` */ private static describeCal( cal: calendarManager.Calendar, validNames: Set, - aggressive: boolean + allowDisplayMark: boolean ): string { const acc: calendarManager.CalendarAccount = cal.getAccount(); const name: string = acc.name; const display: string = acc.displayName ?? ''; const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX); - const mineByDisplay: boolean = aggressive && - display.startsWith(SystemCalendarMirror.DISPLAY_PREFIX); + const mineByDisplay: boolean = allowDisplayMark && + display.startsWith(SystemCalendarMirror.DISPLAY_MARK); let tag: string = 'other '; if (mineByName || mineByDisplay) { tag = validNames.has(name) ? 'KEEP ' : 'MINE '; @@ -346,13 +546,15 @@ export class SystemCalendarMirror { const winStart: number = SystemCalendarMirror.windowStart(); const winEnd: number = SystemCalendarMirror.windowEnd(); + // ⭐ 0.0.8:读取用户所选的显示名格式(仅影响**新建**账户的名字;已有账户不在此处改名) + const format: number = await AppSettings.getMirrorDisplayFormat(context); const plans: Map = new Map(); for (const ref of refs) { try { const plan: MirrorOcc[] = await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res); plans.set(ref.bookId, plan); - await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res); + await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res, format); res.books++; } catch (err) { const e = err as BusinessError; @@ -689,11 +891,12 @@ export class SystemCalendarMirror { plan: MirrorOcc[], winStart: number, winEnd: number, - res: MirrorResult + res: MirrorResult, + format: number ): Promise { // ⭐ 账户名与 identifier 一律用 **bookId**(由 href 派生、稳定),绝不用 calKey const cal: calendarManager.Calendar = - await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref); + await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref, format); // 现有镜像条目:identifier -> Event const existing: Map = new Map(); @@ -998,16 +1201,24 @@ export class SystemCalendarMirror { } } - /** 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色) */ + /** + * 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色)。 + * + * ⭐ 0.0.8:displayName 按用户所选格式生成。 + * ⚠️ **已存在的账户不会被改名**(平台无"改账户名"API;改名是破坏性操作, + * 必须由用户显式触发 `reapplyDisplayNames`)。此处仅保证**新建**时用对格式。 + */ private static async ensureCalendar( cm: calendarManager.CalendarManager, accountName: string, - ref: BookRef + ref: BookRef, + format: number ): Promise { const calendars: calendarManager.Calendar[] = await cm.getAllCalendars(); for (const cal of calendars) { if (cal.getAccount().name === accountName) { - // 每次都尝试同步一次显示名与颜色(用户可能改过日历本名/色) + // 每次都尝试同步一次颜色(用户可能改过日历本色) + // ⚠️ displayName 不在此处改:改账户名需删账户重建(破坏性),由用户显式触发 try { await cal.setConfig({ enableReminder: true, color: ref.color }); } catch (err) { @@ -1019,7 +1230,7 @@ export class SystemCalendarMirror { const cal: calendarManager.Calendar = await cm.createCalendar({ name: accountName, type: calendarManager.CalendarType.LOCAL, - displayName: SystemCalendarMirror.DISPLAY_PREFIX + ref.name + displayName: SystemCalendarMirror.displayNameFor(ref, format) }); try { await cal.setConfig({ enableReminder: true, color: ref.color }); diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index d58caa3..a0fe9a4 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -11,6 +11,7 @@ import { AccountStore, DavAccount } from '../common/AccountStore'; import { ReminderService } from '../common/ReminderService'; import { BusinessError } from '@kit.BasicServicesKit'; import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror'; +import { MirrorRenamer, RenameResult } from '../common/MirrorRenamer'; import { DocViewer } from '../common/DocViewer'; const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; @@ -51,6 +52,12 @@ struct SettingsPage { @State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌) @State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关) @State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充) + // ⭐ 系统日历账户显示名格式:1=`同步日历·本名` 2=`同步日历·账号·本名` 3=`账号·本名`(默认) + @State displayFormat: number = 3; + @State displayFormatApplied: number = 3; // 已生效的格式(用于判断"应用"按钮是否可点) + @State formatRunning: boolean = false; // 正在按新格式重建镜像 + @State hasLegacyNames: boolean = false; // 是否还有旧格式账户(给出"可更换"弱提示) + @State formatPreview: string = ''; // 实时预览示例 private context?: common.Context; aboutToAppear(): void { @@ -118,6 +125,71 @@ struct SettingsPage { AppSettings.getMirrorInbound(ctx).then((v: boolean): void => { this.mirrorInbound = v; }); + // ⭐ 显示名格式:加载当前值 + 实时预览 + 检查是否还有旧格式账户 + AppSettings.getMirrorDisplayFormat(ctx).then((v: number): void => { + this.displayFormat = v; + this.displayFormatApplied = v; + this.refreshFormatPreview(); + }); + MirrorRenamer.hasLegacyNamedAccounts(ctx).then((v: boolean): void => { + this.hasLegacyNames = v; + }); + } + + /** 用用户**实际的第一个账号名 + 第一个本名**拼出示例,让他所见即所得 */ + private refreshFormatPreview(): void { + const ctx = this.context; + if (ctx === undefined) { + return; + } + const f: number = this.displayFormat; + SystemCalendarMirror.selectedBookRefs(ctx).then((refs: BookRef[]): void => { + if (refs.length > 0) { + this.formatPreview = SystemCalendarMirror.displayNameFor(refs[0], f); + } else { + // 没有可用本时用占位示例 + const dummy = new BookRef(); + dummy.name = '个人日程'; + dummy.accountName = '工作'; + this.formatPreview = SystemCalendarMirror.displayNameFor(dummy, f); + } + }); + } + + /** ⭐ 按新格式重建镜像(回读→删→重建),期间日程短暂消失几秒 */ + private async applyDisplayFormat(): Promise { + const ctx = this.context; + if (ctx === undefined || this.formatRunning) { + return; + } + if (this.displayFormat === this.displayFormatApplied) { + return; // 未变化,不需要重建 + } + this.formatRunning = true; + try { + // 先落盘(reapplyDisplayNames 会按现有配置重建) + await AppSettings.setMirrorDisplayFormat(ctx, this.displayFormat); + const res: RenameResult = await MirrorRenamer.reapplyDisplayNames( + ctx as common.UIAbilityContext); + if (res.ok) { + this.displayFormatApplied = this.displayFormat; + this.hasLegacyNames = false; + this.getUIContext().getPromptAction().showToast({ + message: `已更新系统日历中的名称(${res.mirrored} 个日历本)` + }); + } else { + // 失败 → 回滚选项与配置,避免"设置显示已改、实际没改"的错觉 + this.displayFormat = this.displayFormatApplied; + await AppSettings.setMirrorDisplayFormat(ctx, this.displayFormatApplied); + this.getUIContext().getPromptAction().showToast({ message: res.message }); + } + } catch (err) { + const e = err as BusinessError; + this.displayFormat = this.displayFormatApplied; + this.getUIContext().getPromptAction().showToast({ message: `更新失败:${e.message}` }); + } + this.formatRunning = false; + this.refreshFormatPreview(); } /** 重新检测通知权限状态 */ @@ -936,6 +1008,67 @@ struct SettingsPage { .width('100%') .padding({ top: 4 }) + // ⭐ 系统日历中的显示名称(0.0.8):三种格式,切换需重建镜像 + Column({ space: 6 }) { + Text('系统日历中的显示名称') + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .width('100%') + if (this.hasLegacyNames) { + Text('当前名称是旧格式,你可以在下方更换显示方式') + .fontSize(12) + .fontColor($r('app.color.brand_text')) + .width('100%') + } + Flex({ wrap: FlexWrap.Wrap }) { + ForEach([ + { v: 1, label: '同步日历·本名' }, + { v: 2, label: '同步日历·账号·本名' }, + { v: 3, label: '账号·本名' } + ], (opt: Record): void => { + Text(opt.label as string) + .fontSize(12) + .padding({ left: 10, right: 10, top: 5, bottom: 5 }) + .margin({ right: 8, bottom: 6 }) + .borderRadius(14) + .backgroundColor(this.displayFormat === (opt.v as number) + ? $r('app.color.brand') : $r('app.color.card_bg')) + .fontColor(this.displayFormat === (opt.v as number) + ? $r('app.color.button_text') : $r('app.color.text_primary')) + .border({ + width: 1, + color: this.displayFormat === (opt.v as number) + ? $r('app.color.brand') : $r('app.color.shadow_color') + }) + .onClick(() => { + this.displayFormat = opt.v as number; + this.refreshFormatPreview(); + }) + }, (opt: Record): string => `fmt_${opt.v}`) + } + .width('100%') + Text(`示例:${this.formatPreview}`) + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + .width('100%') + Text('修改后需重新写入系统日历,期间日程会短暂消失几秒') + .fontSize(11) + .fontColor($r('app.color.text_hint')) + .width('100%') + Button(this.formatRunning ? '正在应用…' + : (this.displayFormat === this.displayFormatApplied ? '已应用' : '应用')) + .fontSize(13) + .width('100%') + .enabled(!this.formatRunning && this.displayFormat !== this.displayFormatApplied) + .onClick(() => { + this.applyDisplayFormat(); + }) + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding({ top: 8 }) + Button(this.mirrorRunning ? '正在执行…' : '立即执行镜像') .fontSize(14) .width('100%')