Files
SyncCalendar/entry/src/main/ets/common/SystemCalendarMirror.ets
T
laoyang c76c78b8a6 删除 CalDAV 账号时一并清除其系统日历镜像本
问题:在 App 里删除一个 CalDAV 账号后,之前镜像进系统日历的日历本
(synccalendar.mirror.<accId>-<hash> 账户) 仍留在系统日历里,形成幽灵账户。
真机实测确认:账号 1789564608312 已删,其名下 13 个镜像账户仍在(KEEP)。

根因:
- AccountsPage.confirmDeleteAccount 只清账号配置+本地库+设置,完全不碰系统日历;
- 自动兜底也失效:SystemCalendarMirror.syncNow 在解析不到任何选中本时提前 return,
  删完账号后正是此状态,purgeOrphans 永不执行。

修复:
- SystemCalendarMirror.removeByAccount(accId):按 'mirror.<accId>-' 前缀删除该账号全部
  镜像账户(兼容历史 '<accId>_' 桶名),逐个独立 try/catch + 清空日程降级;
  绝不按 displayName 匹配 —— 不同账号下的同名日历本不会误删;同步清快照与选中项设置。
- AccountsPage 删除账号时先 removeByAccount(必须在改账号配置前,否则 bookId 解析不出)。
- EditAccountPage 取消勾选日历本时删除其镜像账户(用 href 算稳定 bookId)。
- SettingsPage 账号库重置时 removeAll。
- syncNow 提前 return 前加 purgeOrphanAccountMirrors 兜底,带安全阀(账号列表为空即跳过,
  防读失败误删全量)。
2026-10-09 08:47:50 +08:00

