新增系统日历镜像功能,镜像与备份目标统一改用 bookId;修复重复日程覆盖行、备份目标随勾选漂移等一批问题
- 新增 SystemCalendarMirror / MirrorSnapshot / MirrorInbound:把选定的 CalDAV 日历本镜像写入系统日历(供小艺、桌面日历卡片、手表可见),并做入站对账,区分"自己写入的 / 用户改的 / 用户删的 / 用户新建的",镜像标识一律用稳定的 bookId(accId-href哈希),不再用会随勾选顺序漂移的 calKey - 后台同步接入镜像:同步前先做入站对账、同步后自动出站镜像,无需用户手动触发 - 系统日历备份目标改用 bookId:当用户取消同步某个日历本导致目标失效时,自动清空目标并把模式回退为"仅显示";开启备份必须先选定目标本,不再自动挑第一个本 - EventDb:覆盖行保留 RECURRENCE-ID,排除主事件对应发生改用 overrideKey(calKey, uid, recurrenceId, startTime),修复重复日程出现"幽灵条目"的问题 - 新增 WRITE_CALENDAR 权限声明及其用途说明;添加日历不再出现"本机",日历本对齐显示;版本升至 0.0.3(300)
This commit is contained in:
@@ -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<InboundResult> {
|
||||
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<void> {
|
||||
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<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
|
||||
const events: calendarManager.Event[] = await cal.getEvents(
|
||||
undefined, MirrorInbound.QUERY_FIELDS);
|
||||
|
||||
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
|
||||
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||||
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<string, LocalEvent> = new Map<string, LocalEvent>();
|
||||
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 形如 `<bookId>|<uid>@<发生时间戳>`):
|
||||
// 它是我们从一条重复日程展开出来的,不是独立事件 —— 改它没法映射回服务器上的
|
||||
// 那一条 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 形如 `<bookId>|<uid>@<发生时间戳>`。
|
||||
*
|
||||
* ⚠️ 不能用 `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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user