新增系统日历镜像功能,镜像与备份目标统一改用 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:
laoyang
2026-09-19 23:07:03 +08:00
parent 165740fe9e
commit 736a0a6587
16 changed files with 2815 additions and 84 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 200, "versionCode": 300,
"versionName": "0.0.2", "versionName": "0.0.3",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+190 -5
View File
@@ -11,13 +11,22 @@ export class AppSettings {
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes'; private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync'; 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_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_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_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_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 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_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'; // 是否已同意隐私政策与用户协议 private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。 // 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true**不再"显示前就置 true"**—— // 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true**不再"显示前就置 true"**——
@@ -149,6 +158,141 @@ export class AppSettings {
} }
} }
/**
* 是否把选中的 CalDAV 日历本镜像到系统日历(默认关)。
* ⚠️ 该开关必须与 WRITE_CALENDAR 授权状态一致:未授权即为关(权限按需申请约束)。
*/
static async getMirrorEnabled(context: common.Context): Promise<boolean> {
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<void> {
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<string[]> {
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<void> {
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哈希>`,由服务器分配的 href 派生 —— 日历本增删改序都不会变。
*/
static async getMirrorBookIds(context: common.Context): Promise<string[]> {
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<void> {
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<boolean> {
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<void> {
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 时期)落库的残缺数据。 * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
@@ -286,9 +430,14 @@ export class AppSettings {
if (muted2.length !== muted.length) { if (muted2.length !== muted.length) {
await AppSettings.setMutedReminderKeys(context, muted2); await AppSettings.setMutedReminderKeys(context, muted2);
} }
const backupKey: string = await AppSettings.getBackupCalKey(context); // 备份目标指向该账号名下某个本 → 目标必失效:清空目标**并关闭备份功能**
if (backupKey.startsWith(`${accId}_`)) { // (只清空目标会让"备份到 CalDAV"开关停在开着却什么都不做的状态,状态与实际不符)
await AppSettings.setBackupCalKey(context, ''); 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) { } catch (err) {
const e = err as BusinessError; 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<string> { static async getBackupCalKey(context: common.Context): Promise<string> {
try { try {
const store: preferences.Preferences = const store: preferences.Preferences =
@@ -330,18 +484,49 @@ export class AppSettings {
} }
} }
/** 【已废弃】写老键。新代码请用 setBackupBookId()。 */
static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> { static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> {
try { try {
const store: preferences.Preferences = const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE); await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_BACKUP_KEY, calKey); await store.put(AppSettings.KEY_BACKUP_KEY, calKey);
await store.flush(); await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存备份目标(老键)失败: ${e.message}`);
}
}
/** ⭐ 备份目标日历本的稳定标识 bookId`<accId>-<href哈希>`),空 = 未选择 */
static async getBackupBookId(context: common.Context): Promise<string> {
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<void> {
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) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
console.error(`保存备份目标失败: ${e.message}`); console.error(`保存备份目标失败: ${e.message}`);
} }
} }
/** 一次清空备份目标(新键 + 老键一起清,避免迁移残留又复活) */
static async clearBackupTarget(context: common.Context): Promise<void> {
await AppSettings.setBackupBookId(context, '');
await AppSettings.setBackupCalKey(context, '');
}
/** 是否开启后台持续同步(长时任务,默认关) */ /** 是否开启后台持续同步(长时任务,默认关) */
static async getBackgroundSync(context: common.Context): Promise<boolean> { static async getBackgroundSync(context: common.Context): Promise<boolean> {
try { try {
@@ -202,12 +202,14 @@ export class CalendarDataService {
} }
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。 // 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。
// 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本); // 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本);
// ②只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留) // ②键必须用 **recurrenceId(原始发生时刻)**——覆盖行的 startTime 是"被改到的新时间"
// 用它做键时主事件在原时间的那次发生不会被排除 → 同一日程显示两条(幽灵日程);
// ③只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
// 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示" // 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示"
const overrideKeys: string[] = []; const overrideKeys: string[] = [];
for (const e of rows) { for (const e of rows) {
if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) { 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; let occTotal: number = 0;
@@ -232,7 +234,7 @@ export class CalendarDataService {
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500); const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
if (occs.length > 0) { if (occs.length > 0) {
times = occs.filter((occ: number): boolean => 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) { for (const occ of times) {
+133 -5
View File
@@ -25,6 +25,10 @@ export class LocalEvent {
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生 rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔 exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
recurrenceId: number = 0;
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒 reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序 reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
} }
@@ -44,6 +48,7 @@ export class RemoteEvent {
completed: boolean = false; // VTODO STATUS:COMPLETED completed: boolean = false; // VTODO STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE rrule: string = ''; // 原始 RRULE
exdate: string = ''; // 原始 EXDATE(分号分隔) exdate: string = ''; // 原始 EXDATE(分号分隔)
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
reminders: number[] = []; // 全部提醒提前分钟数(多选) reminders: number[] = []; // 全部提醒提前分钟数(多选)
} }
@@ -51,6 +56,36 @@ export class RemoteEvent {
export class EventDb { export class EventDb {
private static db: relationalStore.RdbStore | null = null; 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<number> {
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<relationalStore.RdbStore> { static async getDb(context: common.Context): Promise<relationalStore.RdbStore> {
if (EventDb.db !== null) { if (EventDb.db !== null) {
return EventDb.db; return EventDb.db;
@@ -104,6 +139,12 @@ export class EventDb {
} catch (err) { } catch (err) {
// 列已存在 // 列已存在
} }
// ⭐ 覆盖实例的"原始发生时刻"RECURRENCE-ID)。老库升级后该列一律为 0,需要一次全量重拉回填
try {
await store.executeSql('ALTER TABLE events ADD COLUMN recurrence_id INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
EventDb.db = store; EventDb.db = store;
return store; return store;
} }
@@ -124,6 +165,18 @@ export class EventDb {
return EventDb.normalizeReminders(list).join(','); 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 { private static fromRow(rs: relationalStore.ResultSet): LocalEvent {
const e = new LocalEvent(); const e = new LocalEvent();
e.id = rs.getLong(rs.getColumnIndex('id')); e.id = rs.getLong(rs.getColumnIndex('id'));
@@ -146,6 +199,7 @@ export class EventDb {
e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1; e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1;
e.rrule = rs.getString(rs.getColumnIndex('rrule')); e.rrule = rs.getString(rs.getColumnIndex('rrule'));
e.exdate = rs.getString(rs.getColumnIndex('exdate')); e.exdate = rs.getString(rs.getColumnIndex('exdate'));
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
e.reminder = rs.getLong(rs.getColumnIndex('reminder')); e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒 // 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
const remStr: string = rs.getString(rs.getColumnIndex('reminders')); const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
@@ -178,6 +232,7 @@ export class EventDb {
'completed': e.completed ? 1 : 0, 'completed': e.completed ? 1 : 0,
'rrule': e.rrule, 'rrule': e.rrule,
'exdate': e.exdate, 'exdate': e.exdate,
'recurrence_id': e.recurrenceId,
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0, 'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
'reminders': e.reminders.map((n: number): string => String(n)).join(',') 'reminders': e.reminders.map((n: number): string => String(n)).join(',')
}; };
@@ -371,12 +426,22 @@ export class EventDb {
} }
const remoteKeys: string[] = []; const remoteKeys: string[] = [];
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
const seenKeys: Set<string> = new Set<string>();
for (const r of remote) { for (const r of remote) {
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间 // 重复日程实例共享 UID,唯一标识 = uid + 开始时间 + RECURRENCE-ID
const key: string = `${r.uid}_${r.startTime}`; //(同一时刻可能同时存在"主事件"与"覆盖该次发生的实例行",只带上 recurrenceId 才唯一)
const key: string = `${r.uid}_${r.startTime}_${r.recurrenceId}`;
if (seenKeys.has(key)) {
continue; // 同一资源内的重复条目 → 只保留一条
}
seenKeys.add(key);
remoteKeys.push(key); remoteKeys.push(key);
const found = existing.find((x: LocalEvent): boolean => 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) { if (found === undefined) {
// 新增 // 新增
const e = new LocalEvent(); const e = new LocalEvent();
@@ -398,6 +463,7 @@ export class EventDb {
e.completed = r.completed; e.completed = r.completed;
e.rrule = r.rrule; e.rrule = r.rrule;
e.exdate = r.exdate; e.exdate = r.exdate;
e.recurrenceId = r.recurrenceId;
e.reminder = r.reminder; e.reminder = r.reminder;
e.reminders = r.reminders; e.reminders = r.reminders;
await store.insert('events', EventDb.toBucket(e)); await store.insert('events', EventDb.toBucket(e));
@@ -420,6 +486,7 @@ export class EventDb {
found.completed = r.completed; found.completed = r.completed;
found.rrule = r.rrule; found.rrule = r.rrule;
found.exdate = r.exdate; found.exdate = r.exdate;
found.recurrenceId = r.recurrenceId;
found.reminder = r.reminder; found.reminder = r.reminder;
found.reminders = r.reminders; found.reminders = r.reminders;
const up = new relationalStore.RdbPredicates('events'); const up = new relationalStore.RdbPredicates('events');
@@ -435,7 +502,7 @@ export class EventDb {
if (local.dirty) { if (local.dirty) {
continue; continue;
} }
const localKey: string = `${local.uid}_${local.startTime}`; const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
if (!remoteKeys.includes(localKey)) { if (!remoteKeys.includes(localKey)) {
const del = new relationalStore.RdbPredicates('events'); const del = new relationalStore.RdbPredicates('events');
del.equalTo('id', local.id); del.equalTo('id', local.id);
@@ -446,11 +513,72 @@ export class EventDb {
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`; 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<number> {
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<LocalEvent[]> { static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context); const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events'); const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind); predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
predicates.orderByAsc('id');
const rs = await store.query(predicates); const rs = await store.query(predicates);
const list: LocalEvent[] = []; const list: LocalEvent[] = [];
try { try {
+12
View File
@@ -16,6 +16,7 @@ class ParsedEvent {
inAlarm: boolean = false; // 是否处于 VALARM 子组件内(内部属性不参与解析) inAlarm: boolean = false; // 是否处于 VALARM 子组件内(内部属性不参与解析)
rrule: string = ''; // 原始 RRULE 值 rrule: string = ''; // 原始 RRULE 值
exdates: string[] = []; // 原始 EXDATE 值列表 exdates: string[] = []; // 原始 EXDATE 值列表
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒)——本行覆盖的是主事件的哪一次发生
reminder: number = 0; // 主提醒提前分钟数(第一个 VALARM,0 = 不提醒) reminder: number = 0; // 主提醒提前分钟数(第一个 VALARM,0 = 不提醒)
reminders: number[] = []; // 全部提醒提前分钟数(多 VALARM) reminders: number[] = []; // 全部提醒提前分钟数(多 VALARM)
} }
@@ -102,6 +103,16 @@ export class IcsUtil {
current.recurring = true; current.recurring = true;
if (propName === 'RRULE') { if (propName === 'RRULE') {
current.rrule = value; 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') { } else if (propName === 'EXDATE') {
const exValues: string[] = value.split(','); const exValues: string[] = value.split(',');
@@ -140,6 +151,7 @@ export class IcsUtil {
r.recurring = p.recurring; r.recurring = p.recurring;
r.rrule = p.rrule; r.rrule = p.rrule;
r.exdate = p.exdates.join(';'); r.exdate = p.exdates.join(';');
r.recurrenceId = p.recurrenceId;
r.reminder = p.reminder; r.reminder = p.reminder;
r.reminders = p.reminders; r.reminders = p.reminders;
return r; return r;
+328
View File
@@ -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 '';
}
}
}
@@ -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 = ''; // `<bookId>|<uid>`
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<relationalStore.RdbStore> {
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<Map<string, MirrorSnapshotRow>> {
const out: Map<string, MirrorSnapshotRow> = new Map<string, MirrorSnapshotRow>();
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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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 (`<calKey>|<uid>`) → 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);
}
}
+23
View File
@@ -46,6 +46,18 @@ export class SyncEngine {
// 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位 // 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位
LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`); LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
try { 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 分钟 // 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context); const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
const timeoutMs: number = fullRefetch ? 600000 : 300000; const timeoutMs: number = fullRefetch ? 600000 : 300000;
@@ -280,6 +292,7 @@ export class SyncEngine {
r.completed = e.completed; r.completed = e.completed;
r.rrule = e.rrule; r.rrule = e.rrule;
r.exdate = e.exdate; r.exdate = e.exdate;
r.recurrenceId = e.recurrenceId;
r.reminder = e.reminder; r.reminder = e.reminder;
r.reminders = e.reminders.slice(); r.reminders = e.reminders.slice();
return r; return r;
@@ -345,6 +358,16 @@ export class SyncEngine {
await EventDb.clearDirty(context, e.id, ''); await EventDb.clearDirty(context, e.id, '');
} }
} }
// ⭐ 顺带清理"同一资源被重复插入"的冗余行(旧版 applyRemote 判重缺陷遗留的脏数据)。
// 纯本地去重、不推服务器;放在这里是因为它是每次同步都会经过的收尾步骤。
try {
const dup: number = await EventDb.dedupeDuplicateRows(context);
if (dup > 0) {
LogUtil.write(`清理本地重复行:${dup} 条(同一资源被插了多次)`);
}
} catch (err) {
// 忽略:去重失败不影响同步
}
} }
/** /**
@@ -5,10 +5,10 @@
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { calendarManager } from '@kit.CalendarKit'; import { calendarManager } from '@kit.CalendarKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { AccountStore, DavAccount } from './AccountStore';
import { EventDb, LocalEvent } from './EventDb'; import { EventDb, LocalEvent } from './EventDb';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
export class SystemCalendarImport { export class SystemCalendarImport {
/** /**
@@ -16,6 +16,15 @@ export class SystemCalendarImport {
* 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。 * 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。
* 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程), * 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程),
* 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。 * 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。
*
* ⚠️ 另有两层防重复(09-18 补):
* 1. **跳过本应用镜像出去的账户**`synccalendar.mirror.*`)——它们的 type 也是 LOCAL
* 但内容是我们从 CalDAV 写进去的,再导进备份本会让同一条日程在服务器出现两份。
* 2. 导进备份本的 uid 统一带 `syscal-` 前缀,出站镜像会跳过该前缀 → 备份本不会被镜像回系统日历。
*
* ⚠️ 目标本用**稳定标识 bookId** 解析(`SystemCalendarMirror.backupTargetRef`):
* 用户「取消勾选」目标本后序号会整体前移,老版本按 `accId_序号` 定位会**导进另一个本**。
* 现在解析不到即视为失效,并把「备份到 CalDAV」模式**自动关回仅显示**。
*/ */
static async importIfNeeded(context: common.Context): Promise<number> { static async importIfNeeded(context: common.Context): Promise<number> {
try { try {
@@ -23,30 +32,42 @@ export class SystemCalendarImport {
if (mode !== 'backup') { if (mode !== 'backup') {
return 0; return 0;
} }
const calKey: string = await AppSettings.getBackupCalKey(context); // ⭐ 目标本一律走 `backupTargetRef()`:内部用**稳定标识 bookId** 解析,
if (calKey === '') { // 并兼做「老 calKey 值 → bookId」的一次性迁移。
LogUtil.write('系统日历备份:未选择目标日历本,跳过导入'); // 解析不到时它会**自动把 sys_cal_mode 关回 display**(用户明确要求的行为):
// 目标本被取消同步/账号被删后,绝不能"随便挑一个本"把系统日历导进去。
const ref: BookRef | undefined = await SystemCalendarMirror.backupTargetRef(context);
if (ref === undefined) {
// 未选择目标本(或刚被判失效并关闭)→ 跳过。原因已由 backupTargetRef 写进日志。
LogUtil.write('系统日历备份:当前无有效目标日历本,跳过导入');
return 0; return 0;
} }
// 定位目标 CalDAV 日历本 const calKey: string = ref.calKey; // 仅用于查本地库 / 落库
const accounts: DavAccount[] = await AccountStore.loadAll(context); const targetHref: string = ref.href;
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];
}
}
}
if (targetHref === '') { if (targetHref === '') {
LogUtil.write(`系统日历备份:目标日历本 ${calKey} 不存在(账号可能已删除),跳过导入`); LogUtil.write(`系统日历备份:目标日历本 ${ref.bookId} 缺少集合 URL,跳过导入`);
return 0; return 0;
} }
const uiContext = context as common.UIAbilityContext; const uiContext = context as common.UIAbilityContext;
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext); const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars(); 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 imported: number = 0;
let skippedCal: number = 0; let skippedCal: number = 0;
let skippedMirror: number = 0; // 本应用镜像出去的账户(不能导进备份本,否则重复)
for (const cal of calendars) { for (const cal of calendars) {
try { try {
// 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌 // 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌
@@ -55,6 +76,13 @@ export class SystemCalendarImport {
skippedCal++; skippedCal++;
continue; continue;
} }
// ⚠️ 关键:跳过**本应用自己镜像出去的账户**(type 同样 LOCAL,但内容是我们从 CalDAV 写进去的)。
// 若一并导入备份本,同一条日程会在服务器里出现两份(原始本一份、备份本一份)→ 数据非常乱。
// 这些账户的改动由 MirrorInbound 负责回写到它**原本所属**的日历本,不走备份通道。
if (SystemCalendarMirror.isMirrorAccountName(account.name)) {
skippedMirror++;
continue;
}
const events: calendarManager.Event[] = await cal.getEvents(); const events: calendarManager.Event[] = await cal.getEvents();
for (const ev of events) { for (const ev of events) {
if (ev.id === undefined) { if (ev.id === undefined) {
@@ -86,8 +114,9 @@ export class SystemCalendarImport {
// 单个日历读取失败不影响其余 // 单个日历读取失败不影响其余
} }
} }
if (imported > 0 || skippedCal > 0) { if (imported > 0 || skippedCal > 0 || skippedMirror > 0) {
LogUtil.write(`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过账户日历 ${skippedCal} 个)`); LogUtil.write(
`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过非本地账户 ${skippedCal} 个、镜像账户 ${skippedMirror} 个)`);
} }
return imported; return imported;
} catch (err) { } catch (err) {
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -1,6 +1,10 @@
// entry/src/main/ets/pages/EditAccountPage.ets // entry/src/main/ets/pages/EditAccountPage.ets
// 编辑账号:查看/重选该账号下的日历本、修改账户名 // 编辑账号:查看/重选该账号下的日历本、修改账户名
// 保存后清理失效日历本的本地数据,并触发一次重新同步 // 保存后清理失效日历本的本地数据,并触发一次重新同步
//
// ⚠️ 取消勾选某个日历本会改变**后面所有本的序号**(calKey = accId_序号)。因此保存时还要检查:
// 被取消的那个本是不是「系统日历 → CalDAV」的备份目标 —— 是的话当场把该功能关闭,
// 否则用户会以为备份还在工作,实际却可能把系统日程导进别的本(用户实测反馈)。
import { router } from '@kit.ArkUI'; import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { buffer } from '@kit.ArkTS'; import { buffer } from '@kit.ArkTS';
@@ -8,6 +12,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore } from '../common/AccountStore'; import { DavAccount, AccountStore } from '../common/AccountStore';
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient'; import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
import { EventDb } from '../common/EventDb'; import { EventDb } from '../common/EventDb';
import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
/** /**
@@ -243,8 +248,15 @@ struct EditAccountPage {
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`); selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
await EventDb.pruneAccountEvents(context, target.id, validKeys); await EventDb.pruneAccountEvents(context, target.id, validKeys);
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id); AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
this.getUIContext().getPromptAction() // ⭐ 若刚被取消勾选的本正是「系统日历 → CalDAV」的备份目标,**当场**判它失效并关闭备份功能。
.showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` }); // 不这么做的话,要等下一次同步才会发现目标没了;这期间开关还显示"备份到 CalDAV"
// 用户会以为备份仍在正常工作,而实际已经导不进去(或更糟:导进了别的本)。
const backupCheck: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context);
this.getUIContext().getPromptAction().showToast({
message: backupCheck.closed
? '原系统日历备份目标已被取消同步,备份功能已自动关闭'
: `已保存,同步 ${selectedItems.length} 个日历本`
});
router.back(); router.back();
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
+17
View File
@@ -20,6 +20,8 @@ import { RruleUtil } from '../common/RruleUtil';
import { IcsUtil } from '../common/IcsUtil'; import { IcsUtil } from '../common/IcsUtil';
import { DavClient, RemoteItem } from '../common/DavClient'; import { DavClient, RemoteItem } from '../common/DavClient';
import { SystemCalendarImport } from '../common/SystemCalendarImport'; import { SystemCalendarImport } from '../common/SystemCalendarImport';
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { MirrorInbound } from '../common/MirrorInbound';
import { ScreenKeeper } from '../common/ScreenKeeper'; import { ScreenKeeper } from '../common/ScreenKeeper';
import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil'; import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil';
@@ -591,6 +593,12 @@ struct Index {
} catch (err) { } catch (err) {
// 导入失败不影响正常同步 // 导入失败不影响正常同步
} }
// 入站对账:把用户在系统日历里的改动读回来置 dirty,**必须在同步之前**,才能借本次同步一起推送
try {
await MirrorInbound.reconcile(context);
} catch (err) {
// 对账失败不影响正常同步
}
for (const acc of this.accounts) { for (const acc of this.accounts) {
if (acc.type !== TYPE_CALDAV) { if (acc.type !== TYPE_CALDAV) {
continue; continue;
@@ -616,6 +624,15 @@ struct Index {
const e = err as BusinessError; const e = err as BusinessError;
failMsg = e.message; 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; this.syncing = false;
await ScreenKeeper.release(context as common.UIAbilityContext); await ScreenKeeper.release(context as common.UIAbilityContext);
if (failMsg !== '') { if (failMsg !== '') {
+439 -25
View File
@@ -9,6 +9,8 @@ import { CalendarDataService } from '../common/CalendarDataService';
import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { BackgroundSyncService } from '../common/BackgroundSyncService';
import { AccountStore, DavAccount } from '../common/AccountStore'; import { AccountStore, DavAccount } from '../common/AccountStore';
import { ReminderService } from '../common/ReminderService'; import { ReminderService } from '../common/ReminderService';
import { BusinessError } from '@kit.BasicServicesKit';
import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { DocViewer } from '../common/DocViewer'; import { DocViewer } from '../common/DocViewer';
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
@@ -16,6 +18,7 @@ const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
/** 日历本选项(备份目标 / 只读标记 / 静音标记共用) */ /** 日历本选项(备份目标 / 只读标记 / 静音标记共用) */
class BackupTarget { class BackupTarget {
calKey: string = ''; calKey: string = '';
bookId: string = ''; // ⭐ 稳定标识(由服务器 href 派生):镜像选中项用它存,不随序号变
label: string = ''; label: string = '';
serverWritable: boolean = true; // 服务器探测结果 serverWritable: boolean = true; // 服务器探测结果
} }
@@ -27,8 +30,9 @@ struct SettingsPage {
@State intervalMinutes: number = 1; @State intervalMinutes: number = 1;
@State backgroundSync: boolean = false; @State backgroundSync: boolean = false;
@State sysMode: string = 'display'; // display | backup @State sysMode: string = 'display'; // display | backup
@State backupKey: string = ''; @State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey
@State backupTargets: BackupTarget[] = []; @State backupTargets: BackupTarget[] = [];
@State backupNotice: string = ''; // 备份目标失效/未选择时的提示文案
@State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读/静音标记管理用) @State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读/静音标记管理用)
@State manualKeys: string[] = []; // 手动标记只读的 calKey @State manualKeys: string[] = []; // 手动标记只读的 calKey
@State mutedKeys: string[] = []; // 提醒静音的 calKey @State mutedKeys: string[] = []; // 提醒静音的 calKey
@@ -40,6 +44,13 @@ struct SettingsPage {
@State docUrl: string = ''; @State docUrl: string = '';
@State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda @State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda
@State displayStyle: string = 'timeline'; // 日程显示方式:timeline(时间轴)| list(列表) @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; private context?: common.Context;
aboutToAppear(): void { aboutToAppear(): void {
@@ -84,6 +95,29 @@ struct SettingsPage {
this.refreshNotifyState(); this.refreshNotifyState();
this.loadBackupSettings(); this.loadBackupSettings();
this.loadAllBooks(); 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); 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; this.allBooks = books;
await this.refreshMirrorCandidates();
}
/**
* 可镜像的日历本 = 全部 DAV 本 **去掉「系统日历备份目标本」**。
* 备份目标本里的日程本来就来自系统日历(uid 前缀 syscal-),再镜像回系统日历就是自我复制。
*/
private async refreshMirrorCandidates(): Promise<void> {
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<void> { private async loadBackupSettings(): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; 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[] = []; const targets: BackupTarget[] = [];
for (const acc of accounts) { for (const acc of accounts) {
if (acc.type !== 'caldav') { if (acc.type !== 'caldav') {
@@ -219,18 +306,67 @@ 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)); this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey));
const saved: string = await AppSettings.getBackupCalKey(this.context); // 补 bookId(稳定标识)—— 选择状态必须按它存
this.backupKey = targets.some((t: BackupTarget): boolean => t.calKey === saved) ? saved : ''; 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<void> { private async saveSysMode(mode: string): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
} }
// "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限 if (mode !== 'backup') {
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( const granted: boolean = await CalendarDataService.requestSystemCalendarPermission(
this.context as common.UIAbilityContext); this.context as common.UIAbilityContext);
if (!granted) { if (!granted) {
@@ -240,28 +376,24 @@ struct SettingsPage {
}); });
return; return;
} }
}
this.sysMode = mode; this.sysMode = mode;
await AppSettings.setSysCalMode(this.context, mode); await AppSettings.setSysCalMode(this.context, mode);
if (mode === 'backup' && this.backupKey === '') { this.backupNotice = '';
if (this.backupTargets.length > 0) { await this.refreshMirrorCandidates(); // 备份模式/目标变化 → 重算可镜像的本(防回灌)
this.backupKey = this.backupTargets[0].calKey;
await AppSettings.setBackupCalKey(this.context, this.backupKey);
}
}
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: mode === 'backup' message: '已开启备份:下次同步时把系统本地日程导入所选日历本'
? '已开启备份:下次同步时把系统本地日程导入所选日历本'
: '已切换为仅显示:不再把系统日程备份到 CalDAV'
}); });
} }
private async saveBackupTarget(calKey: string): Promise<void> { /** 保存备份目标(入参为 **bookId**,稳定标识,不再是会漂移的 calKey) */
private async saveBackupTarget(bookId: string): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
} }
this.backupKey = calKey; this.backupKey = bookId;
await AppSettings.setBackupCalKey(this.context, calKey); await AppSettings.setBackupBookId(this.context, bookId);
this.backupNotice = ''; // 用户显式选定了目标 → 清掉"失效/未选择"提示
await this.refreshMirrorCandidates(); // 备份目标变了 → 可镜像的本也要跟着变(防回灌)
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: '备份目标已更新,下次同步生效' }); .showToast({ message: '备份目标已更新,下次同步生效' });
} }
@@ -291,6 +423,135 @@ struct SettingsPage {
.showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' }); .showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' });
} }
/**
* 「镜像到系统日历」开关。
* ⚠️ 合规约束(《审核指南》7.17):写入系统日历依赖 WRITE_CALENDAR 权限,
* **只在用户主动打开这个开关时申请**;未授权则开关回弹、不保存设置。
* 关闭时会把之前镜像出去的账户一并删掉,避免系统日历里留下"幽灵日程"。
*/
private async saveMirrorEnabled(isOn: boolean): Promise<void> {
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<void> {
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<void> {
if (this.context === undefined) {
return;
}
this.mirrorInbound = isOn;
await AppSettings.setMirrorInbound(this.context, isOn);
this.getUIContext().getPromptAction().showToast({
message: isOn
? '已开启回写:系统日历里的新建/修改/删除,下次同步时传回服务器'
: '已关闭回写:系统日历的改动不再传回服务器'
});
}
/** 手动执行一次镜像(幂等,可反复点) */
private async runMirrorNow(): Promise<void> {
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<void> {
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<void> { private async saveInterval(minutes: number): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
@@ -533,7 +794,8 @@ struct SettingsPage {
this.saveSysMode(mode); this.saveSysMode(mode);
} }
}) })
if (this.sysMode === 'backup') { // ⭐ 备份目标**始终可配置**(不再只在 backup 模式下显示):
// 否则"先选目标才能开启备份"会变成死锁(Select 只在 backup 模式下渲染)。
Text(this.backupTargets.length > 0 Text(this.backupTargets.length > 0
? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份' ? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份'
: '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限') : '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限')
@@ -545,8 +807,8 @@ struct SettingsPage {
Select(this.backupTargets.map((t: BackupTarget): SelectOption => { Select(this.backupTargets.map((t: BackupTarget): SelectOption => {
return { value: t.label } as SelectOption; return { value: t.label } as SelectOption;
}) as SelectOption[]) }) as SelectOption[])
.selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.calKey === this.backupKey)) .selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.bookId === this.backupKey))
.value(this.backupTargets.find((t: BackupTarget): boolean => t.calKey === this.backupKey)?.label .value(this.backupTargets.find((t: BackupTarget): boolean => t.bookId === this.backupKey)?.label
?? '请选择日历本') ?? '请选择日历本')
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
.font({ size: 14 }) .font({ size: 14 })
@@ -555,10 +817,162 @@ struct SettingsPage {
.width('100%') .width('100%')
.onSelect((index: number) => { .onSelect((index: number) => {
if (index >= 0 && index < this.backupTargets.length) { if (index >= 0 && index < this.backupTargets.length) {
this.saveBackupTarget(this.backupTargets[index].calKey); 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'))
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%')
} 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 })
}
}
} }
} }
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
@@ -8,6 +8,8 @@ import { SyncEngine } from '../common/SyncEngine';
import { CardDataService } from '../common/CardDataService'; import { CardDataService } from '../common/CardDataService';
import { ReminderService } from '../common/ReminderService'; import { ReminderService } from '../common/ReminderService';
import { SystemCalendarImport } from '../common/SystemCalendarImport'; import { SystemCalendarImport } from '../common/SystemCalendarImport';
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { MirrorInbound } from '../common/MirrorInbound';
import { AppSettings } from '../common/AppSettings'; import { AppSettings } from '../common/AppSettings';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
@@ -32,6 +34,12 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
} catch (err) { } catch (err) {
// 导入失败不影响正常同步 // 导入失败不影响正常同步
} }
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
try {
await MirrorInbound.reconcile(context);
} catch (err) {
// 对账失败不影响正常同步
}
let ok: number = 0; let ok: number = 0;
let attempted: number = 0; let attempted: number = 0;
for (const acc of accounts) { for (const acc of accounts) {
@@ -58,6 +66,15 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失, // 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失,
// 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。 // 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。
await AccountStore.saveAll(context, accounts); 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 CardDataService.pushToAllForms(context);
await ReminderService.refreshReminders(context); await ReminderService.refreshReminders(context);
+10
View File
@@ -38,6 +38,16 @@
"when": "inuse" "when": "inuse"
} }
}, },
{
"name": "ohos.permission.WRITE_CALENDAR",
"reason": "$string:perm_write_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{ {
"name": "ohos.permission.LOCATION", "name": "ohos.permission.LOCATION",
"reason": "$string:perm_location", "reason": "$string:perm_location",
@@ -18,7 +18,7 @@
}, },
{ {
"name": "perm_write_calendar", "name": "perm_write_calendar",
"value": "写入系统日历,用于保存您创建的日程" "value": "写入系统日历,用于把您选择的 CalDAV 日历本镜像到系统日历,让小艺、桌面日历卡片和手表也能看到您的日程"
}, },
{ {
"name": "perm_read_whole_calendar", "name": "perm_read_whole_calendar",