// entry/src/main/ets/common/SystemCalendarMirror.ets // 出站镜像:把用户选中的 CalDAV 日历本写进「系统日历」中**本应用自己的日历账户**。 // // 目的:本应用是 CalDAV 客户端,日程不进系统日历,导致小艺 / 桌面日历卡片 / 手表看到的是空的 // (甚至给出"今天没有日程"的错误答案)。把选中的日历本镜像过去后,这些系统入口就能看到。 // // 设计要点: // 1. 每个选中的 DAV 日历本 → 系统日历中一个独立账户(displayName = "同步日历 · <日历本名>"), // 这样每个本能有自己的颜色,避免"多个本挤一个颜色"的问题。 // ✅ 2026-09-17 用户实测:**系统日历能显示应用写入的自定义颜色**(只有手动建本时才给固定色板)。 // 2. `Event.identifier` 存 `|`(重复系列再加 `@<发生时刻>`)作为映射键,用于幂等更新与删除。 // ⭐ 一律用稳定标识 **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 { 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 { 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, aggressive: boolean ): Promise { 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, 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 { if (!await SystemCalendarMirror.hasPermission()) { throw new Error('未获得系统日历读写权限'); } const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context); const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context); const valid: Set = new Set(); for (const ref of refs) { valid.add(ref.sysAccountName); } return SystemCalendarMirror.purgeOrphans(context, cm, valid, true); } /** 执行一次性镜像(幂等,可重复调用;**全量**,不设时间窗口) */ static async syncNow(context: common.Context): Promise { 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 = new Set(); 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 = 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); 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 { 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(), 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 { 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 { const refs: Map = 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 = `-` 天然带账号前缀。因此这里只按 * `synccalendar.mirror.-` 前缀匹配 —— 即使**不同账号下有同名日历本** * (比如两个账号各有一个「工作」),它们的 bookId 不同、账户名也不同,绝不会删错。 * * 幂等;未授权 / 无匹配时返回 0。每个账户独立 try/catch,一个失败不影响其它。 * * @returns 成功删除的镜像账户数 */ static async removeByAccount(context: common.Context, accId: string): Promise { if (accId === '' || !await SystemCalendarMirror.hasPermission()) { return 0; } // ⭐ 账户名前缀 = 公共前缀 + accId + '-'(bookId 里的分隔符)。 // 带尾随 '-' 才不会把 accId「123」错配到 accId「1234」的账户上。 // 同时兼容**历史**账户名(旧版用 calKey 做桶 → `synccalendar.mirror._<序号>`), // 同样带尾随 '_' 分隔符,宁可多清一批脏账户,也不按显示名乱删。 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 { 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 = new Set(); 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 形如 `-`;也兼容历史 `_<序号>` 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 { const calKey: string = ref.calKey; const all: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); res.totalRows += all.length; const seen: Set = new Set(); // RECURRENCE-ID 覆盖行(对某一次发生的单独修改):自己单独成条, // 同时让主事件在展开时跳过对应发生,避免同一次发生出现两条(口径同 CalendarDataService)。 const overrideKeys: Set = new Set(); 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 { // ⭐ 账户名与 identifier 一律用 **bookId**(由 href 派生、稳定),绝不用 calKey const cal: calendarManager.Calendar = await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref); // 现有镜像条目:identifier -> Event const existing: Map = new Map(); 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 = new Set(); 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, winStart: number, winEnd: number ): Promise { 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 = new Set(); 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 = new Map(); 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 = new Set(); 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 { 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 { 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 { 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> { const all: BookRef[] = await SystemCalendarMirror.listBooks(context); const out: Map = new Map(); 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 { 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 { const all: BookRef[] = await SystemCalendarMirror.listBooks(context); const out: BookRef[] = []; const seen: Set = new Set(); 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 { 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 { 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 { 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 { const check: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context); return check.ref; } /** * ⭐ 读取"用户选中要镜像的日历本",返回稳定引用,并顺带完成历史迁移。 * * 迁移:老版本把选中项存成 calKey(`accId_序号`)。序号一变,选中的本就串成另一个本, * 系统日历账户名也跟着变 → 重复账户 + 串本。这里首次读到旧值时, * 把当前 calKey 对应的 bookId 记下来并改用 bookId 存储,之后不再受序号影响。 */ static async selectedBookRefs(context: common.Context): Promise { 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 = 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)}`; } /** * ⭐ 由 ` + href` 算出稳定标识 bookId(= `-`)。 * 与 `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 { 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 = ''; // ⭐ 稳定标识(`-`)—— 系统日历侧一律用它 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 = ''; }