1503 lines
65 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// entry/src/main/ets/common/SystemCalendarMirror.ets
// 出站镜像:把用户选中的 CalDAV 日历本写进「系统日历」中**本应用自己的日历账户**。
//
// 目的:本应用是 CalDAV 客户端,日程不进系统日历,导致小艺 / 桌面日历卡片 / 手表看到的是空的
// (甚至给出"今天没有日程"的错误答案)。把选中的日历本镜像过去后,这些系统入口就能看到。
//
// 设计要点:
// 1. 每个选中的 DAV 日历本 → 系统日历中一个独立账户(displayName = "同步日历 · <日历本名>"),
// 这样每个本能有自己的颜色,避免"多个本挤一个颜色"的问题。
// ✅ 2026-09-17 用户实测:**系统日历能显示应用写入的自定义颜色**(只有手动建本时才给固定色板)。
// 2. `Event.identifier` 存 `<bookId>|<uid>`(重复系列再加 `@<发生时刻>`)作为映射键,用于幂等更新与删除。
// ⭐ 一律用稳定标识 **bookId**(由服务器 href 派生),绝不用于会变的 calKey
// —— calKey 里的序号会因日历本重排而变,曾导致"重复账户 + 串本"(2026-09-19 事故)。
// **只删除 identifier 命中我们格式的条目** —— 用户在系统日历里自己往这个账户加的日程不会被误删
// (这也是后续做"系统日历 → CalDAV 回写"的输入来源)。✅ 用户实测确认不会被删。
// 3. 全天日程的时间语义差异:
// - 本工程 LocalEvent:全天日程 endTime = 排他结束日前一毫秒(iCal 约定)
// - Calendar Kit:全天日程 endTime = 当天 24:00
// → 见 allDayEndTime()。⚠️ 比较时**不能逐毫秒比**:系统会把全天 endTime 归一化成
// 23:59:59.999 之类,导致"每次镜像都更新同一批、永不收敛" → 见 diffOf()。
// 4. **重复日程由我们自己按窗口展开后逐条写入**,绝不把 master + RRULE 交给系统日历去展开。
// 原因:Calendar Kit 的 RecurrenceRule 表达力很弱(BYDAY / BYMONTHDAY / BYMONTH / EXDATE 极易丢),
// 与 App 的 RruleUtil.expand 口径不一致 → 出现"App 里没有、系统日历里一堆"的幽灵重复日程
// (2026-09-19 事故,用户实测)。展开一律用 **RruleUtil.expand**,与显示口径完全一致。
// ⚠️ 本工程 `recurring` 的语义是"属于某个重复系列"(master 与 RECURRENCE-ID 覆盖行都是 true),
// 不是"展开实例";对它们 identifier 统一加 `@<发生时刻>` 才唯一,否则同一 uid 的多条覆盖行互相覆盖。
// 5. **窗口镜像**:窗口 = 过去 1 年 ~ 未来 2 年(对齐到月边界),只写窗口内的发生;
// 窗口外的单点日程跳过(App 也不会显示)。注:RruleUtil.expand 自带 −40 天 / +1 天边界余量。
// 6. **防回灌**:系统日历备份进 CalDAV 的日程(uid 前缀 `syscal-`)不能再镜像回系统日历,
// 否则就是自我复制;备份目标本整体也不参与镜像。
// ⭐ 备份目标一律用 **bookId** 记录与解析(`checkBackupTarget`)。老版本存的是 calKey,
// 用户「取消勾选」某个本后序号前移 → 目标会悄悄变成另一个本,把系统日历导进没选过的本;
// 现在解析不到就**直接关闭备份功能**并提示用户重选。
//
// ⚠️ 合规:WRITE_CALENDAR 只能在用户主动点击"开启镜像"开关时申请,严禁启动时申请。
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { calendarManager } from '@kit.CalendarKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { AccountStore, BookPalette, DavAccount } from './AccountStore';
import { EventDb, LocalEvent } from './EventDb';
import { IcsTime, IcsUtil } from './IcsUtil';
import { RruleUtil } from './RruleUtil';
import { LogUtil } from './LogUtil';
import { AppSettings } from './AppSettings';
import { MirrorSnapshot } from './MirrorSnapshot';
/** 一次镜像执行的统计 */
export class MirrorResult {
books: number = 0; // 处理的日历本数
added: number = 0;
updated: number = 0;
deleted: number = 0;
skipped: number = 0; // 重复实例 / 已删除 / 系统日历备份导入项 等跳过项
failed: number = 0;
// 诊断用
totalRows: number = 0; // 本地库读到的总行数
dupOcc: number = 0; // 同一 (uid, 发生时刻) 重复、被合并掉的发生数(本地库冗余行导致)
winOut: number = 0; // 因"落在窗口外"被跳过的条数
syscal: number = 0; // 因 uid 前缀 syscal-(系统日历备份来的)被跳过的条数
markDel: number = 0; // 因已标记删除/无 uid 被跳过的条数
}
/** 一次孤儿账户清理的结果 */
export class PurgeResult {
removed: number = 0; // 成功删除的账户数
failed: number = 0; // 删除失败的账户数
failedNames: string[] = []; // 删除失败的账户名
remaining: number = 0; // 清理后仍然残留的镜像账户数
remainingNames: string[] = [];
detail: string[] = []; // 账户清单诊断行(`#id [KEEP/MINE/other] name=… display=… type=…`)
}
export class SystemCalendarMirror {
/** 镜像只需要读写"本应用创建的账户",用普通级权限即可;WHOLE_CALENDAR 是 system_basic,拿不到也不需要 */
private static readonly MIRROR_PERMISSIONS: Permissions[] = [
'ohos.permission.READ_CALENDAR',
'ohos.permission.WRITE_CALENDAR'
];
/** 系统日历账户 name 前缀。name 是开发者定义且 readonly,必须稳定可复现才能找到已有账户 */
private static readonly ACCOUNT_PREFIX: string = 'synccalendar.mirror.';
/** 系统日历账户 displayName 前缀,便于用户在系统日历里识别来源 */
private static readonly DISPLAY_PREFIX: string = '同步日历 · ';
private static readonly DAY: number = 86400000;
/** getEvents() 显式指定返回字段:identifier 只在 API20+ 的默认字段里,写死更保险 */
private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [
'id', 'type', 'title', 'startTime', 'endTime', 'isAllDay',
'description', 'location', 'identifier', 'reminderTime', 'recurrenceRule'
];
/**
* SystemCalendarImport 写入本地库的 uid 前缀(`syscal-<系统账户名>-<系统事件id>`)。
* 这些日程**本来就是从系统日历导进来的**,再镜像回系统日历 = 自我复制,必须跳过。
*/
private static readonly SYSCAL_UID_PREFIX: string = 'syscal-';
/** 批量新增每批条数 */
private static readonly CHUNK: number = 50;
/**
* ⭐ 单条重复日程最多展开多少条"发生"。
* 我们自己按窗口展开(不再让系统日历展开),"每天重复 × 3 年"会有上千条,
* 必须设上限防止把系统日历撑爆;达到上限会记日志。
*/
private static readonly MAX_OCC: number = 500;
/* ==================== 权限 ==================== */
/** **只检查、绝不弹窗** —— 供界面判断状态(合规要求:不得在未主动操作时申请) */
static async hasPermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const info = bundleManager.getBundleInfoForSelfSync(
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
const tokenId: number = info.appInfo.accessTokenId;
for (const p of SystemCalendarMirror.MIRROR_PERMISSIONS) {
const status = await atManager.checkAccessToken(tokenId, p);
if (status !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
return false;
}
}
return true;
} catch (err) {
return false;
}
}
/**
* 申请镜像所需权限。
* ⚠️ 只能在用户主动点击"开启镜像到系统日历"开关时调用(已授权则直接返回,不再弹窗)。
*/
static async requestPermission(context: common.UIAbilityContext): Promise<boolean> {
if (await SystemCalendarMirror.hasPermission()) {
return true;
}
try {
const atManager = abilityAccessCtrl.createAtManager();
const result = await atManager.requestPermissionsFromUser(
context, SystemCalendarMirror.MIRROR_PERMISSIONS);
for (const r of result.authResults) {
if (r !== 0) {
return false;
}
}
return true;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`镜像权限申请失败: ${e.code} - ${e.message}`);
return false;
}
}
/* ==================== 主入口 ==================== */
/**
* ⚠️ **已废弃**:账户名必须以 BookRef.sysAccountName(由 href 派生的稳定 bookId)为准。
* 保留仅为兼容引用;用 calKey 拼账户名会导致重复账户与串本(09-19 事故)。
*/
static accountNameOf(calKey: string): string {
return SystemCalendarMirror.ACCOUNT_PREFIX + calKey;
}
/**
* 某个系统日历账户是不是**本应用镜像出去的**?
* ⚠️ `SystemCalendarImport`(系统日历 → 备份本)必须跳过这些账户:
* 它们的 type 也是 LOCAL,但内容是我们从 CalDAV 写进去的,
* 再导进备份本就会让同一条日程在服务器里出现两份。
*/
static isMirrorAccountName(name: string): boolean {
return name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
}
/* ==================== 孤儿账户清理 ==================== */
/**
* ⭐ 清理孤儿镜像账户:凡是"我们建的"但不属于本次选中本的,全部删掉。
*
* ⚠️ 关键教训(09-19 第二次):**每个账户必须独立 try/catch**。
* 旧写法把整段循环包在一个 try 里,任意一个 `deleteCalendar` 抛错就会跳出整个 try,
* 后面所有账户都不再处理 → 表现为"只删掉一部分、还有残留"。
*
* 降级策略:直接删账户失败 → 先 `deleteEvents` 清空其中所有日程,再删一次。
*
* @param validNames 本次要保留的账户名集合(当前选中本的 sysAccountName)
* @param aggressive 是否连"只有 displayName 命中、name 被系统改写"的账户一起清(历史脏数据兜底)
*/
static async purgeOrphans(
context: common.Context,
cm: calendarManager.CalendarManager,
validNames: Set<string>,
aggressive: boolean
): Promise<PurgeResult> {
const out: PurgeResult = new PurgeResult();
let all: calendarManager.Calendar[] = [];
try {
all = await cm.getAllCalendars();
} catch (err) {
LogUtil.write(`清理镜像账户失败:读不到系统日历账户列表 ${(err as BusinessError).message}`);
return out;
}
// ① 诊断:把**全部**账户列一遍(不只是我们的),才能看出残留账户到底是什么名字
for (const cal of all) {
out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, aggressive));
}
LogUtil.write(`系统日历账户清单(共 ${all.length}):`);
for (const line of out.detail) {
LogUtil.write(` ${line}`);
}
// ② 逐个清理
for (const cal of all) {
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);
if (!mineByName && !mineByDisplay) {
continue;
}
if (validNames.has(name)) {
continue; // 本次还要用,保留
}
const bucket: string = mineByName
? name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length)
: name;
try {
await cm.deleteCalendar(cal);
out.removed++;
LogUtil.write(`已删除镜像账户: id=${cal.id} name=${name} display=${display}`);
} catch (err) {
const e1 = err as BusinessError;
LogUtil.write(`删除镜像账户失败(1次) id=${cal.id} name=${name}: ${e1.code ?? ''} ${e1.message}`);
// 降级:先清空该账户下所有日程,再删一次
try {
const evs: calendarManager.Event[] = await cal.getEvents();
const ids: number[] = [];
for (const ev of evs) {
if (ev.id !== undefined) {
ids.push(ev.id);
}
}
if (ids.length > 0) {
await cal.deleteEvents(ids);
}
} catch (err2) {
LogUtil.write(`清空镜像账户日程失败 id=${cal.id}: ${(err2 as BusinessError).message}`);
}
try {
await cm.deleteCalendar(cal);
out.removed++;
LogUtil.write(`已删除镜像账户(清空后重试成功): id=${cal.id} name=${name}`);
} catch (err3) {
const e3 = err3 as BusinessError;
out.failed++;
out.failedNames.push(name);
LogUtil.write(`删除镜像账户失败(最终) id=${cal.id} name=${name}: ${e3.code ?? ''} ${e3.message}`);
}
}
await MirrorSnapshot.removeByCalKey(context, bucket);
}
// ③ 复查:清理后还剩几个"我们的"账户
try {
const after: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of after) {
const acc: calendarManager.CalendarAccount = cal.getAccount();
if (acc.name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX) &&
!validNames.has(acc.name)) {
out.remaining++;
out.remainingNames.push(`id=${cal.id} name=${acc.name}`);
}
}
} catch (err) {
// 复查失败不影响结果
}
return out;
}
/** 一个账户的诊断行:`#id [KEEP/MINE/other] name=… display=… type=…` */
private static describeCal(
cal: calendarManager.Calendar,
validNames: Set<string>,
aggressive: 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);
let tag: string = 'other ';
if (mineByName || mineByDisplay) {
tag = validNames.has(name) ? 'KEEP ' : 'MINE ';
}
return `#${cal.id} [${tag}] name=${name} display=${display} type=${acc.type}`;
}
/**
* 供设置页「清理残留镜像账户」按钮调用:
* 列出全部账户到日志,并把"我们建的、但不属于当前选中本"的账户全部清掉(含 displayName 兜底)。
*/
static async forceCleanup(context: common.Context): Promise<PurgeResult> {
if (!await SystemCalendarMirror.hasPermission()) {
throw new Error('未获得系统日历读写权限');
}
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context);
const valid: Set<string> = new Set<string>();
for (const ref of refs) {
valid.add(ref.sysAccountName);
}
return SystemCalendarMirror.purgeOrphans(context, cm, valid, true);
}
/** 执行一次性镜像(幂等,可重复调用;**全量**,不设时间窗口) */
static async syncNow(context: common.Context): Promise<MirrorResult> {
const res: MirrorResult = new MirrorResult();
if (!await SystemCalendarMirror.hasPermission()) {
LogUtil.write('镜像中止:未获得系统日历读写权限');
throw new Error('未获得系统日历读写权限');
}
// ⭐ 选中项一律走**稳定标识** bookId(由服务器 href 派生),并顺带迁移历史 calKey 选中项
const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context);
// ⭐ 兜底:无论有没有选中本,都顺手清掉"账号已删除"的孤儿镜像账户。
// 否则"删掉一个账号后系统日历里还留着它的日历本"永远不会被自动修掉
// (旧代码在这里 return 之前根本不做清理,这正是本次要修的 bug)。
try {
await SystemCalendarMirror.purgeOrphanAccountMirrors(context);
} catch (err) {
// 兜底清理失败不影响正常镜像
}
if (refs.length === 0) {
LogUtil.write('镜像中止:未选择要镜像的日历本(或全部是备份目标本)');
return res;
}
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
// ⭐ 清理孤儿账户:系统日历里凡是本应用镜像出去、但不属于本次选中本的,全删。
// 这是清理历史脏数据(旧版用 calKey 建的账户)的关键一步,否则重复账户会一直堆积。
const valid: Set<string> = new Set<string>();
for (const ref of refs) {
valid.add(ref.sysAccountName);
}
const purge: PurgeResult = await SystemCalendarMirror.purgeOrphans(context, cm, valid, false);
LogUtil.write(
`镜像前清理:删除 ${purge.removed} 个孤儿镜像账户,失败 ${purge.failed} 个,清理后仍剩余 ${purge.remaining} 个`);
const winStart: number = SystemCalendarMirror.windowStart();
const winEnd: number = SystemCalendarMirror.windowEnd();
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);
res.books++;
} catch (err) {
const e = err as BusinessError;
res.failed++;
LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
}
}
// ⭐ 每次镜像后自动导出一份对照诊断,便于"系统日历里有、App 里没有"这类问题逐条比对
await SystemCalendarMirror.dumpDiagnosis(context, cm, refs, plans, winStart, winEnd);
LogUtil.write(
`镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` +
`跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` +
` 本地总行=${res.totalRows} 重复发生合并=${res.dupOcc}`);
return res;
}
/** 清空镜像:删除本应用创建的所有镜像账户(关闭开关时调用) */
static async removeAll(context: common.Context): Promise<number> {
if (!await SystemCalendarMirror.hasPermission()) {
return 0;
}
let n: number = 0;
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
// ⭐ 复用 purgeOrphans:保留集合为空 = 全删;aggressive = 连 displayName 命中的历史脏账户一起清
const purge: PurgeResult =
await SystemCalendarMirror.purgeOrphans(context, cm, new Set<string>(), true);
await MirrorSnapshot.clearAll(context); // 快照整体清空,顺带清掉历史 calKey 键的残留
n = purge.removed;
LogUtil.write(
`镜像已清除:删除 ${purge.removed} 个镜像账户,失败 ${purge.failed} 个,仍剩余 ${purge.remaining} 个`);
if (purge.failed > 0) {
LogUtil.write(`删除失败的账户:${purge.failedNames.join(' , ')}`);
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`清除镜像失败: ${e.message}`);
}
return n;
}
/**
* 取消单个日历本的镜像:删除它对应的系统日历账户(幂等;不存在则什么也不做)。
* ⚠️ 参数必须是 **bookId**(稳定标识),不是 calKey。
*/
static async removeById(context: common.Context, bookId: string): Promise<boolean> {
if (!await SystemCalendarMirror.hasPermission()) {
return false;
}
const accountName: string = SystemCalendarMirror.ACCOUNT_PREFIX + bookId;
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of calendars) {
if (cal.getAccount().name === accountName) {
await cm.deleteCalendar(cal);
await MirrorSnapshot.removeByCalKey(context, bookId);
LogUtil.write(`已删除镜像账户: ${accountName}`);
return true;
}
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`删除镜像账户失败 ${accountName}: ${e.message}`);
}
return false;
}
/** 兼容旧调用:传 calKey 也能删(内部解析成 bookId,两种账户名都清) */
static async removeBook(context: common.Context, calKey: string): Promise<boolean> {
const refs: Map<string, BookRef> = await SystemCalendarMirror.resolveBookRefs(context, [calKey]);
const ref: BookRef | undefined = refs.get(calKey);
const bookId: string = ref !== undefined ? ref.bookId : calKey;
return SystemCalendarMirror.removeById(context, bookId);
}
/**
* ⭐ 删除**某一个 DAV 账号**名下的全部镜像账户(在 App 里删除该账号时调用)。
*
* 背景(用户反馈):删掉一个 CalDAV 账号后,之前镜像进系统日历的那些"日历本"
* 仍然留在系统日历里(幽灵账户 + 幽灵日程)。旧代码只清了账号配置与本地库,
* 完全没碰系统日历侧;而自动兜底(syncNow 里的 purgeOrphans)在"解析不到任何选中本"时
* **会提前 return**,所以删完账号后根本不会触发兜底清理。
*
* ⭐ **绝不按日历本显示名匹配**:镜像账户名是 `synccalendar.mirror.<bookId>`,
* 而 bookId = `<accId>-<href哈希>` 天然带账号前缀。因此这里只按
* `synccalendar.mirror.<accId>-` 前缀匹配 —— 即使**不同账号下有同名日历本**
* (比如两个账号各有一个「工作」),它们的 bookId 不同、账户名也不同,绝不会删错。
*
* 幂等;未授权 / 无匹配时返回 0。每个账户独立 try/catch,一个失败不影响其它。
*
* @returns 成功删除的镜像账户数
*/
static async removeByAccount(context: common.Context, accId: string): Promise<number> {
if (accId === '' || !await SystemCalendarMirror.hasPermission()) {
return 0;
}
// ⭐ 账户名前缀 = 公共前缀 + accId + '-'(bookId 里的分隔符)。
// 带尾随 '-' 才不会把 accId「123」错配到 accId「1234」的账户上。
// 同时兼容**历史**账户名(旧版用 calKey 做桶 → `synccalendar.mirror.<accId>_<序号>`),
// 同样带尾随 '_' 分隔符,宁可多清一批脏账户,也不按显示名乱删。
const prefix: string = `${SystemCalendarMirror.ACCOUNT_PREFIX}${accId}-`;
const legacyPrefix: string = `${SystemCalendarMirror.ACCOUNT_PREFIX}${accId}_`;
let removed: number = 0;
const buckets: string[] = [];
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const all: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of all) {
const name: string = cal.getAccount().name;
if (!name.startsWith(prefix) && !name.startsWith(legacyPrefix)) {
continue; // 不属于本账号(含其它账号的同名日历本)→ 绝不动
}
const bucket: string = name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length);
try {
await cm.deleteCalendar(cal);
removed++;
buckets.push(bucket);
LogUtil.write(`删除账号 ${accId}:已删除镜像账户 id=${cal.id} name=${name}`);
} catch (err) {
const e1 = err as BusinessError;
LogUtil.write(`删除账号 ${accId}:删除镜像账户失败(重试) id=${cal.id} name=${name}: ${e1.message}`);
// 降级:先清空账户内全部日程,再删一次(与 purgeOrphans 同策略)
try {
const evs: calendarManager.Event[] = await cal.getEvents();
const ids: number[] = [];
for (const ev of evs) {
if (ev.id !== undefined) {
ids.push(ev.id);
}
}
if (ids.length > 0) {
await cal.deleteEvents(ids);
}
} catch (err2) {
LogUtil.write(`删除账号 ${accId}:清空镜像账户日程失败 id=${cal.id}: ${(err2 as BusinessError).message}`);
}
try {
await cm.deleteCalendar(cal);
removed++;
buckets.push(bucket);
LogUtil.write(`删除账号 ${accId}:已删除镜像账户(清空后重试成功) id=${cal.id} name=${name}`);
} catch (err3) {
LogUtil.write(`删除账号 ${accId}:镜像账户删除失败(最终) id=${cal.id} name=${name}: ${(err3 as BusinessError).message}`);
}
}
}
} catch (err) {
LogUtil.write(`删除账号 ${accId}:读取系统日历账户列表失败 ${(err as BusinessError).message}`);
}
// 清掉这些本在镜像快照里的残留(bucket = bookId)
for (const b of buckets) {
await MirrorSnapshot.removeByCalKey(context, b);
}
// 清掉"镜像选中项"里属于本账号的条目(防止残留脏 bookId 干扰下次选择/迁移)。
// ⚠️ 用 `${accId}-` / `${accId}_` 前缀过滤(同样带分隔符,避免误伤相似 accId)。
try {
const ids: string[] = await AppSettings.getMirrorBookIds(context);
const kept: string[] = ids.filter((id: string): boolean => !id.startsWith(`${accId}-`));
if (kept.length !== ids.length) {
await AppSettings.setMirrorBookIds(context, kept);
}
const legacy: string[] = await AppSettings.getMirrorKeys(context);
const keptLegacy: string[] = legacy.filter((k: string): boolean => !k.startsWith(`${accId}_`));
if (keptLegacy.length !== legacy.length) {
await AppSettings.setMirrorKeys(context, keptLegacy);
}
} catch (err) {
// 设置清理失败不影响账户删除结果,下次同步会再兜底
}
LogUtil.write(`删除账号 ${accId}:系统日历镜像清理完成,共删除 ${removed} 个镜像账户`);
return removed;
}
/**
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
*
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。
*
* @returns 删除的账户数
*/
static async purgeOrphanAccountMirrors(context: common.Context): Promise<number> {
if (!await SystemCalendarMirror.hasPermission()) {
return 0;
}
let accounts: DavAccount[] = [];
try {
accounts = await AccountStore.loadAll(context);
} catch (err) {
return 0; // 读失败一律不动
}
if (accounts.length === 0) {
return 0; // 安全阀:见注释
}
const validAccIds: Set<string> = new Set<string>();
for (const a of accounts) {
validAccIds.add(a.id);
}
let removed: number = 0;
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const all: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of all) {
const name: string = cal.getAccount().name;
if (!name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
continue;
}
const bucket: string = name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length);
// bucket 形如 `<accId>-<hash>`;也兼容历史 `<accId>_<序号>`
const dash: number = bucket.indexOf('-');
const under: number = bucket.indexOf('_');
let sep: number = -1;
if (dash >= 0 && under >= 0) {
sep = Math.min(dash, under);
} else {
sep = dash >= 0 ? dash : under;
}
if (sep <= 0) {
continue; // 解析不出 accId → 不动(宁可留,不误删)
}
const accId: string = bucket.substring(0, sep);
if (validAccIds.has(accId)) {
continue;
}
try {
await cm.deleteCalendar(cal);
removed++;
LogUtil.write(`兜底清理孤儿镜像账户(账号 ${accId} 已删除) id=${cal.id} name=${name}`);
} catch (err) {
LogUtil.write(`兜底删除孤儿镜像账户失败 id=${cal.id} name=${name}: ${(err as BusinessError).message}`);
}
await MirrorSnapshot.removeByCalKey(context, bucket);
}
} catch (err) {
LogUtil.write(`兜底清理孤儿镜像账户:读取系统日历账户失败 ${(err as BusinessError).message}`);
}
if (removed > 0) {
LogUtil.write(`兜底清理孤儿镜像账户完成:删除 ${removed} 个`);
}
return removed;
}
/* ==================== 单个日历本 ==================== */
/**
* ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。
* 实际写入(syncBook)与诊断导出(dumpDiagnosis)都用它,保证诊断看到的就是真正写进去的。
*/
private static async planFor(
context: common.Context,
ref: BookRef,
winStart: number,
winEnd: number,
res: MirrorResult
): Promise<MirrorOcc[]> {
const calKey: string = ref.calKey;
const all: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
res.totalRows += all.length;
const seen: Set<string> = new Set<string>();
// RECURRENCE-ID 覆盖行(对某一次发生的单独修改):自己单独成条,
// 同时让主事件在展开时跳过对应发生,避免同一次发生出现两条(口径同 CalendarDataService)。
const overrideKeys: Set<string> = new Set<string>();
for (const e of all) {
if (!e.deleted && e.rrule === '' && e.recurring) {
overrideKeys.add(EventDb.overrideKey(e.calKey, e.uid, e.recurrenceId, e.startTime));
}
}
const out: MirrorOcc[] = [];
for (const e of all) {
if (e.deleted || e.uid === '') {
res.skipped++;
res.markDel++;
continue;
}
// 防回灌:来自系统日历备份的日程不再写回系统日历
if (e.uid.startsWith(SystemCalendarMirror.SYSCAL_UID_PREFIX)) {
res.skipped++;
res.syscal++;
continue;
}
const dur: number = Math.max(0, e.endTime - e.startTime);
let times: number[] = [];
if (e.rrule !== '') {
const exNums: number[] = SystemCalendarMirror.exdateNums(e.exdate);
const occs: number[] = RruleUtil.expand(
e.rrule, e.startTime, winStart, winEnd, exNums, SystemCalendarMirror.MAX_OCC);
if (occs.length >= SystemCalendarMirror.MAX_OCC) {
LogUtil.write(`⚠️ 重复日程展开达上限 ${SystemCalendarMirror.MAX_OCC} 条:${e.title} [${e.rrule}]`);
}
times = occs.filter((occ: number): boolean =>
!overrideKeys.has(EventDb.overrideKey(e.calKey, e.uid, 0, occ)));
} else if (e.startTime >= winStart && e.startTime <= winEnd) {
times = [e.startTime];
} else {
res.skipped++; // 窗口外的单点日程 → 不写(App 里也不显示)
res.winOut++;
continue;
}
for (const t of times) {
const o: MirrorOcc = new MirrorOcc();
// ⭐ 凡是"重复系列"里的(master 的每次发生、以及 RECURRENCE-ID 覆盖行)
// 都要带上自己的发生时刻,否则同一 uid 的多条覆盖行会挤在同一个 identifier 上互相覆盖。
// (实测:轻舟本计划 984 条、实际只写出 975 条,就是这么丢的)
o.identifier = e.recurring
? `${ref.bookId}|${e.uid}@${t}`
: `${ref.bookId}|${e.uid}`;
if (seen.has(o.identifier)) {
// 同一 (uid, 发生时刻) 出现两次(本地库冗余行)→ 系统日历里同一 identifier 只能存一条,
// 明确跳过并计数;否则会出现"计划 984 条、实际只写出 983 条"的口径差。
res.dupOcc++;
continue;
}
seen.add(o.identifier);
o.start = t;
o.end = t + dur;
o.isAllDay = e.isAllDay;
o.title = e.title;
o.src = e;
out.push(o);
}
}
return out;
}
private static async syncBook(
context: common.Context,
cm: calendarManager.CalendarManager,
ref: BookRef,
plan: MirrorOcc[],
winStart: number,
winEnd: number,
res: MirrorResult
): Promise<void> {
// ⭐ 账户名与 identifier 一律用 **bookId**(由 href 派生、稳定),绝不用 calKey
const cal: calendarManager.Calendar =
await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref);
// 现有镜像条目:identifier -> Event
const existing: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
const sysEvents: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
for (const ev of sysEvents) {
const id: string = ev.identifier ?? '';
if (id === '' || ev.id === undefined) {
continue;
}
existing.set(id, ev);
}
// 期望存在的条目
const want: Set<string> = new Set<string>();
const toAdd: calendarManager.Event[] = [];
let logged: number = 0;
for (const occ of plan) {
want.add(occ.identifier);
const desired: calendarManager.Event =
SystemCalendarMirror.toSystemEvent(occ.src, occ.identifier, occ.start, occ.end);
const cur: calendarManager.Event | undefined = existing.get(occ.identifier);
if (cur === undefined) {
toAdd.push(desired);
continue;
}
const diff: string = SystemCalendarMirror.diffOf(cur, desired);
if (diff !== '') {
desired.id = cur.id;
await cal.updateEvent(desired);
res.updated++;
if (logged < 5) { // 只记前 5 条,避免刷屏
LogUtil.write(`镜像更新[${ref.name}] ${occ.identifier}: 差异=${diff}`);
logged++;
}
}
}
if (toAdd.length > 0) {
await SystemCalendarMirror.addInChunks(cal, toAdd, res);
}
// 删除:本地已没有,但系统日历里还在的**我们写进去的**条目
for (const id of want) {
existing.delete(id);
}
for (const id of existing.keys()) {
if (!id.startsWith(`${ref.bookId}|`)) {
continue; // 不是本日历本写进去的(可能是用户在系统日历里手动加的)→ 保留
}
const ev: calendarManager.Event | undefined = existing.get(id);
if (ev !== undefined && ev.id !== undefined) {
await cal.deleteEvent(ev.id);
res.deleted++;
}
}
// ⭐ 落成快照:出站写完后重新读一遍系统日历,整体重建该本的快照。
// 这是"防回灌"的地基 —— 下一轮入站对账靠它区分"用户改的"还是"我们自己写的"。
// 快照按 **bookId** 存(与系统日历侧标识一致),不按会变的 calKey。
const after: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
const snapN: number = await MirrorSnapshot.replaceAll(context, ref.bookId, after);
LogUtil.write(
`镜像[${ref.name}] bookId=${ref.bookId} 计划=${plan.length} 系统已有=${sysEvents.length} ` +
`新增=${toAdd.length} 快照=${snapN} ` +
`窗口=${SystemCalendarMirror.fmt(winStart)}~${SystemCalendarMirror.fmt(winEnd)}`);
}
/**
* ⭐ 诊断导出:把"App 侧计划写入"与"系统日历里实际存在"的完整对照写到
* `${filesDir}/mirror_diag.txt`(可用 hdc 直接取出分析)。
*
* 为什么需要它:用户反馈"系统日历里有、App 里没有"这种问题靠描述很难定位,
* 必须把两侧的清单原样摆在一起逐条比。每次镜像后自动重写一份。
*/
private static async dumpDiagnosis(
context: common.Context,
cm: calendarManager.CalendarManager,
refs: BookRef[],
plans: Map<string, MirrorOcc[]>,
winStart: number,
winEnd: number
): Promise<void> {
try {
const lines: string[] = [];
lines.push(`镜像诊断 ${new Date().toLocaleString()}`);
lines.push(`窗口 ${SystemCalendarMirror.fmt(winStart)} ~ ${SystemCalendarMirror.fmt(winEnd)} ` +
`(${winStart} ~ ${winEnd})`);
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
// ⭐ 全设备日历账户清单:一眼看出有没有"本应用建的、但不属于当前选中本"的残留账户
// (历史 calKey 命名的账户、重复账户)。每次镜像都导出一份,省得再靠按钮抓。
const keepNames: Set<string> = new Set<string>();
for (const r of refs) {
keepNames.add(r.sysAccountName);
}
let mineN: number = 0;
let keepN: number = 0;
lines.push('');
lines.push(`[全设备日历账户] 共 ${cals.length} 个日历本:`);
for (const c of cals) {
let accName: string = '';
let disp: string = '';
let n: number = -1;
try {
accName = c.getAccount().name;
disp = c.getAccount().displayName ?? '';
} catch (err) {
// 忽略
}
try {
n = (await SystemCalendarMirror.queryEvents(c)).length;
} catch (err) {
// 忽略
}
let tag: string = 'other';
if (accName.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
if (keepNames.has(accName)) {
tag = 'KEEP';
keepN++;
} else {
tag = 'MINE(残留)';
mineN++;
}
}
lines.push(` [${tag}] #${c.id} acc=${accName} display=${disp} 条目=${n}`);
}
lines.push(` 小结:本应用账户 ${keepN + mineN} 个(本次保留 ${keepN},残留 ${mineN})`);
for (const ref of refs) {
const plan: MirrorOcc[] | undefined = plans.get(ref.bookId);
const list: MirrorOcc[] = plan !== undefined ? plan : [];
let cal: calendarManager.Calendar | undefined = undefined;
for (const c of cals) {
if (c.getAccount().name === ref.sysAccountName) {
cal = c;
break;
}
}
lines.push('');
lines.push(`[本] ${ref.name} | bookId=${ref.bookId} | calKey=${ref.calKey} | 账户=${ref.sysAccountName} | 账户存在=${cal !== undefined}`);
if (cal === undefined) {
continue;
}
const sysEvents: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
const sysMap: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
for (const ev of sysEvents) {
const id: string = ev.identifier ?? '';
if (id !== '') {
sysMap.set(id, ev);
}
}
lines.push(` 计划写入=${list.length} 系统日历实际=${sysEvents.length}(其中带 identifier=${sysMap.size})`);
// ① 系统日历里有、计划里没有 → 多出来的(正常会被删掉;删不掉就是残留)
const planIds: Set<string> = new Set<string>();
for (const o of list) {
planIds.add(o.identifier);
}
const extra: string[] = [];
for (const id of sysMap.keys()) {
if (planIds.has(id)) {
continue;
}
const ev: calendarManager.Event | undefined = sysMap.get(id);
if (ev === undefined) {
continue;
}
extra.push(` ${SystemCalendarMirror.stamp(ev.startTime)} ${ev.title ?? ''} [${id}]`);
}
lines.push(` ① 系统多出(${extra.length}):`);
for (let i: number = 0; i < extra.length && i < 120; i++) {
lines.push(extra[i]);
}
// ② 计划里有、系统日历没有 → 漏写
const miss: string[] = [];
for (const o of list) {
if (!sysMap.has(o.identifier)) {
miss.push(` ${SystemCalendarMirror.stamp(o.start)} ${o.title} [${o.identifier}]`);
}
}
lines.push(` ② 漏写(${miss.length}):`);
for (let i: number = 0; i < miss.length && i < 60; i++) {
lines.push(miss[i]);
}
// ③ 系统日历里该账户的**全部**条目(供逐条核对,最多 400 行)
lines.push(` ③ 系统日历条目(${sysEvents.length},最多列 400):`);
const sorted: calendarManager.Event[] = sysEvents.slice().sort(
(a: calendarManager.Event, b: calendarManager.Event): number => a.startTime - b.startTime);
for (let i: number = 0; i < sorted.length && i < 400; i++) {
const ev: calendarManager.Event = sorted[i];
lines.push(` ${SystemCalendarMirror.stamp(ev.startTime)}~${SystemCalendarMirror.stamp(ev.endTime)} ` +
`${ev.isAllDay === true ? '[全天]' : ''}${ev.title ?? ''} [${ev.identifier ?? ''}]`);
}
}
const path: string = `${context.filesDir}/mirror_diag.txt`;
const text: string = lines.join('\n') + '\n';
const file: fs.File = fs.openSync(path,
fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, text);
fs.closeSync(file.fd);
LogUtil.write(`镜像诊断已导出:${path}(${lines.length} 行)`);
} catch (err) {
LogUtil.write(`导出镜像诊断失败: ${(err as BusinessError).message}`);
}
}
/** 时间戳 → `MM-DD HH:mm` */
private static stamp(ts: number): string {
const d: Date = new Date(ts);
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/* ==================== 镜像窗口 ==================== */
/**
* ⭐ 镜像窗口:**过去 1 年 ~ 未来 2 年**,且两端都对齐到"月份边界"。
*
* 为什么要窗口:App 首页显示的是"窗口内展开的发生",而系统日历如果拿到 master + RRULE
* 会自己无限展开 —— 两套口径不一致就会出现"App 里没有、系统日历里一堆"的幽灵日程。
* 改成我们自己按窗口展开后,两边口径完全一致。
*
* 为什么对齐到月:镜像每天都会跑,若窗口按"今天±N天"滑动,每天都在增删边界上的条目。
* 对齐到月初后,只有跨月时边界才动。
*/
private static windowStart(): number {
const d: Date = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(1);
d.setMonth(d.getMonth() - 12);
return d.getTime();
}
private static windowEnd(): number {
const d: Date = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(1);
d.setMonth(d.getMonth() + 25);
return d.getTime() - 1;
}
private static fmt(ts: number): string {
const d: Date = new Date(ts);
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
}
/** EXDATE(分号分隔的 iCal 时间值)→ 时间戳数组,供 RruleUtil 排除 */
private static exdateNums(exdate: string): number[] {
const out: number[] = [];
if (exdate === '') {
return out;
}
for (const raw of exdate.split(';')) {
const t: IcsTime | null = IcsUtil.parseTime(raw, !raw.includes('T'));
if (t !== null) {
out.push(t.time);
}
}
return out;
}
/** 批量新增(每批 CHUNK 条);整批失败则退回逐条,避免一条脏数据拖垮一批 */
private static async addInChunks(
cal: calendarManager.Calendar,
list: calendarManager.Event[],
res: MirrorResult
): Promise<void> {
for (let i: number = 0; i < list.length; i += SystemCalendarMirror.CHUNK) {
const end: number = Math.min(i + SystemCalendarMirror.CHUNK, list.length);
const part: calendarManager.Event[] = list.slice(i, end);
try {
await cal.addEvents(part);
res.added += part.length;
} catch (err) {
for (const ev of part) {
try {
await cal.addEvent(ev);
res.added++;
} catch (e2) {
res.failed++;
LogUtil.write(`镜像新增失败: ${(e2 as BusinessError).message}`);
}
}
}
}
}
/**
* 取回该账户下已有日程。
*
* ⚠️ 09-19 实测:`getEvents(undefined, 字段数组)` 在这个版本上报 `type error!`,
* 每次调用都先抛一次异常、再退回无参,既慢又把日志刷满(一次镜像几十行)。
* 而无参调用返回的 Event 里 identifier 是有值的(诊断已验证)→ 直接用无参。
*/
private static async queryEvents(cal: calendarManager.Calendar): Promise<calendarManager.Event[]> {
try {
return await cal.getEvents();
} catch (err) {
LogUtil.write(`getEvents()失败,退回带字段: ${(err as BusinessError).message}`);
return await cal.getEvents(undefined, SystemCalendarMirror.QUERY_FIELDS);
}
}
/** 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色) */
private static async ensureCalendar(
cm: calendarManager.CalendarManager,
accountName: string,
ref: BookRef
): Promise<calendarManager.Calendar> {
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of calendars) {
if (cal.getAccount().name === accountName) {
// 每次都尝试同步一次显示名与颜色(用户可能改过日历本名/色)
try {
await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) {
// 设色失败不影响镜像本身
}
return cal;
}
}
const cal: calendarManager.Calendar = await cm.createCalendar({
name: accountName,
type: calendarManager.CalendarType.LOCAL,
displayName: SystemCalendarMirror.DISPLAY_PREFIX + ref.name
});
try {
await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) {
// 同上
}
return cal;
}
/* ==================== 转换 ==================== */
/**
* 构造一条写进系统日历的日程。
* ⚠️ 传入的是**某一次发生**的起止时刻 —— 重复日程由我们自己展开成多条,
* **不再设置 recurrenceRule**(交给系统日历展开就会出现幽灵日程,见窗口说明)。
*/
private static toSystemEvent(
e: LocalEvent,
identifier: string,
startTs: number,
endTs: number
): calendarManager.Event {
const ev: calendarManager.Event = {
type: calendarManager.EventType.NORMAL,
title: e.title !== '' ? e.title : '(无标题)',
startTime: startTs,
endTime: e.isAllDay ? SystemCalendarMirror.dayStart(endTs) + SystemCalendarMirror.DAY : endTs,
isAllDay: e.isAllDay,
identifier: identifier
};
if (e.description !== '') {
ev.description = e.description;
}
if (e.location !== '') {
ev.location = { location: e.location };
}
const reminders: number[] = SystemCalendarMirror.remindersOf(e);
if (reminders.length > 0) {
ev.reminderTime = reminders;
}
// ⚠️ 这里**故意不设 recurrenceRule**:重复日程由我们自己按窗口展开成多条实例,
// 交给系统日历展开会因 RecurrenceRule 表达不全而出现错误的重复(09-19 事故)。
return ev;
}
/** 本地时区当天 0 点的时间戳 */
private static dayStart(ts: number): number {
const d: Date = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
private static remindersOf(e: LocalEvent): number[] {
if (e.reminders.length > 0) {
return e.reminders;
}
if (e.reminder > 0) {
return [e.reminder];
}
return [];
}
/**
* 比对现有条目与期望条目:**返回空串表示相同**,否则返回首个差异描述(用于诊断"每次都更新")。
*
* ⚠️ 全天日程**不能逐毫秒比**。实测(09-19 真机)系统日历对全天 endTime 有三种写法,
* 差值时区偏移(+8h)或 1 毫秒都会让"每次镜像都更新同一批、永不收敛":
* · 我们写入: 次日 00:00(本地)
* · 系统读回 A: 当日 23:59:59.999
* · 系统读回 B: 次日 00:00 + 时区偏移(如 08:00)
* → 全天一律先折成"日"再比,三种写法都要能认出来。
*/
private static diffOf(cur: calendarManager.Event, want: calendarManager.Event): string {
if (cur.title !== want.title) {
return `title "${cur.title}" != "${want.title}"`;
}
const curAllDay: boolean = cur.isAllDay ?? false;
const wantAllDay: boolean = want.isAllDay ?? false;
if (curAllDay !== wantAllDay) {
return `isAllDay ${curAllDay} != ${wantAllDay}`;
}
if (curAllDay) {
if (SystemCalendarMirror.dayStart(cur.startTime) !== SystemCalendarMirror.dayStart(want.startTime)) {
return `全天start ${cur.startTime} != ${want.startTime}`;
}
const wantEndDay: number = SystemCalendarMirror.dayStart(want.endTime - 1);
// 写法 A:当日 23:59:59.999
const curEndDayA: number = SystemCalendarMirror.dayStart(cur.endTime - 1);
// 写法 B:次日 00:00(含被时区偏移推后到次日 08:00 的情况)
const curEndDayB: number = SystemCalendarMirror.dayStart(cur.endTime) - SystemCalendarMirror.DAY;
if (curEndDayA !== wantEndDay && curEndDayB !== wantEndDay) {
return `全天end ${cur.endTime} != ${want.endTime}`;
}
} else {
if (cur.startTime !== want.startTime) {
return `start ${cur.startTime} != ${want.startTime}`;
}
if (cur.endTime !== want.endTime) {
return `end ${cur.endTime} != ${want.endTime}`;
}
}
if ((cur.description ?? '') !== (want.description ?? '')) {
return 'description';
}
if ((cur.location?.location ?? '') !== (want.location?.location ?? '')) {
return 'location';
}
return '';
}
/* ==================== 日历本信息 ==================== */
/**
* 由 calKey(accId_序号)解析出日历本名与颜色。
* 颜色优先用服务器定义的 calendar-color,缺失时退回本应用调色板。
*/
private static bookInfo(calKey: string): BookInfo {
const info: BookInfo = new BookInfo();
info.calKey = calKey;
const pos: number = calKey.lastIndexOf('_');
if (pos <= 0) {
info.name = calKey;
info.color = BookPalette.colorFor(0);
return info;
}
const accId: string = calKey.substring(0, pos);
const idx: number = Number.parseInt(calKey.substring(pos + 1), 10);
info.name = `日历本 ${idx + 1}`;
info.color = BookPalette.colorFor(idx);
info.accId = accId;
info.index = idx;
return info;
}
/**
* ⭐ 解析出每个日历本的**稳定**引用。
*
* ⚠️ 血的教训(09-19):`calKey` 是 `<账号ID>_<序号>`,**序号会变** ——
* `EventDb.remapCalKeys()` 就是专门在日历本重排时把日程从旧 calKey 搬到新 calKey 的。
* 早期版本拿 calKey 当系统日历账户名 + identifier 前缀,序号一变就:
* · 旧账户还在,但 identifier 全对不上 → 旧条目不删、新条目全插 → 系统日历出现重复;
* · 某个账户被当成另一个本 → 日程串本。
* → 一律改用**服务器分配的 href**(稳定不变)派生 bookId。
*/
static async resolveBookRefs(
context: common.Context,
keys: string[]
): Promise<Map<string, BookRef>> {
const all: BookRef[] = await SystemCalendarMirror.listBooks(context);
const out: Map<string, BookRef> = new Map<string, BookRef>();
for (const calKey of keys) {
for (const ref of all) {
if (ref.calKey === calKey) {
out.set(calKey, ref);
break;
}
}
}
return out;
}
/** ⭐ 列出**全部** DAV 日历本的稳定引用(账号 × 序号) */
static async listBooks(context: common.Context): Promise<BookRef[]> {
const out: BookRef[] = [];
let accounts: DavAccount[] = [];
try {
accounts = await AccountStore.loadAll(context);
} catch (err) {
accounts = [];
}
for (const acc of accounts) {
const n: number = Math.max(
acc.calendarHrefs.length,
Math.max(acc.calendarNames.length, acc.calendarColors.length)
);
for (let i: number = 0; i < n; i++) {
const ref: BookRef = new BookRef();
ref.accId = acc.id;
ref.index = i;
ref.calKey = `${acc.id}_${i}`;
ref.href = i < acc.calendarHrefs.length ? acc.calendarHrefs[i] : '';
ref.name = (i < acc.calendarNames.length && acc.calendarNames[i] !== '')
? acc.calendarNames[i]
: `日历本 ${i + 1}`;
ref.color = (i < acc.calendarColors.length && acc.calendarColors[i] !== '')
? acc.calendarColors[i]
: BookPalette.colorFor(i);
ref.accountName = acc.name;
// 稳定标识:href 由服务器分配、不随序号变动(href 缺失时退回 calKey 并记日志)
ref.bookId = ref.href !== ''
? `${ref.accId}-${SystemCalendarMirror.stableHash(ref.href)}`
: ref.calKey;
if (ref.href === '') {
LogUtil.write(`⚠️ 日历本 ${ref.calKey} 缺少 href,退回用 calKey 做镜像标识(序号变动会导致重复)`);
}
ref.sysAccountName = SystemCalendarMirror.ACCOUNT_PREFIX + ref.bookId;
out.push(ref);
}
}
return out;
}
/** 按 bookId 选出要处理的本(顺序跟随 ids;同一 bookId 只会出现一次,避免同名账户) */
static async resolveByIds(context: common.Context, ids: string[]): Promise<BookRef[]> {
const all: BookRef[] = await SystemCalendarMirror.listBooks(context);
const out: BookRef[] = [];
const seen: Set<string> = new Set<string>();
for (const id of ids) {
if (seen.has(id)) {
continue;
}
for (const ref of all) {
if (ref.bookId === id) {
out.push(ref);
seen.add(id);
break;
}
}
}
return out;
}
/** 禁止镜像的 bookId(备份目标本) */
static async blockedBookIds(context: common.Context): Promise<string[]> {
try {
const check: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context);
if (check.ref !== undefined) {
return [check.ref.bookId];
}
} catch (err) {
// 读设置失败不阻断镜像
}
return [];
}
/* ==================== 系统日历备份目标(稳定标识 + 失效即关闭) ==================== */
/**
* ⭐ 解析「系统日历 → CalDAV」的备份目标本;解析不到就**直接关掉这个功能**。
*
* 背景(用户实测):老版本把目标存成 calKey(`accId_序号`)。用户在「编辑账号」里
* **取消勾选**某个日历本后,`calendarHrefs` 收缩、后面的本序号整体前移,
* 于是原 `accId_2` 悄悄指向了**另一个本** → 系统日历被导进用户根本没选过的本里。
* 这与镜像侧的"串本"是同一类 bug,修法也一样:改用由 href 派生的 bookId。
*
* 失效判据(任一成立即视为失效):
* - 存的 bookId 在**当前日历本集合**(= 各账号已勾选的本)里找不到
* → 该本被取消勾选、或账号/日历本被删除;
* - 老 calKey 值存在 → **不可信**(见下方 ①);
* - 模式为 backup 却根本没有目标 → 不一致状态。
*
* 失效的动作:清空备份目标;**若功能正开着则置回 `display`(= 关掉这个功能)**,并留日志。
* 这样就不会出现"开关显示备份中,实际却导进了别的本 / 或什么都没导"的假象。
*
* ⚠️ 目标本有效时返回 `ref`;用户**从未选择**过目标(且功能没开)时返回空且不关闭。
*/
static async checkBackupTarget(context: common.Context): Promise<BackupTargetCheck> {
const out: BackupTargetCheck = new BackupTargetCheck();
try {
const mode: string = await AppSettings.getSysCalMode(context);
const bookId: string = await AppSettings.getBackupBookId(context);
const legacy: string = await AppSettings.getBackupCalKey(context);
// ① 老值(calKey,形如 `accId_序号`)**一律不可信**:
// 从写下它到现在的这段时间里,序号有没有漂移过、漂移了几次,都无从判断;
// "乐观迁移"会把漂移后的另一个本**固化成新的目标** —— 等于把错误目标洗白。
// 所以直接作废,让用户重新显式选一次(一次性成本,换取永久消除静默写错本)。
if (bookId === '' && legacy !== '') {
await SystemCalendarMirror.invalidateBackupTarget(context, out, mode,
`旧版备份目标(${legacy})无法确认对应哪个日历本`);
return out;
}
// ② 从未配置过目标本
if (bookId === '') {
if (mode === 'backup') {
// 模式开着却没有目标 = 不一致状态(正常流程不允许出现)→ 一并关掉,别让它空转
await SystemCalendarMirror.invalidateBackupTarget(context, out, mode,
'未选择备份目标日历本');
}
return out;
}
// ③ 用稳定标识解析目标本;解析不到 = 该本被取消勾选 / 账号或本被删除
const all: BookRef[] = await SystemCalendarMirror.listBooks(context);
for (const r of all) {
if (r.bookId === bookId) {
out.ref = r;
return out;
}
}
await SystemCalendarMirror.invalidateBackupTarget(context, out, mode,
`目标日历本(${bookId})已被取消同步或删除`);
return out;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`解析系统日历备份目标失败: ${e.message}`);
return out;
}
}
/**
* 目标不可用时的统一处置:清掉目标 + 留日志;**若备份功能正开着,则一并关闭**。
*
* ⚠️ 抽出来是为了让所有调用方口径一致 —— 尤其是"只清目标、不关开关"这种半吊子处理:
* 那会留下"开关显示着备份中、实际什么都没导"的假象。
*
* @param mode 当前 sys_cal_mode;只有它本来就是 'backup' 时才算"自动关闭",才需要提示用户
*/
private static async invalidateBackupTarget(
context: common.Context,
out: BackupTargetCheck,
mode: string,
reason: string
): Promise<void> {
out.ref = undefined;
out.closed = (mode === 'backup');
out.reason = reason; // 无论功能是否开着都要带上原因,UI 才能告诉用户"为什么没了"
await AppSettings.clearBackupTarget(context);
if (out.closed) {
await AppSettings.setSysCalMode(context, 'display');
LogUtil.write(
`⚠️ 系统日历备份已自动关闭:${reason}。` +
`为避免把系统日程导进别的日历本,已把「系统日历 → CalDAV」重置为仅显示;` +
`如需继续备份,请在设置页重新选择目标日历本。`);
} else {
LogUtil.write(`已清除失效的备份目标设置:${reason}(备份功能本就未开启,无需关闭)`);
}
}
/** 只要目标本(不需要失效信息)时的便捷入口 */
static async backupTargetRef(context: common.Context): Promise<BookRef | undefined> {
const check: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context);
return check.ref;
}
/**
* ⭐ 读取"用户选中要镜像的日历本",返回稳定引用,并顺带完成历史迁移。
*
* 迁移:老版本把选中项存成 calKey(`accId_序号`)。序号一变,选中的本就串成另一个本,
* 系统日历账户名也跟着变 → 重复账户 + 串本。这里首次读到旧值时,
* 把当前 calKey 对应的 bookId 记下来并改用 bookId 存储,之后不再受序号影响。
*/
static async selectedBookRefs(context: common.Context): Promise<BookRef[]> {
let ids: string[] = await AppSettings.getMirrorBookIds(context);
if (ids.length === 0) {
const legacy: string[] = await AppSettings.getMirrorKeys(context);
if (legacy.length > 0) {
const refs: Map<string, BookRef> =
await SystemCalendarMirror.resolveBookRefs(context, legacy);
ids = [];
for (const k of legacy) {
const ref: BookRef | undefined = refs.get(k);
if (ref !== undefined) {
ids.push(ref.bookId);
}
}
await AppSettings.setMirrorBookIds(context, ids);
LogUtil.write(`镜像选中项已迁移为稳定标识:${legacy.join(',')} → ${ids.join(',')}`);
}
}
if (ids.length === 0) {
return [];
}
const blocked: string[] = await SystemCalendarMirror.blockedBookIds(context);
return await SystemCalendarMirror.resolveByIds(context, ids.filter((id: string): boolean =>
!blocked.includes(id)));
}
/** href → 短且稳定的字符串(避免 href 里的特殊字符进账户名) */
private static stableHash(s: string): string {
let h1: number = 0;
let h2: number = 5381;
for (let i: number = 0; i < s.length; i++) {
const c: number = s.charCodeAt(i);
h1 = ((h1 << 5) + h1 + c) | 0;
h2 = ((h2 << 5) + h2 + c) | 0;
}
return `${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
}
/**
* ⭐ 由 `<accId> + href` 算出稳定标识 bookId(= `<accId>-<href哈希>`)。
* 与 `listBooks()` 内部算法**完全一致**,供编辑账号页在"取消勾选某个日历本"时
* 立即算出它的 bookId 并删掉对应镜像账户用(此时账号配置已被改写,listBooks 已查不到它)。
* href 为空时退回 calKey(与 listBooks 的兜底口径一致)。
*/
static bookIdOf(accId: string, href: string, fallbackCalKey: string): string {
return href !== ''
? `${accId}-${SystemCalendarMirror.stableHash(href)}`
: fallbackCalKey;
}
/** 用账号信息补全日历本名与颜色(需要异步加载账号) */
static async loadBookLabels(context: common.Context, keys: string[]): Promise<BookInfo[]> {
const out: BookInfo[] = [];
let accounts: DavAccount[] = [];
try {
accounts = await AccountStore.loadAll(context);
} catch (err) {
accounts = [];
}
for (const calKey of keys) {
const info: BookInfo = SystemCalendarMirror.bookInfo(calKey);
for (const acc of accounts) {
if (acc.id === info.accId) {
if (info.index >= 0 && info.index < acc.calendarNames.length &&
acc.calendarNames[info.index] !== '') {
info.name = acc.calendarNames[info.index];
}
if (info.index >= 0 && info.index < acc.calendarColors.length &&
acc.calendarColors[info.index] !== '') {
info.color = acc.calendarColors[info.index];
}
info.accountName = acc.name;
break;
}
}
out.push(info);
}
return out;
}
}
/**
* 一次"发生"(写进系统日历的一条)。
* 重复日程会被展开成多条 MirrorOcc,每条对应一次发生。
*/
class MirrorOcc {
identifier: string = '';
title: string = '';
start: number = 0;
end: number = 0;
isAllDay: boolean = false;
src: LocalEvent = new LocalEvent();
}
/** 一个日历本的展示信息 */
export class BookInfo {
calKey: string = '';
accId: string = '';
index: number = -1;
name: string = '';
color: string = '#007DFF';
accountName: string = '';
}
/**
* 日历本的**稳定**引用(镜像专用)。
* calKey 会随序号变动,bookId 由服务器 href 派生、不会变 —— 系统日历账户名与
* Event.identifier 一律用 bookId,绝不用 calKey。
*/
export class BookRef {
calKey: string = ''; // 工程内部标识(会变,只用于查本地库)
bookId: string = ''; // ⭐ 稳定标识(`<accId>-<href哈希>`)—— 系统日历侧一律用它
sysAccountName: string = ''; // 系统日历账户名 = 前缀 + bookId
accId: string = '';
index: number = -1;
href: string = '';
name: string = '';
color: string = '#007DFF';
accountName: string = ''; // 所属 DAV 账号名(仅展示用)
}
/**
* 「系统日历备份目标」的检查结果(供 UI 与同步流程共用)。
*
* 只有三种状态:
* - `ref != undefined` → 目标有效,正常导入
* - `ref == undefined && !closed` → 用户**从未选择**过目标本(功能待配置,不算故障)
* - `ref == undefined && closed` → 目标本**原来存在、现在没了** → 已自动关闭备份功能
*/
export class BackupTargetCheck {
/** 解析到的目标日历本(稳定引用);undefined = 无可用目标 */
ref: BookRef | undefined = undefined;
/** 是否因目标失效而**自动关闭**了「备份到 CalDAV」(sys_cal_mode 置回 display) */
closed: boolean = false;
/** 自动关闭的原因(UI 提示用) */
reason: string = '';
}