diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets index d749055..e693588 100644 --- a/entry/src/main/ets/common/CalendarDataService.ets +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -153,11 +153,14 @@ export class CalendarDataService { rows.push(m); } } - // 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复 + // 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。 + // 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本); + // ②只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留) + // 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示" const overrideKeys: string[] = []; for (const e of rows) { - if (e.rrule === '' && e.recurring) { - overrideKeys.push(`${e.uid}_${e.startTime}`); + if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) { + overrideKeys.push(`${e.calKey}_${e.uid}_${e.startTime}`); } } let occTotal: number = 0; @@ -181,7 +184,8 @@ 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.uid}_${occ}`)); + times = occs.filter((occ: number): boolean => + !overrideKeys.includes(`${e.calKey}_${e.uid}_${occ}`)); } } for (const occ of times) { diff --git a/entry/src/main/ets/common/CardDataService.ets b/entry/src/main/ets/common/CardDataService.ets index 0b2f2f7..bf634dd 100644 --- a/entry/src/main/ets/common/CardDataService.ets +++ b/entry/src/main/ets/common/CardDataService.ets @@ -121,13 +121,15 @@ export class CardDataService { const end: number = start + 60 * 86400000; const sources = await CalendarDataService.loadSources(context); const events: DisplayEvent[] = await CalendarDataService.loadEvents(context, start, end, sources); - // 1) 过滤已结束超过 1 小时的;2) 按天分组,组内全天在前、有时间按开始时间排序; + // 1) 过滤:保留"今天 0 点以来"的日程(今天已结束的也显示,否则重复日程的 + // 第一次发生会被隐藏,表现为"第一次不显示、第二次及以后显示"); + // 2) 按天分组,组内全天在前、有时间按开始时间排序; // 3) 平铺输出并给每组第一条打 showDate 标记(日期头只跟在自己日期前面) // 跨天日程:已开始未结束的归到"今天"、按全天显示,不再从开始那天重复显示 - const upcoming: DisplayEvent[] = events - .filter((e: DisplayEvent): boolean => e.endTime >= now.getTime() - 3600000); - const todayKey: number = CardDataService.startOfDay(now.getTime()); + const upcoming: DisplayEvent[] = events + .filter((e: DisplayEvent): boolean => e.endTime >= todayKey); + const dayKey = (ms: number): number => CardDataService.startOfDay(ms); const spansDays = (e: DisplayEvent): boolean => dayKey(e.endTime) > dayKey(e.startTime); diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index cc4a78e..1c8fed4 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -368,8 +368,8 @@ export class EventDb { await store.insert('events', EventDb.toBucket(e)); added++; } else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate - || found.reminder !== r.reminder) { - // 更新(rrule/exdate 变化也更新,兼容老数据回填) + || found.reminder !== r.reminder || found.recurring !== r.recurring) { + // 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填) found.title = r.title; found.description = r.description; found.location = r.location; @@ -417,6 +417,26 @@ export class EventDb { await store.delete(predicates); } + /** + * 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。 + * 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。 + * 这些行会参与重复日程的 override 排除计算,导致重复日程的第一次发生不显示,必须物理删除。 + * 匹配规则为前缀包含(NOT LIKE 'accId_%' 的 AND 组合),宁可少删不会误删有效数据。 + */ + static async pruneOrphanCalKeys(context: common.Context, accIds: string[]): Promise { + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.notEqualTo('cal_key', 'local'); + for (const id of accIds) { + if (id === '') { + continue; + } + predicates.and().notLike('cal_key', `${id}_%`); + } + const removed: number = await store.delete(predicates); + return removed; + } + /** * 重新勾选日历本后清理失效数据: * 删除该账号下 calKey 不在有效列表中的本地日程/待办(calKey = accId_序号,重选后序号会变) diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 428bc45..5a91028 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -91,7 +91,7 @@ export class IcsUtil { current.description = IcsUtil.unescape(value); } else if (propName === 'LOCATION') { current.location = IcsUtil.unescape(value); - } else if (propName === 'RRULE' || propName === 'RECURREIENCE-ID') { + } else if (propName === 'RRULE' || propName === 'RECURRENCE-ID') { current.recurring = true; if (propName === 'RRULE') { current.rrule = value; diff --git a/entry/src/main/ets/common/RruleUtil.ets b/entry/src/main/ets/common/RruleUtil.ets index 877d23c..7a8d59c 100644 --- a/entry/src/main/ets/common/RruleUtil.ets +++ b/entry/src/main/ets/common/RruleUtil.ets @@ -108,6 +108,13 @@ export class RruleUtil { if (targets.length === 0) { targets.push(base.getDay()); } + // RFC 5545:DTSTART 始终是第一次发生——即使它的星期不在 BYDAY 中 + // (例如周二创建的"每周一重复"日程,第一次是周二那次,必须显示) + if (byday.length > 0 && !targets.includes(base.getDay())) { + if (!accept(dtstart)) { + return result; + } + } const anchorWeek: number = RruleUtil.weekStartMs(dtstart); let dayMs: number = baseZeroMs; let guard: number = 0; @@ -129,6 +136,13 @@ export class RruleUtil { let y: number = base.getFullYear(); let m: number = base.getMonth(); let guard: number = 0; + // RFC 5545:DTSTART 始终是第一次发生——即使它的日期不在 BYMONTHDAY 中 + if (bymonthday.length > 0 && + !bymonthday.some((v: number): boolean => v === base.getDate())) { + if (!accept(dtstart)) { + return result; + } + } while (guard < 1500) { guard++; const monthFirst: number = new Date(y, m, 1).getTime(); diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index 0b4f8ec..72d3798 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -201,4 +201,23 @@ export class SyncEngine { } } } + + /** + * 清理孤儿日程行:calKey 不属于任何现有账号(accId_ 前缀)也不是本机日历(local)的历史残留。 + * 典型如账号重建/日历重选后遗留的 `_13`、`acc1789274273124_0_13` 等, + * 这些行会参与重复日程的 override 排除计算,导致重复日程第一次发生不显示。 + * 建议在所有账号同步完成后调用。 + */ + static async pruneOrphanRows(context: common.Context, accounts: DavAccount[]): Promise { + try { + const accIds: string[] = accounts.map((a: DavAccount): string => a.id); + const removed: number = await EventDb.pruneOrphanCalKeys(context, accIds); + if (removed > 0) { + LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`); + } + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`); + } + } } \ No newline at end of file diff --git a/entry/src/main/ets/pages/AccountsPage.ets b/entry/src/main/ets/pages/AccountsPage.ets index 71fde35..b4e990b 100644 --- a/entry/src/main/ets/pages/AccountsPage.ets +++ b/entry/src/main/ets/pages/AccountsPage.ets @@ -76,6 +76,7 @@ struct AccountsPage { if (acc.type === TYPE_CALDAV) { await SyncEngine.withTimeout( SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000); + await SyncEngine.pruneOrphanRows(context, this.accounts); } acc.itemCount = acc.calendarHrefs.length; acc.lastSyncTime = this.formatNow(); diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 58f1117..6a21400 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -3,7 +3,7 @@ // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" import { router } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; -import { BusinessError } from '@kit.BasicServicesKit'; +import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService'; import { SyncEngine } from '../common/SyncEngine'; @@ -12,6 +12,9 @@ import { LunarUtil } from '../common/LunarUtil'; import { CardDataService } from '../common/CardDataService'; import { ReminderService } from '../common/ReminderService'; import { AppSettings } from '../common/AppSettings'; +import { EventDb, LocalEvent } from '../common/EventDb'; +import { RruleUtil } from '../common/RruleUtil'; +import { IcsUtil } from '../common/IcsUtil'; /** 月视图单元格 */ class MonthCell { @@ -284,6 +287,7 @@ struct Index { } await SyncEngine.withTimeout( SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000); + await SyncEngine.pruneOrphanRows(context, this.accounts); acc.lastSyncTime = this.formatNow(); await AccountStore.saveAll(context, this.accounts); this.getUIContext().getPromptAction() @@ -341,6 +345,7 @@ struct Index { } try { await SyncEngine.settleLocalEvents(context); + await SyncEngine.pruneOrphanRows(context, this.accounts); await AccountStore.saveAll(context, this.accounts); } catch (err) { const e = err as BusinessError; @@ -608,7 +613,7 @@ struct Index { }) } - /** 切换视图:进入列表视图时按需加载(今天 ~ 未来2年,已结束的不显示) */ + /** 切换视图:进入列表视图时按需加载(今天整天 ~ 未来2年,昨天之前已结束的不显示) */ private async handleModeSwitch(key: string): Promise { this.mode = key; if (key === 'agenda') { @@ -630,8 +635,9 @@ struct Index { const end: number = now + 730 * 86400000; try { const raw: DisplayEvent[] = await CalendarDataService.loadEvents(context, start, end, this.sources); - // 只保留未结束的(跨天进行中的保留);跨天且已开始的归到今天,与卡片一致 - this.agendaEvents = raw.filter((e: DisplayEvent): boolean => e.endTime >= now - 3600000); + // 保留"今天 0 点以来"的全部日程:今天已结束的也显示(否则重复日程的第一次发生会被隐藏), + // 昨天及更早且已结束的不显示;跨天进行中的归到今天,与卡片一致 + this.agendaEvents = raw.filter((e: DisplayEvent): boolean => e.endTime >= start); this.agendaGroupsData = this.buildAgendaGroups(this.agendaEvents); this.agendaStale = false; LogUtil.write(`列表视图加载:${this.agendaEvents.length} 条(今天~未来2年),分组 ${this.agendaGroupsData.length} 组`); @@ -752,6 +758,9 @@ struct Index { } .width('100%') .padding({ left: 20, right: 20, top: 6 }) + .gesture(LongPressGesture().onAction(() => { + this.showOccurrenceDebug(this.selectedDate); + })) this.eventList() } @@ -759,6 +768,110 @@ struct Index { .layoutWeight(1) } + /** 调试(临时):长按月视图日期标题,检查重复日程在该日的首次发生情况 */ + private async showOccurrenceDebug(dateMs: number): Promise { + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + return; + } + try { + const dayStart: number = this.startOfDay(dateMs); + const dayEnd: number = dayStart + 86400000; + const winStart: number = dayStart - 365 * 86400000; + const winEnd: number = dayEnd + 365 * 86400000; + const rows: LocalEvent[] = await EventDb.queryRange(context, winStart, winEnd); + const sources = await CalendarDataService.loadSources(context); + const visibleKeys: string[] = sources.filter((s: CalSource): boolean => s.visible) + .map((s: CalSource): string => s.calKey); + const masters = new Map(); + const overrides = new Map(); + for (const r of rows) { + if (r.rrule !== '') { + masters.set(r.uid, r); + } else { + const arr = overrides.get(r.uid); + if (arr === undefined) { + overrides.set(r.uid, [r]); + } else { + arr.push(r); + } + } + } + const p = (n: number): string => (n < 10 ? '0' + n : String(n)); + const fmt = (ms: number): string => { + const d = new Date(ms); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; + }; + const lines: string[] = [`调试 ${this.fmtDateCn(dateMs)}`, `visibleKeys=${visibleKeys.join(',')}`]; + let idx: number = 0; + for (const m of masters.values()) { + if (idx >= 6) { + lines.push('…(更多系列省略)'); + break; + } + // 只列出与该日期相关的系列:主行开始日在该日±90天内,或展开命中该日 + const exNums: number[] = []; + if (m.exdate !== '') { + for (const raw of m.exdate.split(';')) { + const t = IcsUtil.parseTime(raw, !raw.includes('T')); + if (t !== null) { + exNums.push(t.time); + } + } + } + const occs: number[] = RruleUtil.expand(m.rrule, m.startTime, m.startTime, winEnd, exNums, 400); + const hitsDay: boolean = occs.some((o: number): boolean => o >= dayStart && o < dayEnd); + const nearStart: boolean = Math.abs(m.startTime - dayStart) <= 90 * 86400000; + if (!hitsDay && !nearStart) { + continue; + } + idx++; + const hasFirst: boolean = occs.some((o: number): boolean => o === m.startTime); + const mVis: boolean = visibleKeys.includes(m.calKey); + lines.push(`◇ ${m.title}`); + lines.push(` uid=${m.uid.substring(0, 8)} 主行=${fmt(m.startTime)} rec=${m.recurring ? 1 : 0} calKey=${m.calKey} vis=${mVis ? 1 : 0} dirty=${m.dirty ? 1 : 0}`); + lines.push(` rrule=${m.rrule === '' ? '(空!)' : m.rrule}`); + lines.push(` exdate=${m.exdate === '' ? '无' : m.exdate}`); + lines.push(` 展开条数=${occs.length} 含首次=${hasFirst ? '是' : '否'} 首次=${occs.length > 0 ? fmt(occs[0]) : '无'} 该日发生=${hitsDay ? '是' : '否'}`); + const ovs = overrides.get(m.uid) ?? []; + if (ovs.length === 0) { + lines.push(' 覆盖实例: 无'); + } else { + for (const o of ovs.slice(0, 5)) { + const oVis: boolean = visibleKeys.includes(o.calKey); + lines.push(` 覆盖: ${fmt(o.startTime)}~${fmt(o.endTime)} rec=${o.recurring ? 1 : 0} calKey=${o.calKey} vis=${oVis ? 1 : 0} dirty=${o.dirty ? 1 : 0}`); + } + if (ovs.length > 5) { + lines.push(` 覆盖共 ${ovs.length} 条`); + } + } + } + if (lines.length <= 2) { + lines.push('(该日期附近没有重复主事件)'); + } + const text: string = lines.join('\n').substring(0, 3500); + this.getUIContext().showAlertDialog({ + title: '重复日程调试', + message: text, + autoCancel: true, + alignment: DialogAlignment.Center, + primaryButton: { + value: '复制', + action: (): void => { + const data = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); + pasteboard.getSystemPasteboard().setData(data); + } + }, + secondaryButton: { + value: '关闭', + action: (): void => {} + } + }); + } catch (err) { + // 调试失败不影响主流程 + } + } + /** 周视图整周切换(左滑下一周、右滑上一周);跨月时自动重载数据 */ private switchWeek(delta: number): void { this.selectedDate += delta * 7 * 86400000; diff --git a/entry/src/main/ets/syncwork/SyncWorkAbility.ets b/entry/src/main/ets/syncwork/SyncWorkAbility.ets index 8641e64..526f3ec 100644 --- a/entry/src/main/ets/syncwork/SyncWorkAbility.ets +++ b/entry/src/main/ets/syncwork/SyncWorkAbility.ets @@ -39,6 +39,7 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { } } await SyncEngine.settleLocalEvents(context); + await SyncEngine.pruneOrphanRows(context, accounts); // 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见 await CardDataService.pushToAllForms(context); await ReminderService.refreshReminders(context);