0.0.8:系统日历账户显示名可切换 + 清理判定改为 bookId 身份校验

背景:应用往系统日历写数据时,账户显示名固定为「同步日历 · <本名>」。多账号用户
无法看出某个日历本属于哪个账号;若把"应用名+账号名"都写上又太长,本名被挤压看不清。
方案见《镜像账户命名方案-去掉应用名前缀.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 兜底失效")—— **全部通过**。
- 手机未连接 → 未装机(待用户插线后安装测试)。
This commit is contained in:
2026-10-14 10:09:48 +08:00
parent ed26da8dc4
commit a49e7d03b1
5 changed files with 495 additions and 19 deletions
+30
View File
@@ -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<number> {
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<void> {
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;成功完成后标记,恢复增量模式。
+102
View File
@@ -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<RenameResult> {
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<boolean> {
return await SystemCalendarMirror.hasLegacyDisplayName(context);
}
}
@@ -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<boolean> {
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<Set<string>> {
const ids: Set<string> = new Set<string>();
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`(`<accId>-<href哈希>`,取**最后一个** `-` 之前的部分),
* 并确认它在当前账号库里 —— 精准识别"账号已删的孤儿",**与显示名完全解耦**。
*
* ⚠️ 为什么不能只靠 displayName:显示名现在**可切换**(格式 3 不含"同步日历"),
* 且格式 3(`账号·本名`)可能与别的 App 的账户同名 → 只按名字判定会**误删用户数据**。
*
* @param knownIds 当前真实账号 id 集合(**空集 = 读不到账号 → 一律返回 false**,保守)
* @param allowDisplayMark 是否允许"仅 displayName 含归属标识"兜底(**仅格式 1/2 可用**)
*/
static isOurMirrorAccount(
name: string,
display: string,
knownIds: Set<string>,
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<string> = 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<number> {
if (bookIds.length === 0) {
return 0;
}
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const targets: Set<string> = new Set<string>();
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<string>,
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<string, MirrorOcc[]> = new Map<string, MirrorOcc[]>();
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<void> {
// ⭐ 账户名与 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<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
@@ -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<calendarManager.Calendar> {
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 });
+133
View File
@@ -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<void> {
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<string, string | number>): 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, string | number>): 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%')