2026-09-19 23:07:03 +08:00
|
|
|
|
// 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);
|
2026-10-09 08:47:50 +08:00
|
|
|
|
// ⭐ 兜底:无论有没有选中本,都顺手清掉"账号已删除"的孤儿镜像账户。
|
|
|
|
|
|
// 否则"删掉一个账号后系统日历里还留着它的日历本"永远不会被自动修掉
|
|
|
|
|
|
// (旧代码在这里 return 之前根本不做清理,这正是本次要修的 bug)。
|
|
|
|
|
|
try {
|
|
|
|
|
|
await SystemCalendarMirror.purgeOrphanAccountMirrors(context);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// 兜底清理失败不影响正常镜像
|
|
|
|
|
|
}
|
2026-09-19 23:07:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 08:47:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 删除**某一个 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 23:07:03 +08:00
|
|
|
|
/* ==================== 单个日历本 ==================== */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。
|
|
|
|
|
|
* 实际写入(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)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 08:47:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 由 `<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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 23:07:03 +08:00
|
|
|
|
/** 用账号信息补全日历本名与颜色(需要异步加载账号) */
|
|
|
|
|
|
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 = '';
|
|
|
|
|
|
}
|