diff --git a/AppScope/app.json5 b/AppScope/app.json5 index 6169045..e5e64bb 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "synccalendar.yangyq.net", "vendor": "yangyq", - "versionCode": 200, - "versionName": "0.0.2", + "versionCode": 300, + "versionName": "0.0.3", "buildVersion": "1", "icon": "$media:layered_image", "label": "$string:app_name" diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index 9a4b39c..952637c 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -11,13 +11,22 @@ export class AppSettings { private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes'; private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync'; private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup' - private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本 + // ⚠️ 备份目标**必须**用稳定标识 bookId 存(由服务器 href 派生,见 SystemCalendarMirror.listBooks)。 + // 老键存的是 calKey(`accId_序号`),序号会随"取消勾选某个日历本/服务器新增日历本"整体漂移 + // → 目标本会**悄悄变成另一个本**,把系统日历导进用户没选过的本(用户实测发现)。 + // KEY_BACKUP_KEY 仅保留用于老值一次性迁移,读到后立即换存 KEY_BACKUP_BOOK_ID 并清空。 + private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 【已废弃】备份目标 calKey(仅迁移用) + private static readonly KEY_BACKUP_BOOK_ID: string = 'sys_backup_book_id'; // ⭐ 备份目标稳定标识 bookId private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list' + private static readonly KEY_MIRROR_ENABLED: string = 'mirror_enabled'; // 是否把选中 CalDAV 日历本镜像到系统日历(默认关) + private static readonly KEY_MIRROR_KEYS: string = 'mirror_cal_keys'; // 要镜像的 DAV 日历本 calKey 列表 + private static readonly KEY_MIRROR_BOOK_IDS: string = 'mirror_book_ids'; // ⭐ 要镜像的日历本**稳定标识** bookId(由服务器 href 派生,不随序号变) + private static readonly KEY_MIRROR_INBOUND: string = 'mirror_inbound'; // 是否把系统日历的改动回写 CalDAV(默认关) private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议 // 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。 // 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**—— @@ -149,6 +158,141 @@ export class AppSettings { } } + /** + * 是否把选中的 CalDAV 日历本镜像到系统日历(默认关)。 + * ⚠️ 该开关必须与 WRITE_CALENDAR 授权状态一致:未授权即为关(权限按需申请约束)。 + */ + static async getMirrorEnabled(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_MIRROR_ENABLED, false) as boolean; + } catch (err) { + return false; + } + } + + static async setMirrorEnabled(context: common.Context, value: boolean): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_MIRROR_ENABLED, value); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存镜像开关失败: ${e.message}`); + } + } + + /** + * ⚠️ **已废弃**:早期用 calKey 存"要镜像哪些本",但 calKey 是 `<账号ID>_<序号>`, + * 日历本重排后序号会变 → 选中的本串成另一个本、镜像账户名也变 → 系统日历出现重复账户与串本。 + * 新代码一律用 `getMirrorBookIds()`。这里只为读取历史值做迁移。 + */ + static async getMirrorKeys(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + const raw: string = await store.get(AppSettings.KEY_MIRROR_KEYS, '') as string; + if (raw === '') { + return []; + } + const arr: string[] = JSON.parse(raw) as string[]; + if (!Array.isArray(arr)) { + return []; + } + const out: string[] = []; + for (let i: number = 0; i < arr.length; i++) { + const v: string = arr[i]; + if (typeof v === 'string' && v !== '') { + out.push(v); + } + } + return out; + } catch (err) { + return []; + } + } + + static async setMirrorKeys(context: common.Context, keys: string[]): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_MIRROR_KEYS, JSON.stringify(keys)); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存镜像日历本失败: ${e.message}`); + } + } + + /** + * ⭐ 要镜像到系统日历的日历本**稳定标识** bookId 列表,空 = 未选择。 + * bookId = `<账号ID>-`,由服务器分配的 href 派生 —— 日历本增删改序都不会变。 + */ + static async getMirrorBookIds(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + const raw: string = await store.get(AppSettings.KEY_MIRROR_BOOK_IDS, '') as string; + if (raw === '') { + return []; + } + const arr: string[] = JSON.parse(raw) as string[]; + if (!Array.isArray(arr)) { + return []; + } + const out: string[] = []; + for (let i: number = 0; i < arr.length; i++) { + const v: string = arr[i]; + if (typeof v === 'string' && v !== '') { + out.push(v); + } + } + return out; + } catch (err) { + return []; + } + } + + static async setMirrorBookIds(context: common.Context, ids: string[]): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_MIRROR_BOOK_IDS, JSON.stringify(ids)); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存镜像日历本(bookId)失败: ${e.message}`); + } + } + + /** + * 是否把"用户在系统日历里的改动"回写到 CalDAV(**默认关**)。 + * ⚠️ 默认关是有意的:这个功能会写用户的服务器数据,首次上线必须先单向观察一轮再开。 + */ + static async getMirrorInbound(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_MIRROR_INBOUND, false) as boolean; + } catch (err) { + return false; + } + } + + static async setMirrorInbound(context: common.Context, value: boolean): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_MIRROR_INBOUND, value); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存回写开关失败: ${e.message}`); + } + } + /** * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 @@ -286,9 +430,14 @@ export class AppSettings { if (muted2.length !== muted.length) { await AppSettings.setMutedReminderKeys(context, muted2); } - const backupKey: string = await AppSettings.getBackupCalKey(context); - if (backupKey.startsWith(`${accId}_`)) { - await AppSettings.setBackupCalKey(context, ''); + // 备份目标指向该账号名下某个本 → 目标必失效:清空目标**并关闭备份功能** + // (只清空目标会让"备份到 CalDAV"开关停在开着却什么都不做的状态,状态与实际不符) + const bookId: string = await AppSettings.getBackupBookId(context); + const legacyKey: string = await AppSettings.getBackupCalKey(context); + if (bookId.startsWith(`${accId}-`) || legacyKey.startsWith(`${accId}_`)) { + await AppSettings.clearBackupTarget(context); + await AppSettings.setSysCalMode(context, 'display'); + console.log(`删除账号 ${accId}:其名下的系统日历备份目标已失效,备份功能自动关闭`); } } catch (err) { const e = err as BusinessError; @@ -319,7 +468,12 @@ export class AppSettings { } } - /** 备份目标日历本 calKey(accId_序号),空 = 未选择 */ + /** + * 【已废弃,仅供老值迁移】备份目标 calKey(`accId_序号`)。 + * ⚠️ 不要再用它做判断!序号会漂移(见 KEY_BACKUP_KEY 处注释)。 + * 新代码一律用 `SystemCalendarMirror.backupTargetRef()` / `checkBackupTarget()`, + * 它内部会把这个老值翻译成 bookId 并清空本键。 + */ static async getBackupCalKey(context: common.Context): Promise { try { const store: preferences.Preferences = @@ -330,18 +484,49 @@ export class AppSettings { } } + /** 【已废弃】写老键。新代码请用 setBackupBookId()。 */ static async setBackupCalKey(context: common.Context, calKey: string): Promise { try { const store: preferences.Preferences = await preferences.getPreferences(context, AppSettings.STORE); await store.put(AppSettings.KEY_BACKUP_KEY, calKey); await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存备份目标(老键)失败: ${e.message}`); + } + } + + /** ⭐ 备份目标日历本的稳定标识 bookId(`-`),空 = 未选择 */ + static async getBackupBookId(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_BACKUP_BOOK_ID, '') as string; + } catch (err) { + return ''; + } + } + + /** ⭐ 保存备份目标(bookId)。传空字符串 = 取消选择。 */ + static async setBackupBookId(context: common.Context, bookId: string): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_BACKUP_BOOK_ID, bookId); + await store.flush(); } catch (err) { const e = err as BusinessError; console.error(`保存备份目标失败: ${e.message}`); } } + /** 一次清空备份目标(新键 + 老键一起清,避免迁移残留又复活) */ + static async clearBackupTarget(context: common.Context): Promise { + await AppSettings.setBackupBookId(context, ''); + await AppSettings.setBackupCalKey(context, ''); + } + /** 是否开启后台持续同步(长时任务,默认关) */ static async getBackgroundSync(context: common.Context): Promise { try { diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets index 6505a0b..cc1cb1f 100644 --- a/entry/src/main/ets/common/CalendarDataService.ets +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -202,12 +202,14 @@ export class CalendarDataService { } // 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。 // 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本); - // ②只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留) + // ②键必须用 **recurrenceId(原始发生时刻)**——覆盖行的 startTime 是"被改到的新时间", + // 用它做键时主事件在原时间的那次发生不会被排除 → 同一日程显示两条(幽灵日程); + // ③只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留) // 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示" const overrideKeys: string[] = []; for (const e of rows) { if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) { - overrideKeys.push(`${e.calKey}_${e.uid}_${e.startTime}`); + overrideKeys.push(EventDb.overrideKey(e.calKey, e.uid, e.recurrenceId, e.startTime)); } } let occTotal: number = 0; @@ -232,7 +234,7 @@ export class CalendarDataService { const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500); if (occs.length > 0) { times = occs.filter((occ: number): boolean => - !overrideKeys.includes(`${e.calKey}_${e.uid}_${occ}`)); + !overrideKeys.includes(EventDb.overrideKey(e.calKey, e.uid, 0, occ))); } } for (const occ of times) { diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index d2f268c..8c78386 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -25,6 +25,10 @@ export class LocalEvent { completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED) rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生 exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔 + /** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。 + * 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻—— + * 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */ + recurrenceId: number = 0; reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒 reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序 } @@ -44,6 +48,7 @@ export class RemoteEvent { completed: boolean = false; // VTODO STATUS:COMPLETED rrule: string = ''; // 原始 RRULE exdate: string = ''; // 原始 EXDATE(分号分隔) + recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无) reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) reminders: number[] = []; // 全部提醒提前分钟数(多选) } @@ -51,6 +56,36 @@ export class RemoteEvent { export class EventDb { private static db: relationalStore.RdbStore | null = null; + /** + * ⭐ 统计"是覆盖实例(RECURRENCE-ID 行)但还没回填 recurrence_id"的行数。 + * + * 语义:`recurring=1 且 rrule=''` ⟺ 这一行的 .ics 里有 RECURRENCE-ID。 + * 老库升级后这些行的 recurrence_id 一律是 0(字段以前被丢弃,无法从本地反推), + * 必须靠一次全量重拉(GET 每个 .ics)才能填上。 + * **用数据本身当标志,而不是内存变量** —— 卡片进程/进程重启会把内存标志吃掉 + *(实测 2026-09-19 18:30/18:36 两次同步都没触发全量重拉)。 + * 回填完成后返回值自然变 0,不会反复触发。 + */ + static async countOverridesMissingRecurrenceId(context: common.Context): Promise { + try { + const store = await EventDb.getDb(context); + const rs = await store.querySql( + "select count(*) c from events " + + "where deleted = 0 and recurring = 1 and rrule = '' and recurrence_id = 0"); + let n: number = 0; + try { + if (rs.goToNextRow()) { + n = rs.getLong(rs.getColumnIndex('c')); + } + } finally { + rs.close(); + } + return n; + } catch (err) { + return 0; + } + } + static async getDb(context: common.Context): Promise { if (EventDb.db !== null) { return EventDb.db; @@ -104,6 +139,12 @@ export class EventDb { } catch (err) { // 列已存在 } + // ⭐ 覆盖实例的"原始发生时刻"(RECURRENCE-ID)。老库升级后该列一律为 0,需要一次全量重拉回填 + try { + await store.executeSql('ALTER TABLE events ADD COLUMN recurrence_id INTEGER DEFAULT 0'); + } catch (err) { + // 列已存在 + } EventDb.db = store; return store; } @@ -124,6 +165,18 @@ export class EventDb { return EventDb.normalizeReminders(list).join(','); } + /** + * ⭐ 覆盖实例(RECURRENCE-ID 独立行)"排除主事件哪一次发生"用的键。 + * + * 必须优先用 **recurrenceId**(原始发生时刻):覆盖行的 startTime 是**被改到的新时间**, + * 用它做键时主事件在**原时间**的那次发生不会被排除 → App 与系统日历里同一日程出现两条 + * (2026-09-19 真机实测:窗口内 63 条这种"幽灵日程")。 + * recurrenceId = 0 表示旧库数据(还没回填)→ 回退到 startTime,保持老行为不变。 + */ + static overrideKey(calKey: string, uid: string, recurrenceId: number, startTime: number): string { + return `${calKey}_${uid}_${recurrenceId > 0 ? recurrenceId : startTime}`; + } + private static fromRow(rs: relationalStore.ResultSet): LocalEvent { const e = new LocalEvent(); e.id = rs.getLong(rs.getColumnIndex('id')); @@ -146,6 +199,7 @@ export class EventDb { e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1; e.rrule = rs.getString(rs.getColumnIndex('rrule')); e.exdate = rs.getString(rs.getColumnIndex('exdate')); + e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id')); e.reminder = rs.getLong(rs.getColumnIndex('reminder')); // 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒 const remStr: string = rs.getString(rs.getColumnIndex('reminders')); @@ -178,6 +232,7 @@ export class EventDb { 'completed': e.completed ? 1 : 0, 'rrule': e.rrule, 'exdate': e.exdate, + 'recurrence_id': e.recurrenceId, 'reminder': e.reminders.length > 0 ? e.reminders[0] : 0, 'reminders': e.reminders.map((n: number): string => String(n)).join(',') }; @@ -371,12 +426,22 @@ export class EventDb { } const remoteKeys: string[] = []; + // ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。 + // 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行, + // 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。 + const seenKeys: Set = new Set(); for (const r of remote) { - // 重复日程实例共享 UID,唯一标识 = uid + 开始时间 - const key: string = `${r.uid}_${r.startTime}`; + // 重复日程实例共享 UID,唯一标识 = uid + 开始时间 + RECURRENCE-ID + //(同一时刻可能同时存在"主事件"与"覆盖该次发生的实例行",只带上 recurrenceId 才唯一) + const key: string = `${r.uid}_${r.startTime}_${r.recurrenceId}`; + if (seenKeys.has(key)) { + continue; // 同一资源内的重复条目 → 只保留一条 + } + seenKeys.add(key); remoteKeys.push(key); const found = existing.find((x: LocalEvent): boolean => - !x.dirty && x.uid === r.uid && x.startTime === r.startTime); + !x.dirty && x.uid === r.uid && x.startTime === r.startTime + && x.recurrenceId === r.recurrenceId); if (found === undefined) { // 新增 const e = new LocalEvent(); @@ -398,6 +463,7 @@ export class EventDb { e.completed = r.completed; e.rrule = r.rrule; e.exdate = r.exdate; + e.recurrenceId = r.recurrenceId; e.reminder = r.reminder; e.reminders = r.reminders; await store.insert('events', EventDb.toBucket(e)); @@ -420,6 +486,7 @@ export class EventDb { found.completed = r.completed; found.rrule = r.rrule; found.exdate = r.exdate; + found.recurrenceId = r.recurrenceId; found.reminder = r.reminder; found.reminders = r.reminders; const up = new relationalStore.RdbPredicates('events'); @@ -435,7 +502,7 @@ export class EventDb { if (local.dirty) { continue; } - const localKey: string = `${local.uid}_${local.startTime}`; + const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`; if (!remoteKeys.includes(localKey)) { const del = new relationalStore.RdbPredicates('events'); del.equalTo('id', local.id); @@ -446,11 +513,72 @@ export class EventDb { return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`; } - /** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行) */ + /** + * 清理"同一资源"的冗余重复行(**纯本地去重,绝不触碰服务器**)。 + * + * 起因:旧版 applyRemote 用循环前的一次性 existing 快照判重,同一个 .ics 里若含 + * 多条相同 (UID, DTSTART) 的 VEVENT,第二条仍被判为"新增"再插一行 → + * 本地库出现 href / etag / remote_path 完全相同的重复行(2026-09-19 实测 10 组)。 + * 影响:① App 里同一日程显示两条;② 镜像到系统日历后同一 identifier 互相覆盖, + * 出现"计划 984 条、系统只写出 983 条"的口径差。 + * + * 口径:同 (cal_key, uid, start_time, recurrence_id) 且在**同一资源**(href 非空)下, + * 只保留 id 最小的一行。 + * 含本地未推送修改(dirty=1)的行整个不参与,绝不会连带删掉待推送数据。 + * 被删行**不置 dirty/deleted**,因此不会向服务器推送任何删除。 + * ⚠️ 分组必须带上 recurrence_id:主事件与"覆盖该次发生的实例行"可能同 uid 同 start_time, + * 它们是两条**合法**的不同发生,不能被当成冗余行删掉。 + * @returns 删除的冗余行数 + */ + static async dedupeDuplicateRows(context: common.Context): Promise { + let removed: number = 0; + try { + const store = await EventDb.getDb(context); + // 只处理"远端资源、已删标记为 0、且没有本地未推送修改(dirty=0)"的行。 + // 某个组里只要有一行是 dirty,它就不参与分组 → 组内只剩 1 行 → count(*)>1 不成立 → 不动。 + // 这样绝不会把"待推送的本地修改"连带删掉。 + const rs = await store.querySql( + "select group_concat(id) ids from events " + + "where deleted = 0 and dirty = 0 and href <> '' " + + "group by cal_key, uid, start_time, recurrence_id having count(*) > 1"); + const groups: string[] = []; + try { + while (rs.goToNextRow()) { + const v: string = rs.getString(rs.getColumnIndex('ids')); + if (v !== undefined && v !== null && v !== '') { + groups.push(v); + } + } + } finally { + rs.close(); + } + for (const g of groups) { + const ids: number[] = g.split(',') + .map((s: string): number => Number(s.trim())) + .filter((n: number): boolean => !Number.isNaN(n)); + ids.sort((a: number, b: number): number => a - b); + for (let i: number = 1; i < ids.length; i++) { // 0 号(最小 id)保留 + const del = new relationalStore.RdbPredicates('events'); + del.equalTo('id', ids[i]); + await store.delete(del); + removed++; + } + } + } catch (err) { + // 去重失败不影响主流程 + return removed; + } + return removed; + } + + /** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行) + * ⭐ 固定按 id 升序:镜像/展示遇到"同 uid 多行"时谁先谁后必须稳定, + * 否则同一 identifier 每次换一行 → 系统日历在两处之间反复翻转(实测过)。 */ static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise { const store = await EventDb.getDb(context); const predicates = new relationalStore.RdbPredicates('events'); predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind); + predicates.orderByAsc('id'); const rs = await store.query(predicates); const list: LocalEvent[] = []; try { diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 1abb681..8464d1b 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -16,6 +16,7 @@ class ParsedEvent { inAlarm: boolean = false; // 是否处于 VALARM 子组件内(内部属性不参与解析) rrule: string = ''; // 原始 RRULE 值 exdates: string[] = []; // 原始 EXDATE 值列表 + recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒)——本行覆盖的是主事件的哪一次发生 reminder: number = 0; // 主提醒提前分钟数(第一个 VALARM,0 = 不提醒) reminders: number[] = []; // 全部提醒提前分钟数(多 VALARM) } @@ -102,6 +103,16 @@ export class IcsUtil { current.recurring = true; if (propName === 'RRULE') { current.rrule = value; + } else { + // ⭐ 必须**保留 RECURRENCE-ID 的值**:它指出本行覆盖的是主事件哪一次发生。 + // 覆盖行的 DTSTART 是"被改到的新时间",只改标题时两者才相同; + // 一旦把某次发生改期,用 startTime 排除主事件那次发生就会漏掉 → + // App 与系统日历里同一日程出现两条(2026-09-19 真机事故)。 + const recDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE'); + const rt = IcsUtil.parseTime(value, recDateOnly); + if (rt !== null) { + current.recurrenceId = rt.time; + } } } else if (propName === 'EXDATE') { const exValues: string[] = value.split(','); @@ -140,6 +151,7 @@ export class IcsUtil { r.recurring = p.recurring; r.rrule = p.rrule; r.exdate = p.exdates.join(';'); + r.recurrenceId = p.recurrenceId; r.reminder = p.reminder; r.reminders = p.reminders; return r; diff --git a/entry/src/main/ets/common/MirrorInbound.ets b/entry/src/main/ets/common/MirrorInbound.ets new file mode 100644 index 0000000..f9ed03f --- /dev/null +++ b/entry/src/main/ets/common/MirrorInbound.ets @@ -0,0 +1,328 @@ +// entry/src/main/ets/common/MirrorInbound.ets +// 入站对账:把"用户在系统日历里做的改动"读回来,写回本地库并置 dirty,随下一次同步推送到 CalDAV。 +// +// 这是「系统日历 ↔ 同步日历 App ↔ CalDAV 服务器」闭环的**回程**。 +// 出站(CalDAV → 系统日历)在 SystemCalendarMirror,本文件负责反向。 +// +// ⚠️ 防回灌是这里唯一的核心难题:系统日历里既有"我们写进去的",也有"用户改的/新建的/删的", +// 靠 `MirrorSnapshot`(上次写入值的内容指纹)区分: +// +// | 系统日历当前状态 | 判定 | 动作 | +// |-----------------------------------|------------------|---------------------------| +// | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 | +// | 有 identifier,指纹 != 快照 | 用户改了 | 写回本地库 → 置 dirty 推送 | +// | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) | +// | 快照有,系统里没了 | 用户删了 | markDeleted → 推送 DELETE | +// | identifier 不是我们的格式 | 用户在系统日历新建 | 新建 LocalEvent → 推送 | +// +// ⚠️ "没有快照就补快照后跳过"这条非常关键:首次启用回写时快照是空的, +// 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。 + +import { common } from '@kit.AbilityKit'; +import { calendarManager } from '@kit.CalendarKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { EventDb, LocalEvent } from './EventDb'; +import { LogUtil } from './LogUtil'; +import { AppSettings } from './AppSettings'; +import { MirrorSnapshot, MirrorSnapshotRow } from './MirrorSnapshot'; +import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror'; + +/** 一次入站对账的统计 */ +export class InboundResult { + created: number = 0; // 系统日历新建 → 加进 CalDAV + updated: number = 0; // 系统日历修改 → 回写 CalDAV + deleted: number = 0; // 系统日历删除 → 从 CalDAV 删 + skipped: number = 0; + failed: number = 0; +} + +export class MirrorInbound { + private static readonly DAY: number = 86400000; + private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [ + 'id', 'type', 'title', 'startTime', 'endTime', 'isAllDay', + 'description', 'location', 'identifier', 'reminderTime', 'recurrenceRule' + ]; + + /** + * 入站对账主入口。 + * ⚠️ 必须在 `SyncEngine.syncAccount()` **之前**调用 —— 它产生的 dirty 行要借本次同步一起推送。 + */ + static async reconcile(context: common.Context): Promise { + const res: InboundResult = new InboundResult(); + try { + const on: boolean = await AppSettings.getMirrorInbound(context); + if (!on) { + return res; + } + if (!await SystemCalendarMirror.hasPermission()) { + return res; + } + const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context); + // ⭐ 与出站镜像用**同一套**稳定引用(bookId 由服务器 href 派生),否则两边对不上号 + const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context); + for (const ref of refs) { + try { + await MirrorInbound.reconcileBook(context, cm, ref, res); + } catch (err) { + const e = err as BusinessError; + res.failed++; + LogUtil.write(`入站对账失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`); + } + } + if (res.created + res.updated + res.deleted > 0) { + LogUtil.write( + `入站对账:新建=${res.created} 修改=${res.updated} 删除=${res.deleted} 跳过=${res.skipped}`); + } + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`入站对账异常: ${e.message}`); + } + return res; + } + + /* ==================== 单个日历本 ==================== */ + + private static async reconcileBook( + context: common.Context, + cm: calendarManager.CalendarManager, + ref: BookRef, + res: InboundResult + ): Promise { + const calKey: string = ref.calKey; // 仅用于查本地库 + const bucket: string = ref.bookId; // ⭐ 系统日历侧的一切标识都用 bookId + const accountName: string = ref.sysAccountName; + let cal: calendarManager.Calendar | undefined = undefined; + const calendars: calendarManager.Calendar[] = await cm.getAllCalendars(); + for (const c of calendars) { + if (c.getAccount().name === accountName) { + cal = c; + break; + } + } + if (cal === undefined) { + return; // 还没镜像过,无从对账 + } + const href: string = ref.href; + if (href === '') { + LogUtil.write(`入站对账跳过 bookId=${bucket}:找不到对应的日历本集合 URL`); + return; + } + + const snaps: Map = await MirrorSnapshot.loadMap(context, bucket); + const events: calendarManager.Event[] = await cal.getEvents( + undefined, MirrorInbound.QUERY_FIELDS); + + // 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建) + const mine: Map = new Map(); + const others: calendarManager.Event[] = []; + for (const ev of events) { + const id: string = ev.identifier ?? ''; + if (id !== '' && id.startsWith(`${bucket}|`)) { + mine.set(id, ev); + } else { + others.push(ev); + } + } + + // 本地库现有事件:uid -> LocalEvent + const locals: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); + const byUid: Map = new Map(); + for (const e of locals) { + if (e.uid !== '') { + byUid.set(e.uid, e); + } + } + + // ① 修改 + for (const id of mine.keys()) { + const ev: calendarManager.Event | undefined = mine.get(id); + if (ev === undefined) { + continue; + } + const fp: string = MirrorSnapshot.fingerprintOf(ev); + const snap: MirrorSnapshotRow | undefined = snaps.get(id); + if (snap === undefined) { + // ⚠️ 没有快照:认作是我们自己写的,补一份后跳过 —— 绝不误判成"用户改了" + await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp)); + res.skipped++; + continue; + } + snaps.delete(id); // 还活着,稍后剩下的才是"被删了" + if (snap.fingerprint === fp) { + res.skipped++; // 没动 + continue; + } + // ⚠️ 重复日程的**某一次发生**(identifier 形如 `|@<发生时间戳>`): + // 它是我们从一条重复日程展开出来的,不是独立事件 —— 改它没法映射回服务器上的 + // 那一条 VEVENT,直接跳过,避免把服务器数据搞乱。 + if (MirrorInbound.isOccurrence(id)) { + res.skipped++; + await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp)); + continue; + } + const uid: string = MirrorSnapshot.uidOf(id); + const local: LocalEvent | undefined = byUid.get(uid); + if (local === undefined) { + res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站镜像处理 + continue; + } + if (local.dirty || local.deleted) { + res.skipped++; // 本地本来就有待推送的改动 → 不拿系统值覆盖,避免两边打架 + continue; + } + MirrorInbound.applyToLocal(local, ev); + await EventDb.updateLocal(context, local); + await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp)); + res.updated++; + LogUtil.write(`入站修改回写:${id}(${local.title})`); + } + + // ② 删除:快照里剩下的(系统日历里已经没有了) + for (const id of snaps.keys()) { + const snap: MirrorSnapshotRow | undefined = snaps.get(id); + if (snap === undefined) { + continue; + } + // 同上:展开出来的"某一次发生"消失(多半是滑出镜像窗口),不能当成用户删除 + if (MirrorInbound.isOccurrence(id)) { + res.skipped++; + await MirrorSnapshot.remove(context, id); + continue; + } + const local: LocalEvent | undefined = byUid.get(snap.uid); + if (local !== undefined && !local.deleted) { + await EventDb.markDeleted(context, local.id); + res.deleted++; + LogUtil.write(`入站删除回写:${id}(${local.title})`); + } else { + res.skipped++; + } + await MirrorSnapshot.remove(context, id); + } + + // ③ 新建:系统日历里 identifier 不是我们格式的条目(用户在系统日历里手动加的) + for (const ev of others) { + if (ev.id === undefined) { + continue; + } + const uid: string = `sc-${bucket}-${ev.id}`; + if (byUid.has(uid)) { + continue; + } + // ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。 + // 否则下一次出站镜像会把同一条再加一遍 → 系统日历里出现重复。 + const identifier: string = `${bucket}|${uid}`; + const stamped: calendarManager.Event = ev; + stamped.identifier = identifier; + try { + await cal.updateEvent(stamped); + } catch (err) { + const e = err as BusinessError; + res.skipped++; + LogUtil.write(`入站新建跳过:无法写回 identifier(${e.message}),避免重复`); + continue; + } + const e: LocalEvent = new LocalEvent(); + e.uid = uid; + e.calKey = calKey; + e.href = href; + e.remotePath = encodeURIComponent(uid) + '.ics'; + e.kind = 'event'; + MirrorInbound.applyToLocal(e, ev); + await EventDb.insertLocal(context, e); + await MirrorSnapshot.put(context, MirrorInbound.rowOf( + bucket, identifier, ev, MirrorSnapshot.fingerprintOf(ev))); + res.created++; + LogUtil.write(`入站新建回写:${identifier}(${e.title})`); + } + } + + /* ==================== 辅助 ==================== */ + + /** + * 是不是"重复系列里的某一次发生"?identifier 形如 `|@<发生时间戳>`。 + * + * ⚠️ 不能用 `includes('@')` 判断:**uid 本身就常含 @**(如 `20260906T163329-d13e0cb6@172.17.0.1`), + * 会误判。只有"最后一段是纯数字"才是我们加的发生时间戳。 + */ + private static isOccurrence(id: string): boolean { + const pos: number = id.lastIndexOf('@'); + if (pos < 0) { + return false; + } + const tail: string = id.substring(pos + 1); + if (tail === '') { + return false; + } + for (let i: number = 0; i < tail.length; i++) { + const c: string = tail[i]; + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + private static rowOf( + bucket: string, + identifier: string, + ev: calendarManager.Event, + fp: string + ): MirrorSnapshotRow { + const row: MirrorSnapshotRow = new MirrorSnapshotRow(); + row.identifier = identifier; + row.calKey = bucket; + row.uid = MirrorSnapshot.uidOf(identifier); + row.sysId = ev.id ?? -1; + row.fingerprint = fp; + return row; + } + + /** 系统日历 Event → 本工程 LocalEvent(只覆盖可映射字段) */ + private static applyToLocal(e: LocalEvent, ev: calendarManager.Event): void { + e.title = ev.title ?? ''; + e.description = ev.description ?? ''; + e.location = ev.location?.location ?? ''; + e.startTime = ev.startTime; + e.isAllDay = ev.isAllDay === true; + // 系统全天 endTime 可能是"次日 00:00",也可能是"当日 23:59:59.999" + // → 统一成本工程约定:"排他结束日前一毫秒" + e.endTime = e.isAllDay + ? MirrorInbound.dayStart(ev.endTime - 1) + MirrorInbound.DAY - 1 + : ev.endTime; + e.rrule = MirrorInbound.rruleOf(ev); + e.recurring = e.rrule !== ''; + if (ev.reminderTime !== undefined && ev.reminderTime.length > 0) { + e.reminders = [...ev.reminderTime]; + e.reminder = ev.reminderTime[0]; + } + } + + private static dayStart(ts: number): number { + const d: Date = new Date(ts); + d.setHours(0, 0, 0, 0); + return d.getTime(); + } + + /** 系统 RecurrenceRule → RRULE 字符串(仅 FREQ/INTERVAL;复杂规则以服务器端为准) */ + private static rruleOf(ev: calendarManager.Event): string { + try { + const rr = ev.recurrenceRule; + if (rr === undefined) { + return ''; + } + const freqMap: string[] = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY']; + const fi: number = rr.recurrenceFrequency as number; + if (Number.isNaN(fi) || fi < 0 || fi >= freqMap.length) { + return ''; + } + let rrule: string = `FREQ=${freqMap[fi]}`; + if (rr.interval !== undefined && rr.interval > 1) { + rrule += `;INTERVAL=${rr.interval}`; + } + return rrule; + } catch (err) { + return ''; + } + } +} diff --git a/entry/src/main/ets/common/MirrorSnapshot.ets b/entry/src/main/ets/common/MirrorSnapshot.ets new file mode 100644 index 0000000..8ff21a5 --- /dev/null +++ b/entry/src/main/ets/common/MirrorSnapshot.ets @@ -0,0 +1,241 @@ +// entry/src/main/ets/common/MirrorSnapshot.ets +// 镜像快照:记录"上一次我们把什么写进了系统日历"。 +// +// 为什么必须有它(防回灌 / 防死循环): +// 流程是 CalDAV → 本地库 → 写进系统日历。下一轮同步如果直接读系统日历当"用户改动", +// 就会把我们刚写进去的当成用户改的 → 回写服务器 → 服务器变了 → 又镜像回系统日历 …… 死循环。 +// 有了快照就能三方比对: +// · 系统当前值 == 快照 → 没动,是我们自己写的,跳过 +// · 系统当前值 != 快照 → 用户在系统日历改了 → 回写 CalDAV +// · 快照有、系统里没了 → 用户在系统日历删了 → 从 CalDAV 删 +// · 系统里有、快照没有且 identifier 不是我们的格式 → 用户在系统日历新建 → 加进 CalDAV +// +// ⚠️ 只存"状态指纹",**不存日程内容**(内容仍在系统日历与本地库各一份,这是阶段 2 的取舍)。 +// 日程内容只有一份的形态(阶段 3)需要配合"影子表 + 系统日历为内容源",那是另一件事。 + +import { common } from '@kit.AbilityKit'; +import { relationalStore } from '@kit.ArkData'; +import { calendarManager } from '@kit.CalendarKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { LogUtil } from './LogUtil'; + +/** + * 快照行。 + * ⚠️ `calKey` 这一列实际存的是**镜像桶标识 = bookId**(由服务器 href 派生的稳定标识), + * 与系统日历侧 identifier 前缀、账户名后缀完全一致。列名沿用未改,别再传会变的 calKey。 + */ +export class MirrorSnapshotRow { + identifier: string = ''; // `|` + calKey: string = ''; + uid: string = ''; + sysId: number = -1; // 系统日历里的 event id(删除时用) + fingerprint: string = ''; // 写入值的内容指纹 +} + +export class MirrorSnapshot { + private static readonly TABLE: string = 'mirror_snapshot'; + private static ready: boolean = false; + + /* ==================== 表 ==================== */ + + private static async db(context: common.Context): Promise { + const store: relationalStore.RdbStore = await relationalStore.getRdbStore(context, { + name: 'sync_calendar.db', + securityLevel: relationalStore.SecurityLevel.S1 + }); + if (!MirrorSnapshot.ready) { + await store.executeSql( + 'CREATE TABLE IF NOT EXISTS mirror_snapshot (' + + 'identifier TEXT PRIMARY KEY, cal_key TEXT, uid TEXT, ' + + 'sys_id INTEGER, fingerprint TEXT, updated_at INTEGER)' + ); + MirrorSnapshot.ready = true; + } + return store; + } + + /* ==================== 读写 ==================== */ + + /** 取某个日历本的全部快照,key = identifier。**bucket 必须是 bookId** */ + static async loadMap(context: common.Context, bucket: string): Promise> { + const out: Map = new Map(); + try { + const store: relationalStore.RdbStore = await MirrorSnapshot.db(context); + const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE); + predicates.equalTo('cal_key', bucket); + const rs = await store.query(predicates); + try { + while (rs.goToNextRow()) { + const row: MirrorSnapshotRow = new MirrorSnapshotRow(); + row.identifier = rs.getString(rs.getColumnIndex('identifier')); + row.calKey = bucket; + row.uid = rs.getString(rs.getColumnIndex('uid')); + row.sysId = rs.getLong(rs.getColumnIndex('sys_id')); + row.fingerprint = rs.getString(rs.getColumnIndex('fingerprint')); + out.set(row.identifier, row); + } + } finally { + rs.close(); + } + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`读取镜像快照失败 bucket=${bucket}: ${e.message}`); + } + return out; + } + + /** + * **整体重建**某个日历本的快照:先删光旧行,再写入新行。 + * 为什么不用增量:出站镜像后直接把系统日历里该账户的实际内容重新读一遍再落快照最稳, + * 不用在写入过程中追踪 addEvent 返回的 id(批量 addEvents 还不返回 id)。 + */ + static async replaceAll( + context: common.Context, + bucket: string, + events: calendarManager.Event[] + ): Promise { + let n: number = 0; + try { + const store: relationalStore.RdbStore = await MirrorSnapshot.db(context); + const del = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE); + del.equalTo('cal_key', bucket); + await store.delete(del); + const now: number = Date.now(); + for (const ev of events) { + const id: string = ev.identifier ?? ''; + if (id === '') { + continue; // 用户在系统日历自己加的、没有我们 identifier 的 → 不进快照(由入站对账新建) + } + const vb: relationalStore.ValuesBucket = { + 'identifier': id, + 'cal_key': bucket, + 'uid': MirrorSnapshot.uidOf(id), + 'sys_id': ev.id ?? -1, + 'fingerprint': MirrorSnapshot.fingerprintOf(ev), + 'updated_at': now + }; + await store.insert(MirrorSnapshot.TABLE, vb); + n++; + } + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`重建镜像快照失败 bucket=${bucket}: ${e.message}`); + } + return n; + } + + /** 写入/更新单条快照(入站回写成功后同步指纹,避免下次重复判定为"用户改了") */ + static async put(context: common.Context, row: MirrorSnapshotRow): Promise { + try { + const store: relationalStore.RdbStore = await MirrorSnapshot.db(context); + const bucket: relationalStore.ValuesBucket = { + 'identifier': row.identifier, + 'cal_key': row.calKey, + 'uid': row.uid, + 'sys_id': row.sysId, + 'fingerprint': row.fingerprint, + 'updated_at': Date.now() + }; + await store.insert(MirrorSnapshot.TABLE, bucket); + } catch (err) { + // 主键冲突时改成 update + try { + const store: relationalStore.RdbStore = await MirrorSnapshot.db(context); + const bucket: relationalStore.ValuesBucket = { + 'sys_id': row.sysId, + 'fingerprint': row.fingerprint, + 'updated_at': Date.now() + }; + const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE); + predicates.equalTo('identifier', row.identifier); + await store.update(bucket, predicates); + } catch (err2) { + const e = err2 as BusinessError; + LogUtil.write(`写入镜像快照失败 ${row.identifier}: ${e.message}`); + } + } + } + + /** 删除单条(用户删了 / 本地已不存在) */ + static async remove(context: common.Context, identifier: string): Promise { + try { + const store: relationalStore.RdbStore = await MirrorSnapshot.db(context); + const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE); + predicates.equalTo('identifier', identifier); + await store.delete(predicates); + } catch (err) { + // 忽略 + } + } + + /** 删除整个日历本的快照(取消勾选 / 关闭镜像时调用)。bucket = bookId */ + static async removeByCalKey(context: common.Context, bucket: string): Promise { + try { + const store: relationalStore.RdbStore = await MirrorSnapshot.db(context); + const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE); + predicates.equalTo('cal_key', bucket); + await store.delete(predicates); + } catch (err) { + // 忽略 + } + } + + /** 清空全部快照(关闭镜像总开关时用,顺带清掉历史遗留的 calKey 键) */ + static async clearAll(context: common.Context): Promise { + try { + const store: relationalStore.RdbStore = await MirrorSnapshot.db(context); + const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE); + await store.delete(predicates); + } catch (err) { + // 忽略 + } + } + + /* ==================== 指纹 ==================== */ + + /** + * 事件内容指纹。用于判断"系统日历里这条,是不是还是我们上次写进去的样子"。 + * ⚠️ 全天日程的 endTime 会被系统归一化(我们写次日 00:00,可能读回 23:59:59.999), + * 所以全天一律折成"日"再算指纹,否则每次都判定成"用户改了"。 + */ + static fingerprintOf(ev: calendarManager.Event): string { + const allDay: boolean = ev.isAllDay === true; + let start: number = ev.startTime; + let end: number = ev.endTime; + if (allDay) { + start = MirrorSnapshot.dayStart(start); + end = MirrorSnapshot.dayStart(end - 1); + } + const raw: string = [ + ev.title ?? '', + `${start}`, + `${end}`, + allDay ? '1' : '0', + ev.location?.location ?? '', + ev.description ?? '', + MirrorSnapshot.hash(ev.recurrenceRule !== undefined ? JSON.stringify(ev.recurrenceRule) : '') + ].join('\u0001'); + return `${MirrorSnapshot.hash(raw)}:${MirrorSnapshot.hash(raw, 5381)}`; + } + + /** identifier (`|`) → uid */ + static uidOf(identifier: string): string { + const pos: number = identifier.indexOf('|'); + return pos < 0 ? '' : identifier.substring(pos + 1); + } + + private static dayStart(ts: number): number { + const d: Date = new Date(ts); + d.setHours(0, 0, 0, 0); + return d.getTime(); + } + + /** 简单字符串哈希(djb2 变体)。两个不同种子各算一次拼起来,降低碰撞概率 */ + private static hash(s: string, seed?: number): string { + let h: number = seed === undefined ? 0 : seed; + for (let i: number = 0; i < s.length; i++) { + h = ((h << 5) + h + s.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); + } +} diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index 2bb859a..cfbb6bc 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -46,6 +46,18 @@ export class SyncEngine { // 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位 LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`); try { + // ⭐ 库结构升级(recurrence_id 列):老库里覆盖实例的该列全是 0,而被丢弃的 + // RECURRENCE-ID 无法从本地任何字段反推 → 必须靠一次全量重拉(GET 每个 .ics)回填。 + // 不回填的后果:把某次发生改期的覆盖实例无法排除主事件在原时间的那次发生, + // App 与系统日历里同一日程会显示两条(幽灵日程)。 + // ⚠️ 判据必须是**数据库里的真实状态**:内存标志会被卡片进程/进程重启吃掉 + // (2026-09-19 实测两次同步都没触发)。回填完成后计数自然归 0,不会反复重拉。 + await EventDb.getDb(context); + const ridMissing: number = await EventDb.countOverridesMissingRecurrenceId(context); + if (ridMissing > 0) { + LogUtil.write(`recurrence_id 待回填 ${ridMissing} 条覆盖实例 → 重置"一次性全量重拉"`); + await AppSettings.resetFullRefetch(context); + } // 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟 const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context); const timeoutMs: number = fullRefetch ? 600000 : 300000; @@ -280,6 +292,7 @@ export class SyncEngine { r.completed = e.completed; r.rrule = e.rrule; r.exdate = e.exdate; + r.recurrenceId = e.recurrenceId; r.reminder = e.reminder; r.reminders = e.reminders.slice(); return r; @@ -345,6 +358,16 @@ export class SyncEngine { await EventDb.clearDirty(context, e.id, ''); } } + // ⭐ 顺带清理"同一资源被重复插入"的冗余行(旧版 applyRemote 判重缺陷遗留的脏数据)。 + // 纯本地去重、不推服务器;放在这里是因为它是每次同步都会经过的收尾步骤。 + try { + const dup: number = await EventDb.dedupeDuplicateRows(context); + if (dup > 0) { + LogUtil.write(`清理本地重复行:${dup} 条(同一资源被插了多次)`); + } + } catch (err) { + // 忽略:去重失败不影响同步 + } } /** diff --git a/entry/src/main/ets/common/SystemCalendarImport.ets b/entry/src/main/ets/common/SystemCalendarImport.ets index 2ac612c..d2d55ef 100644 --- a/entry/src/main/ets/common/SystemCalendarImport.ets +++ b/entry/src/main/ets/common/SystemCalendarImport.ets @@ -5,10 +5,10 @@ import { common } from '@kit.AbilityKit'; import { calendarManager } from '@kit.CalendarKit'; import { BusinessError } from '@kit.BasicServicesKit'; -import { AccountStore, DavAccount } from './AccountStore'; import { EventDb, LocalEvent } from './EventDb'; import { AppSettings } from './AppSettings'; import { LogUtil } from './LogUtil'; +import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror'; export class SystemCalendarImport { /** @@ -16,6 +16,15 @@ export class SystemCalendarImport { * 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。 * 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程), * 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。 + * + * ⚠️ 另有两层防重复(09-18 补): + * 1. **跳过本应用镜像出去的账户**(`synccalendar.mirror.*`)——它们的 type 也是 LOCAL, + * 但内容是我们从 CalDAV 写进去的,再导进备份本会让同一条日程在服务器出现两份。 + * 2. 导进备份本的 uid 统一带 `syscal-` 前缀,出站镜像会跳过该前缀 → 备份本不会被镜像回系统日历。 + * + * ⚠️ 目标本用**稳定标识 bookId** 解析(`SystemCalendarMirror.backupTargetRef`): + * 用户「取消勾选」目标本后序号会整体前移,老版本按 `accId_序号` 定位会**导进另一个本**。 + * 现在解析不到即视为失效,并把「备份到 CalDAV」模式**自动关回仅显示**。 */ static async importIfNeeded(context: common.Context): Promise { try { @@ -23,30 +32,42 @@ export class SystemCalendarImport { if (mode !== 'backup') { return 0; } - const calKey: string = await AppSettings.getBackupCalKey(context); - if (calKey === '') { - LogUtil.write('系统日历备份:未选择目标日历本,跳过导入'); + // ⭐ 目标本一律走 `backupTargetRef()`:内部用**稳定标识 bookId** 解析, + // 并兼做「老 calKey 值 → bookId」的一次性迁移。 + // 解析不到时它会**自动把 sys_cal_mode 关回 display**(用户明确要求的行为): + // 目标本被取消同步/账号被删后,绝不能"随便挑一个本"把系统日历导进去。 + const ref: BookRef | undefined = await SystemCalendarMirror.backupTargetRef(context); + if (ref === undefined) { + // 未选择目标本(或刚被判失效并关闭)→ 跳过。原因已由 backupTargetRef 写进日志。 + LogUtil.write('系统日历备份:当前无有效目标日历本,跳过导入'); return 0; } - // 定位目标 CalDAV 日历本 - const accounts: DavAccount[] = await AccountStore.loadAll(context); - let targetHref: string = ''; - for (const acc of accounts) { - for (let i = 0; i < acc.calendarHrefs.length; i++) { - if (`${acc.id}_${i}` === calKey) { - targetHref = acc.calendarHrefs[i]; - } - } - } + const calKey: string = ref.calKey; // 仅用于查本地库 / 落库 + const targetHref: string = ref.href; if (targetHref === '') { - LogUtil.write(`系统日历备份:目标日历本 ${calKey} 不存在(账号可能已删除),跳过导入`); + LogUtil.write(`系统日历备份:目标日历本 ${ref.bookId} 缺少集合 URL,跳过导入`); return 0; } const uiContext = context as common.UIAbilityContext; const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext); const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars(); + // 诊断:把 getAllCalendars() 实际能读到的账户全列出来。 + // 用于确认"是否只能读到本应用创建的 + 系统默认账户",还是能读到其他应用自建的日历账户。 + const brief: string[] = []; + for (const cal of calendars) { + let acc: calendarManager.CalendarAccount; + try { + acc = cal.getAccount(); + } catch (err) { + continue; + } + const tag: string = SystemCalendarMirror.isMirrorAccountName(acc.name) ? '(我们的镜像)' : ''; + brief.push(`${acc.name}[type=${acc.type}]${tag}`); + } + LogUtil.write(`系统日历账户清单:共 ${calendars.length} 个 → ${brief.join(' | ')}`); let imported: number = 0; let skippedCal: number = 0; + let skippedMirror: number = 0; // 本应用镜像出去的账户(不能导进备份本,否则重复) for (const cal of calendars) { try { // 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌 @@ -55,6 +76,13 @@ export class SystemCalendarImport { skippedCal++; continue; } + // ⚠️ 关键:跳过**本应用自己镜像出去的账户**(type 同样 LOCAL,但内容是我们从 CalDAV 写进去的)。 + // 若一并导入备份本,同一条日程会在服务器里出现两份(原始本一份、备份本一份)→ 数据非常乱。 + // 这些账户的改动由 MirrorInbound 负责回写到它**原本所属**的日历本,不走备份通道。 + if (SystemCalendarMirror.isMirrorAccountName(account.name)) { + skippedMirror++; + continue; + } const events: calendarManager.Event[] = await cal.getEvents(); for (const ev of events) { if (ev.id === undefined) { @@ -86,8 +114,9 @@ export class SystemCalendarImport { // 单个日历读取失败不影响其余 } } - if (imported > 0 || skippedCal > 0) { - LogUtil.write(`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过账户日历 ${skippedCal} 个)`); + if (imported > 0 || skippedCal > 0 || skippedMirror > 0) { + LogUtil.write( + `系统日历备份:新增 ${imported} 条 → ${calKey}(跳过非本地账户 ${skippedCal} 个、镜像账户 ${skippedMirror} 个)`); } return imported; } catch (err) { diff --git a/entry/src/main/ets/common/SystemCalendarMirror.ets b/entry/src/main/ets/common/SystemCalendarMirror.ets new file mode 100644 index 0000000..f2c3123 --- /dev/null +++ b/entry/src/main/ets/common/SystemCalendarMirror.ets @@ -0,0 +1,1313 @@ +// 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); + 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); + } + + /* ==================== 单个日历本 ==================== */ + + /** + * ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。 + * 实际写入(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)}`; + } + + /** 用账号信息补全日历本名与颜色(需要异步加载账号) */ + 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 = ''; +} diff --git a/entry/src/main/ets/pages/EditAccountPage.ets b/entry/src/main/ets/pages/EditAccountPage.ets index 198e14b..a4c3f7e 100644 --- a/entry/src/main/ets/pages/EditAccountPage.ets +++ b/entry/src/main/ets/pages/EditAccountPage.ets @@ -1,6 +1,10 @@ // entry/src/main/ets/pages/EditAccountPage.ets // 编辑账号:查看/重选该账号下的日历本、修改账户名 // 保存后清理失效日历本的本地数据,并触发一次重新同步 +// +// ⚠️ 取消勾选某个日历本会改变**后面所有本的序号**(calKey = accId_序号)。因此保存时还要检查: +// 被取消的那个本是不是「系统日历 → CalDAV」的备份目标 —— 是的话当场把该功能关闭, +// 否则用户会以为备份还在工作,实际却可能把系统日程导进别的本(用户实测反馈)。 import { router } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; import { buffer } from '@kit.ArkTS'; @@ -8,6 +12,7 @@ import { BusinessError } from '@kit.BasicServicesKit'; import { DavAccount, AccountStore } from '../common/AccountStore'; import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient'; import { EventDb } from '../common/EventDb'; +import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror'; import { LogUtil } from '../common/LogUtil'; /** @@ -243,8 +248,15 @@ struct EditAccountPage { selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`); await EventDb.pruneAccountEvents(context, target.id, validKeys); AppStorage.setOrCreate('pendingSyncAccountId', target.id); - this.getUIContext().getPromptAction() - .showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` }); + // ⭐ 若刚被取消勾选的本正是「系统日历 → CalDAV」的备份目标,**当场**判它失效并关闭备份功能。 + // 不这么做的话,要等下一次同步才会发现目标没了;这期间开关还显示"备份到 CalDAV", + // 用户会以为备份仍在正常工作,而实际已经导不进去(或更糟:导进了别的本)。 + const backupCheck: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context); + this.getUIContext().getPromptAction().showToast({ + message: backupCheck.closed + ? '原系统日历备份目标已被取消同步,备份功能已自动关闭' + : `已保存,同步 ${selectedItems.length} 个日历本` + }); router.back(); } catch (err) { const e = err as BusinessError; diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 4b85882..b26e668 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -20,6 +20,8 @@ import { RruleUtil } from '../common/RruleUtil'; import { IcsUtil } from '../common/IcsUtil'; import { DavClient, RemoteItem } from '../common/DavClient'; import { SystemCalendarImport } from '../common/SystemCalendarImport'; +import { SystemCalendarMirror } from '../common/SystemCalendarMirror'; +import { MirrorInbound } from '../common/MirrorInbound'; import { ScreenKeeper } from '../common/ScreenKeeper'; import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil'; @@ -591,6 +593,12 @@ struct Index { } catch (err) { // 导入失败不影响正常同步 } + // 入站对账:把用户在系统日历里的改动读回来置 dirty,**必须在同步之前**,才能借本次同步一起推送 + try { + await MirrorInbound.reconcile(context); + } catch (err) { + // 对账失败不影响正常同步 + } for (const acc of this.accounts) { if (acc.type !== TYPE_CALDAV) { continue; @@ -616,6 +624,15 @@ struct Index { const e = err as BusinessError; failMsg = e.message; } + // 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点) + try { + if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) { + const mr = await SystemCalendarMirror.syncNow(context); + LogUtil.write(`同步后自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`); + } + } catch (err) { + // 镜像失败不影响同步结果 + } this.syncing = false; await ScreenKeeper.release(context as common.UIAbilityContext); if (failMsg !== '') { diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index ab32c96..0531dc8 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -9,6 +9,8 @@ import { CalendarDataService } from '../common/CalendarDataService'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { AccountStore, DavAccount } from '../common/AccountStore'; import { ReminderService } from '../common/ReminderService'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror'; import { DocViewer } from '../common/DocViewer'; const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; @@ -16,6 +18,7 @@ const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; /** 日历本选项(备份目标 / 只读标记 / 静音标记共用) */ class BackupTarget { calKey: string = ''; + bookId: string = ''; // ⭐ 稳定标识(由服务器 href 派生):镜像选中项用它存,不随序号变 label: string = ''; serverWritable: boolean = true; // 服务器探测结果 } @@ -27,8 +30,9 @@ struct SettingsPage { @State intervalMinutes: number = 1; @State backgroundSync: boolean = false; @State sysMode: string = 'display'; // display | backup - @State backupKey: string = ''; + @State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey) @State backupTargets: BackupTarget[] = []; + @State backupNotice: string = ''; // 备份目标失效/未选择时的提示文案 @State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读/静音标记管理用) @State manualKeys: string[] = []; // 手动标记只读的 calKey @State mutedKeys: string[] = []; // 提醒静音的 calKey @@ -40,6 +44,13 @@ struct SettingsPage { @State docUrl: string = ''; @State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda @State displayStyle: string = 'timeline'; // 日程显示方式:timeline(时间轴)| list(列表) + @State mirrorEnabled: boolean = false; // 是否把选中 CalDAV 日历本镜像到系统日历(默认关) + @State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey) + @State mirrorRunning: boolean = false; // 正在执行镜像 + @State purgeRunning: boolean = false; // 正在清理残留镜像账户 + @State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌) + @State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关) + @State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充) private context?: common.Context; aboutToAppear(): void { @@ -84,6 +95,29 @@ struct SettingsPage { this.refreshNotifyState(); this.loadBackupSettings(); this.loadAllBooks(); + AppSettings.getMirrorEnabled(ctx).then((v: boolean): void => { + this.mirrorEnabled = v; + // 状态自洽:记录为"开"但权限已被系统回收 → 自动回落为"关" + if (v) { + SystemCalendarMirror.hasPermission().then((ok: boolean): void => { + if (!ok && this.context !== undefined) { + this.mirrorEnabled = false; + AppSettings.setMirrorEnabled(this.context, false); + } + }); + } + }); + // ⭐ 走 selectedBookRefs:它兼做历史迁移(老版本存的是 calKey)与"排除备份目标本"过滤 + SystemCalendarMirror.selectedBookRefs(ctx).then((refs: BookRef[]): void => { + const ids: string[] = []; + for (const r of refs) { + ids.push(r.bookId); + } + this.mirrorKeys = ids; + }); + AppSettings.getMirrorInbound(ctx).then((v: boolean): void => { + this.mirrorInbound = v; + }); } /** 重新检测通知权限状态 */ @@ -138,7 +172,51 @@ struct SettingsPage { books.push(b); } } + // ⭐ 补上稳定标识 bookId(由服务器 href 派生)。镜像的"选中状态"必须按它存, + // 否则日历本重排后序号一变,选中的本就串成另一个本。 + if (this.context !== undefined) { + const refs: BookRef[] = await SystemCalendarMirror.listBooks(this.context); + for (const b of books) { + for (const ref of refs) { + if (ref.calKey === b.calKey) { + b.bookId = ref.bookId; + break; + } + } + } + } this.allBooks = books; + await this.refreshMirrorCandidates(); + } + + /** + * 可镜像的日历本 = 全部 DAV 本 **去掉「系统日历备份目标本」**。 + * 备份目标本里的日程本来就来自系统日历(uid 前缀 syscal-),再镜像回系统日历就是自我复制。 + */ + private async refreshMirrorCandidates(): Promise { + if (this.context === undefined) { + return; + } + const ctx: common.Context = this.context; + const blocked: string[] = await SystemCalendarMirror.blockedBookIds(ctx); + this.mirrorCandidates = + this.allBooks.filter((b: BackupTarget): boolean => b.bookId === '' || !blocked.includes(b.bookId)); + // 已选中的本若被改成备份目标 → 自动剔除并删掉它在系统日历里的镜像账户 + const kept: string[] = this.mirrorKeys.filter((k: string): boolean => !blocked.includes(k)); + if (kept.length !== this.mirrorKeys.length) { + for (const k of this.mirrorKeys) { + if (blocked.includes(k)) { + await SystemCalendarMirror.removeById(ctx, k); + } + } + this.mirrorKeys = kept; + await AppSettings.setMirrorBookIds(ctx, kept); + } + } + + /** 因"是备份目标本"而被隐藏、不能镜像的本的数量 */ + private mirrorHiddenCount(): number { + return this.allBooks.length - this.mirrorCandidates.length; } /** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */ @@ -193,12 +271,21 @@ struct SettingsPage { }); } - /** 加载备份目标候选(可写 DAV 日历本)+ 当前选择 */ + /** + * 加载备份目标候选(可写 DAV 日历本)+ 当前选择。 + * + * ⭐ 当前选择用**稳定标识 bookId** 记录(老版本存 calKey,序号会漂移 → 曾把系统日历 + * 导进用户没选过的本)。这里统一走 `checkBackupTarget()`: + * - 老值自动迁移为 bookId; + * - 目标本已不存在(被取消勾选/账号删除)→ 它会把备份模式**自动关回 display**, + * 这里只负责把结果反映到 UI 并给出提示。 + */ private async loadBackupSettings(): Promise { if (this.context === undefined) { return; } - const accounts: DavAccount[] = await AccountStore.loadAll(this.context); + const ctx: common.Context = this.context; + const accounts: DavAccount[] = await AccountStore.loadAll(ctx); const targets: BackupTarget[] = []; for (const acc of accounts) { if (acc.type !== 'caldav') { @@ -219,49 +306,94 @@ struct SettingsPage { } } // 手动标记只读的本不可作为备份目标 - const manual: string[] = await AppSettings.getManualReadonlyKeys(this.context); + const manual: string[] = await AppSettings.getManualReadonlyKeys(ctx); this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey)); - const saved: string = await AppSettings.getBackupCalKey(this.context); - this.backupKey = targets.some((t: BackupTarget): boolean => t.calKey === saved) ? saved : ''; + // 补 bookId(稳定标识)—— 选择状态必须按它存 + const refs: BookRef[] = await SystemCalendarMirror.listBooks(ctx); + for (const t of this.backupTargets) { + for (const r of refs) { + if (r.calKey === t.calKey) { + t.bookId = r.bookId; + break; + } + } + } + // 解析当前目标(可能触发失效清理,并在功能开着时自动关闭) + const check: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(ctx); + this.backupKey = check.ref === undefined ? '' : check.ref.bookId; + if (check.reason !== '') { + this.backupNotice = check.closed + ? `${check.reason},已自动关闭系统日历备份。如需继续备份,请在下方重新选择目标日历本。` + : `${check.reason}。请重新选择备份目标日历本后再开启备份。`; + } else { + this.backupNotice = ''; + } + // 最后再以"落盘值"为准刷新一次选择器:aboutToAppear 里另有一条 getSysCalMode 的异步读取, + // 若 checkBackupTarget 刚刚把模式关成了 display,就以这里读到的为准(避免旧值把 UI 覆盖回去) + this.sysMode = await AppSettings.getSysCalMode(ctx); } + /** + * 切换「系统日历日程的处理方式」。 + * + * ⚠️ 顺序很重要:**先校验备份目标、再申请权限、最后才落盘**。 + * - 没有目标本就不允许开启备份(绝不替用户"随便挑一个本"—— + * 那样会把系统日程导进他没选过的日历本,用户实测反馈的问题); + * - 校验不过就不申请「读取全部日程」权限,避免为一次注定失败的开启弹权限框。 + */ private async saveSysMode(mode: string): Promise { if (this.context === undefined) { return; } - // "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限 - if (mode === 'backup') { - const granted: boolean = await CalendarDataService.requestSystemCalendarPermission( - this.context as common.UIAbilityContext); - if (!granted) { - this.sysMode = 'display'; // 回弹选择,保持与实际授权状态一致 - this.getUIContext().getPromptAction().showToast({ - message: '未获得"读取全部日程"权限,无法开启系统日历备份' - }); - return; - } + if (mode !== 'backup') { + this.sysMode = 'display'; + await AppSettings.setSysCalMode(this.context, 'display'); + this.backupNotice = ''; + await this.refreshMirrorCandidates(); // 备份模式变化 → 重算可镜像的本(防回灌) + this.getUIContext().getPromptAction().showToast({ + message: '已切换为仅显示:不再把系统日程备份到 CalDAV' + }); + return; + } + // ① 必须先有明确的备份目标本(见上方 loadBackupSettings / saveBackupTarget) + if (this.backupKey === '') { + this.sysMode = 'display'; // 回弹,且**不写 preferences**:宁可不开启,也不默认挑一个本 + this.backupNotice = this.backupTargets.length > 0 + ? '请先选择备份目标日历本:未选定目标前不会开启备份,也不会导入任何系统日程。' + : '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限。'; + this.getUIContext().getPromptAction().showToast({ + message: '请先选择备份目标日历本,再开启备份' + }); + return; + } + // ② "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限 + const granted: boolean = await CalendarDataService.requestSystemCalendarPermission( + this.context as common.UIAbilityContext); + if (!granted) { + this.sysMode = 'display'; // 回弹选择,保持与实际授权状态一致 + this.getUIContext().getPromptAction().showToast({ + message: '未获得"读取全部日程"权限,无法开启系统日历备份' + }); + return; } this.sysMode = mode; await AppSettings.setSysCalMode(this.context, mode); - if (mode === 'backup' && this.backupKey === '') { - if (this.backupTargets.length > 0) { - this.backupKey = this.backupTargets[0].calKey; - await AppSettings.setBackupCalKey(this.context, this.backupKey); - } - } + this.backupNotice = ''; + await this.refreshMirrorCandidates(); // 备份模式/目标变化 → 重算可镜像的本(防回灌) this.getUIContext().getPromptAction().showToast({ - message: mode === 'backup' - ? '已开启备份:下次同步时把系统本地日程导入所选日历本' - : '已切换为仅显示:不再把系统日程备份到 CalDAV' + message: '已开启备份:下次同步时把系统本地日程导入所选日历本' }); } - private async saveBackupTarget(calKey: string): Promise { + /** 保存备份目标(入参为 **bookId**,稳定标识,不再是会漂移的 calKey) */ + private async saveBackupTarget(bookId: string): Promise { if (this.context === undefined) { return; } - this.backupKey = calKey; - await AppSettings.setBackupCalKey(this.context, calKey); + this.backupKey = bookId; + await AppSettings.setBackupBookId(this.context, bookId); + this.backupNotice = ''; // 用户显式选定了目标 → 清掉"失效/未选择"提示 + await this.refreshMirrorCandidates(); // 备份目标变了 → 可镜像的本也要跟着变(防回灌) this.getUIContext().getPromptAction() .showToast({ message: '备份目标已更新,下次同步生效' }); } @@ -291,6 +423,135 @@ struct SettingsPage { .showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' }); } + /** + * 「镜像到系统日历」开关。 + * ⚠️ 合规约束(《审核指南》7.17):写入系统日历依赖 WRITE_CALENDAR 权限, + * **只在用户主动打开这个开关时申请**;未授权则开关回弹、不保存设置。 + * 关闭时会把之前镜像出去的账户一并删掉,避免系统日历里留下"幽灵日程"。 + */ + private async saveMirrorEnabled(isOn: boolean): Promise { + if (this.context === undefined) { + return; + } + if (isOn) { + const granted: boolean = await SystemCalendarMirror.requestPermission( + this.context as common.UIAbilityContext); + if (!granted) { + this.mirrorEnabled = false; // 回弹开关,保持与实际授权状态一致 + this.getUIContext().getPromptAction().showToast({ + message: '未获得"写入系统日历"权限,无法开启镜像' + }); + return; + } + } + this.mirrorEnabled = isOn; + await AppSettings.setMirrorEnabled(this.context, isOn); + if (isOn) { + this.getUIContext().getPromptAction().showToast({ + message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」' + }); + return; + } + const n: number = await SystemCalendarMirror.removeAll(this.context); + this.getUIContext().getPromptAction().showToast({ + message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像' + }); + } + + /** 勾选/取消勾选要镜像的日历本;取消勾选会顺手删掉对应的系统日历账户。⚠️ 传的是 bookId */ + private async toggleMirrorKey(bookId: string, on: boolean): Promise { + if (this.context === undefined || bookId === '') { + return; + } + const idx: number = this.mirrorKeys.indexOf(bookId); + if (on && idx < 0) { + this.mirrorKeys = [...this.mirrorKeys, bookId]; + } else if (!on && idx >= 0) { + this.mirrorKeys = this.mirrorKeys.filter((k: string): boolean => k !== bookId); + await SystemCalendarMirror.removeById(this.context, bookId); + } else { + return; + } + await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys); + } + + /** + * 「把系统日历的改动回写」开关(双向闭环的回程)。 + * 依赖镜像快照(MirrorSnapshot)防回灌:只把"和上次写入值不一样"的当成用户改动。 + * ⚠️ 默认关,因为它会写用户的服务器数据 —— 建议先单向观察一轮再打开。 + */ + private async saveMirrorInbound(isOn: boolean): Promise { + if (this.context === undefined) { + return; + } + this.mirrorInbound = isOn; + await AppSettings.setMirrorInbound(this.context, isOn); + this.getUIContext().getPromptAction().showToast({ + message: isOn + ? '已开启回写:系统日历里的新建/修改/删除,下次同步时传回服务器' + : '已关闭回写:系统日历的改动不再传回服务器' + }); + } + + /** 手动执行一次镜像(幂等,可反复点) */ + private async runMirrorNow(): Promise { + if (this.context === undefined || this.mirrorRunning) { + return; + } + if (this.mirrorKeys.length === 0) { + this.getUIContext().getPromptAction().showToast({ message: '请先勾选要镜像的日历本' }); + return; + } + this.mirrorRunning = true; + try { + const res: MirrorResult = await SystemCalendarMirror.syncNow(this.context); + const skip: string = res.skipped > 0 ? ` / 跳过 ${res.skipped}` : ''; + this.getUIContext().getPromptAction().showToast({ + message: `镜像完成:${res.books} 个本,新增 ${res.added} / 更新 ${res.updated} / 删除 ${res.deleted}${skip}` + }); + } catch (err) { + const e = err as BusinessError; + const msg: string = e.message !== undefined && e.message !== '' + ? e.message : '请确认已授予"写入系统日历"权限'; + this.getUIContext().getPromptAction().showToast({ message: `镜像失败:${msg}` }); + } finally { + this.mirrorRunning = false; + } + } + + /** + * 「清理残留镜像账户(诊断)」按钮: + * ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志; + * ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续); + * ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。 + */ + private async runPurgeOrphans(): Promise { + if (this.context === undefined || this.purgeRunning) { + return; + } + this.purgeRunning = true; + try { + const r: PurgeResult = await SystemCalendarMirror.forceCleanup(this.context); + this.accountDump = r.detail.join('\n'); + let msg: string = `已清理 ${r.removed} 个残留账户`; + if (r.failed > 0) { + msg += `,失败 ${r.failed} 个`; + } + if (r.remaining > 0) { + msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`; + } + msg += '(完整账户清单见同步日志)'; + this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 }); + } catch (err) { + const e = err as BusinessError; + this.getUIContext().getPromptAction().showToast({ + message: `清理失败:${e.message ?? '请确认已授予"写入系统日历"权限'}` + }); + } finally { + this.purgeRunning = false; + } + } + private async saveInterval(minutes: number): Promise { if (this.context === undefined) { return; @@ -533,31 +794,184 @@ struct SettingsPage { this.saveSysMode(mode); } }) - if (this.sysMode === 'backup') { - Text(this.backupTargets.length > 0 - ? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份' - : '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限') - .fontSize(12) - .fontColor(this.backupTargets.length > 0 - ? $r('app.color.text_secondary') : $r('app.color.error')) + // ⭐ 备份目标**始终可配置**(不再只在 backup 模式下显示): + // 否则"先选目标才能开启备份"会变成死锁(Select 只在 backup 模式下渲染)。 + Text(this.backupTargets.length > 0 + ? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份' + : '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限') + .fontSize(12) + .fontColor(this.backupTargets.length > 0 + ? $r('app.color.text_secondary') : $r('app.color.error')) + .width('100%') + if (this.backupTargets.length > 0) { + Select(this.backupTargets.map((t: BackupTarget): SelectOption => { + return { value: t.label } as SelectOption; + }) as SelectOption[]) + .selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.bookId === this.backupKey)) + .value(this.backupTargets.find((t: BackupTarget): boolean => t.bookId === this.backupKey)?.label + ?? '请选择日历本') + .fontColor($r('app.color.text_primary')) + .font({ size: 14 }) + .optionFont({ size: 14 }) + .selectedOptionFont({ size: 14 }) .width('100%') - if (this.backupTargets.length > 0) { - Select(this.backupTargets.map((t: BackupTarget): SelectOption => { - return { value: t.label } as SelectOption; - }) as SelectOption[]) - .selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.calKey === this.backupKey)) - .value(this.backupTargets.find((t: BackupTarget): boolean => t.calKey === this.backupKey)?.label - ?? '请选择日历本') + .onSelect((index: number) => { + if (index >= 0 && index < this.backupTargets.length) { + this.saveBackupTarget(this.backupTargets[index].bookId); + } + }) + } + // 失效/未选择提示:目标本没了时功能已被自动关闭,必须让用户看得见原因 + if (this.backupNotice !== '') { + Row({ space: 6 }) { + Text('⚠️') + .fontSize(12) + .fontColor($r('app.color.error')) + Text(this.backupNotice) + .fontSize(12) + .fontColor($r('app.color.error')) + .layoutWeight(1) + } + .width('100%') + .padding(8) + .borderRadius(8) + .backgroundColor($r('app.color.error_bg')) + } else if (this.backupTargets.length > 0 && this.backupKey === '') { + Text('尚未选择备份目标:请先选择要把系统日历备份到哪个 CalDAV 日历本,否则无法开启备份') + .fontSize(12) + .fontColor($r('app.color.error')) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + + // 镜像到系统日历:把选中的 CalDAV 日历本写入系统日历,让小艺 / 桌面卡片 / 手表也能看到 + Column({ space: 8 }) { + Row({ space: 10 }) { + Column({ space: 2 }) { + Text('镜像到系统日历') + .fontSize(15) + .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_primary')) - .font({ size: 14 }) - .optionFont({ size: 14 }) - .selectedOptionFont({ size: 14 }) + Text('把选中的 CalDAV 日历本写入系统日历,这样小艺、桌面日历卡片和手表也能看到你的日程(需授权写入系统日历)') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Toggle({ type: ToggleType.Switch, isOn: this.mirrorEnabled }) + .selectedColor($r('app.color.brand')) + .onChange((isOn: boolean) => { + this.saveMirrorEnabled(isOn); + }) + } + .width('100%') + + if (this.mirrorEnabled) { + if (this.mirrorCandidates.length === 0) { + Text('没有可镜像的 CalDAV 日历本,请先添加账号') + .fontSize(12) + .fontColor($r('app.color.error')) .width('100%') - .onSelect((index: number) => { - if (index >= 0 && index < this.backupTargets.length) { - this.saveBackupTarget(this.backupTargets[index].calKey); + } else { + Text('选择要镜像的日历本:每个本在系统日历里是独立账户,可单独设色与隐藏') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + .width('100%') + if (this.mirrorHiddenCount() > 0) { + Text(`另有 ${this.mirrorHiddenCount()} 个本被设为「系统日历备份目标」,其日程本就来自系统日历,已自动排除以免重复`) + .fontSize(12) + .fontColor($r('app.color.text_hint')) + .width('100%') + } + Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { + ForEach(this.mirrorCandidates, (b: BackupTarget): void => { + Row({ space: 6 }) { + Text(b.label) + .fontSize(13) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Toggle({ type: ToggleType.Checkbox, isOn: this.mirrorKeys.indexOf(b.bookId) >= 0 }) + .selectedColor($r('app.color.brand')) + .onChange((on: boolean) => { + this.toggleMirrorKey(b.bookId, on); + }) } + .width('48%') + .padding({ left: 2, right: 2, top: 4, bottom: 4 }) + }, (b: BackupTarget): string => b.calKey) + } + .width('100%') + + Row({ space: 10 }) { + Column({ space: 2 }) { + Text('把系统日历的改动回写') + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + Text('你在系统日历里新建、修改、删除的日程,同步回 CalDAV 服务器') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Toggle({ type: ToggleType.Switch, isOn: this.mirrorInbound }) + .selectedColor($r('app.color.brand')) + .onChange((isOn: boolean) => { + this.saveMirrorInbound(isOn); + }) + } + .width('100%') + .padding({ top: 4 }) + + Button(this.mirrorRunning ? '正在执行…' : '立即执行镜像') + .fontSize(14) + .width('100%') + .enabled(!this.mirrorRunning) + .onClick(() => { + this.runMirrorNow(); }) + + Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)') + .fontSize(13) + .fontColor($r('app.color.brand_text')) + .backgroundColor(Color.Transparent) + .width('100%') + .enabled(!this.purgeRunning) + .onClick(() => { + this.runPurgeOrphans(); + }) + + if (this.accountDump !== '') { + Column({ space: 4 }) { + Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):') + .fontSize(11) + .fontColor($r('app.color.text_secondary')) + .width('100%') + Scroll() { + Text(this.accountDump) + .fontSize(10) + .lineHeight(14) + .fontColor($r('app.color.text_primary')) + .copyOption(CopyOptions.LocalDevice) + .width('100%') + } + .height(150) + .width('100%') + .backgroundColor($r('app.color.page_bg')) + .borderRadius(8) + .padding(6) + } + .width('100%') + .padding({ top: 4 }) + } } } } diff --git a/entry/src/main/ets/syncwork/SyncWorkAbility.ets b/entry/src/main/ets/syncwork/SyncWorkAbility.ets index 525c6d2..e957e81 100644 --- a/entry/src/main/ets/syncwork/SyncWorkAbility.ets +++ b/entry/src/main/ets/syncwork/SyncWorkAbility.ets @@ -8,6 +8,8 @@ import { SyncEngine } from '../common/SyncEngine'; import { CardDataService } from '../common/CardDataService'; import { ReminderService } from '../common/ReminderService'; import { SystemCalendarImport } from '../common/SystemCalendarImport'; +import { SystemCalendarMirror } from '../common/SystemCalendarMirror'; +import { MirrorInbound } from '../common/MirrorInbound'; import { AppSettings } from '../common/AppSettings'; import { LogUtil } from '../common/LogUtil'; @@ -32,6 +34,12 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { } catch (err) { // 导入失败不影响正常同步 } + // 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前) + try { + await MirrorInbound.reconcile(context); + } catch (err) { + // 对账失败不影响正常同步 + } let ok: number = 0; let attempted: number = 0; for (const acc of accounts) { @@ -58,6 +66,15 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { // 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失, // 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。 await AccountStore.saveAll(context, accounts); + // 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点) + try { + if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) { + const mr = await SystemCalendarMirror.syncNow(context); + LogUtil.write(`延迟任务自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`); + } + } catch (err) { + // 镜像失败不影响同步 + } // 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见 await CardDataService.pushToAllForms(context); await ReminderService.refreshReminders(context); diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 3f7fc40..eeabe8f 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -38,6 +38,16 @@ "when": "inuse" } }, + { + "name": "ohos.permission.WRITE_CALENDAR", + "reason": "$string:perm_write_calendar", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + } + }, { "name": "ohos.permission.LOCATION", "reason": "$string:perm_location", diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 63b25ad..f86298d 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -18,7 +18,7 @@ }, { "name": "perm_write_calendar", - "value": "写入系统日历,用于保存您创建的日程" + "value": "写入系统日历,用于把您选择的 CalDAV 日历本镜像到系统日历,让小艺、桌面日历卡片和手表也能看到您的日程" }, { "name": "perm_read_whole_calendar",