diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index 88c73a4..c135183 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -624,6 +624,116 @@ export class EventDb { await store.delete(predicates); } + /** + * ⭐ 取同一个 UID 在同一个日历本下的**全部行**(主事件 + 覆盖实例)。 + * 用于"重复日程的单次/后续/全部"编辑与删除:重建整个系列后一次性推送。 + * 只返回未删除的行。 + */ + static async queryByUid(context: common.Context, calKey: string, uid: string): Promise { + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid); + predicates.orderByAsc('id'); + const rs = await store.query(predicates); + const list: LocalEvent[] = []; + try { + while (rs.goToNextRow()) { + const e = EventDb.fromRow(rs); + if (!e.deleted) { + list.push(e); + } + } + } finally { + rs.close(); + } + return list; + } + + /** 物理删除某 uid 在某个日历本下的全部行("删除全部"用) */ + static async purgeByUid(context: common.Context, calKey: string, uid: string): Promise { + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid); + return await store.delete(predicates); + } + + /** 物理删除某 uid 在某个日历本下、开始时间 >= from 的全部行("此次及以后"删除用) */ + static async purgeByUidFrom(context: common.Context, calKey: string, uid: string, + from: number): Promise { + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid) + .and().greaterThanOrEqualTo('start_time', from); + return await store.delete(predicates); + } + + /** + * ⭐ 把某个 uid 系列下、开始时间 >= from 的行搬到另一个 uid("此次及以后"改期的实现): + * 主事件被截断(加 UNTIL)后,剩余部分作为**新的独立系列**存在,否则会被原系列覆盖。 + * 返回被搬迁的行数。 + */ + static async reassignUidFrom(context: common.Context, calKey: string, oldUid: string, + newUid: string, newPath: string, from: number): Promise { + const store = await EventDb.getDb(context); + const bucket: relationalStore.ValuesBucket = { + 'uid': newUid, + 'remote_path': newPath, + 'etag': '', + 'dirty': 1 + }; + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid) + .and().greaterThanOrEqualTo('start_time', from); + return await store.update(bucket, predicates); + } + + /** 给单行写入 EXDATE("仅删除这一次"→ 排除主事件的该次发生) */ + static async setExdate(context: common.Context, id: number, exdate: string): Promise { + const store = await EventDb.getDb(context); + const bucket: relationalStore.ValuesBucket = { 'exdate': exdate, 'dirty': 1 }; + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('id', id); + await store.update(bucket, predicates); + } + + /** + * ⭐ 把某 uid 系列下、**RECURRENCE-ID >= from** 的覆盖实例行搬到新 uid + * ("此次及以后"拆分系列时用;主事件行 recurrenceId=0,天然不会被命中)。 + */ + static async reassignOverridesFrom(context: common.Context, calKey: string, oldUid: string, + newUid: string, newPath: string, from: number): Promise { + const store = await EventDb.getDb(context); + const bucket: relationalStore.ValuesBucket = { + 'uid': newUid, + 'remote_path': newPath, + 'etag': '', + 'dirty': 1 + }; + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid) + .and().greaterThanOrEqualTo('recurrence_id', from); + return await store.update(bucket, predicates); + } + + /** + * ⭐ 给某个 uid 系列(指定日历本)的主事件写入 RRULE("此次及以后"截断/改期时用)。 + * 只作用于有 RRULE 的主事件行。 + */ + static async setRruleForUid(context: common.Context, calKey: string, uid: string, + rrule: string): Promise { + const store = await EventDb.getDb(context); + const bucket: relationalStore.ValuesBucket = { 'rrule': rrule, 'dirty': 1 }; + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid) + .and().notEqualTo('rrule', ''); + await store.update(bucket, predicates); + } + + /** 新增一行(供拆分系列时插入新主事件;已置 dirty) */ + static async insertRow(context: common.Context, e: LocalEvent): Promise { + return await EventDb.insertLocal(context, e); + } + /** * 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。 * 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。 diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 8464d1b..078e3bc 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -236,6 +236,42 @@ export class IcsUtil { * 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。 */ static build(e: LocalEvent): string { + const now: string = IcsUtil.fmtUtcNow(); + return 'BEGIN:VCALENDAR\r\n' + + 'VERSION:2.0\r\n' + + 'PRODID:-//SyncCalendar//Calendar//CN\r\n' + + 'CALSCALE:GREGORIAN\r\n' + + IcsUtil.vevent(e, now) + + 'END:VCALENDAR\r\n'; + } + + /** + * ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。 + * + * 为什么必须合在一起:CalDAV 里"重复日程的某一次发生被单独修改"的标准表示法是 + * 在**同一个 .ics 资源**里放第二个 VEVENT,带 `RECURRENCE-ID` 指向被覆盖的那一次。 + * 早先 App 直接把每个实例当独立资源 PUT,会破坏服务器上的整个系列,所以那时干脆 + * 禁止了单次修改(见旧版"重复日程的单次修改暂不支持")。现在改为重建整份系列后推送。 + * + * @param master 主事件行(含 RRULE / EXDATE) + * @param overrides 覆盖实例行(rrule 为空、recurrenceId > 0),已排除 deleted + */ + static buildSeries(master: LocalEvent, overrides: LocalEvent[]): string { + const now: string = IcsUtil.fmtUtcNow(); + let out: string = 'BEGIN:VCALENDAR\r\n' + + 'VERSION:2.0\r\n' + + 'PRODID:-//SyncCalendar//Calendar//CN\r\n' + + 'CALSCALE:GREGORIAN\r\n' + + IcsUtil.vevent(master, now); + for (const ov of overrides) { + out += IcsUtil.vevent(ov, now); + } + out += 'END:VCALENDAR\r\n'; + return out; + } + + /** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */ + private static vevent(e: LocalEvent, now: string): string { const pad = (n: number): string => n < 10 ? '0' + n : String(n); const fmtUtc = (ms: number): string => { const d = new Date(ms); @@ -256,24 +292,88 @@ export class IcsUtil { dtstart = 'DTSTART:' + fmtUtc(e.startTime); dtend = 'DTEND:' + fmtUtc(e.endTime); } - const now: string = fmtUtc(Date.now()); - return 'BEGIN:VCALENDAR\r\n' + - 'VERSION:2.0\r\n' + - 'PRODID:-//SyncCalendar//Calendar//CN\r\n' + - 'CALSCALE:GREGORIAN\r\n' + - 'BEGIN:VEVENT\r\n' + + // ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它 + // 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。 + let recId: string = ''; + if (e.rrule === '' && e.recurrenceId > 0) { + recId = e.isAllDay + ? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n` + : `RECURRENCE-ID:${fmtUtc(e.recurrenceId)}\r\n`; + } + // ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加) + let exdate: string = ''; + if (e.exdate !== '') { + const vals: string[] = []; + for (const raw of e.exdate.split(';')) { + const t = IcsUtil.parseTime(raw, !raw.includes('T')); + if (t !== null) { + vals.push(t.allDay ? fmtDate(t.time) : fmtUtc(t.time)); + } + } + if (vals.length > 0) { + exdate = e.isAllDay + ? `EXDATE;VALUE=DATE:${vals.join(',')}\r\n` + : `EXDATE:${vals.join(',')}\r\n`; + } + } + return 'BEGIN:VEVENT\r\n' + `UID:${e.uid}\r\n` + `DTSTAMP:${now}\r\n` + `${dtstart}\r\n` + `${dtend}\r\n` + + recId + (e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') + + exdate + `SUMMARY:${IcsUtil.escape(e.title)}\r\n` + (e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') + (e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') + IcsUtil.valarms(e.reminders.length > 0 ? e.reminders : (e.reminder > 0 ? [e.reminder] : [])) + - 'END:VEVENT\r\n' + - 'END:VCALENDAR\r\n'; + 'END:VEVENT\r\n'; + } + + /** 当前时刻的 UTC 形式(DTSTAMP) */ + private static fmtUtcNow(): string { + const pad = (n: number): string => n < 10 ? '0' + n : String(n); + const d = new Date(); + return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + + `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; + } + + /** + * 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID 的 ICS 时间串。 + * @param dateOnly 全天日程用 `YYYYMMDD`,否则用 UTC 的 `YYYYMMDDTHHMMSSZ` + */ + static fmtIcsTime(ms: number, dateOnly: boolean): string { + const pad = (n: number): string => n < 10 ? '0' + n : String(n); + const d = new Date(ms); + if (dateOnly) { + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + } + return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + + `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; + } + + /** + * ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。 + * - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现) + * - UNTIL 用 UTC 形式;`untilMs` 一般是"该次发生的开始时刻 − 1 秒" + */ + static rruleWithUntil(rrule: string, untilMs: number): string { + const pad = (n: number): string => n < 10 ? '0' + n : String(n); + const d = new Date(untilMs); + const until: string = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + + `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; + const kept: string[] = []; + for (const part of rrule.split(';')) { + const k: string = part.split('=')[0].trim().toUpperCase(); + if (k === 'UNTIL' || k === 'COUNT' || k === '') { + continue; + } + kept.push(part); + } + kept.push(`UNTIL=${until}`); + return kept.join(';'); } /** 由提醒分钟数组生成多个 VALARM(已规范化去重) */ diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index 54484f1..d84ede5 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -404,6 +404,9 @@ export class SyncEngine { const dirty: LocalEvent[] = await EventDb.getDirty(context); const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href)); LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`); + // ⭐ 同一个重复系列(uid + 日历本)在一次推送里只处理一次:整份 .ics(主事件 + 各覆盖实例) + // 一起重建后推送,避免"先 PUT 覆盖实例、后 PUT 主事件"互相覆盖。 + const pushedSeries: Set = new Set(); for (const e of mine) { // 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推) const bookIdx: number = acc.calendarHrefs.indexOf(e.href); @@ -417,15 +420,17 @@ export class SyncEngine { await EventDb.clearDirty(context, e.id, e.etag); continue; } - if (e.recurring && e.rrule === '') { - // 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持 - await EventDb.clearDirty(context, e.id, e.etag); - LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`); + // ⭐ 重复系列(含覆盖实例):按 uid 取全系列行,重建整份 .ics 后一次性 PUT。 + // 旧版对"覆盖实例"直接跳过(不推送),导致 App 里的单次改期/删除无法上传到服务器; + // 现在支持标准写法(同资源内多 VEVENT + RECURRENCE-ID)。 + const seriesKey: string = `${e.calKey}|${e.uid}`; + if ((e.recurring || e.rrule !== '') && !pushedSeries.has(seriesKey)) { + pushedSeries.add(seriesKey); + await SyncEngine.pushSeries(context, acc, auth, e); continue; } - if (e.recurring) { - // 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送 - LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`); + if ((e.recurring || e.rrule !== '') && pushedSeries.has(seriesKey)) { + continue; // 该系列已在上面整体推送过 } const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`; try { @@ -451,6 +456,75 @@ export class SyncEngine { } } + /** + * ⭐ 推送一个"重复系列":取该 uid 下的主事件与全部覆盖实例,合成整份 .ics 后 PUT。 + * + * - 主事件行若被标记删除(deleted)→ 视为"整个系列删除",DELETE 整个资源并清掉本地该 uid 全部行。 + * - 否则重建(主事件可能带上了 EXDATE / 被截断的 UNTIL,覆盖实例带 RECURRENCE-ID), + * 一次性 PUT 覆盖服务器上的同一资源;成功后清掉本系列全部行的 dirty。 + */ + private static async pushSeries(context: common.Context, acc: DavAccount, auth: string, + trigger: LocalEvent): Promise { + const calKey: string = trigger.calKey; + const uid: string = trigger.uid; + const rows: LocalEvent[] = await EventDb.queryByUid(context, calKey, uid); + // 主事件:优先取带 RRULE 的行;退而取最早一行 + let master: LocalEvent | null = null; + for (const r of rows) { + if (r.rrule !== '') { + master = r; + break; + } + } + if (master === null && rows.length > 0) { + master = rows[0]; + } + if (master === null) { + return; + } + const href: string = master.href; + const path: string = master.remotePath !== '' ? master.remotePath : encodeURIComponent(uid) + '.ics'; + const url: string = href.endsWith('/') ? href + path : `${href}/${path}`; + // 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行 + if (master.deleted) { + try { + await DavClient.deleteRemote(url, auth); + } catch (err) { + if (err instanceof DavHttpError && (err as DavHttpError).status === 401) { + throw err as Error; + } + LogUtil.write(`推送删除重复系列失败:uid=${uid} → ${(err as Error).message}`); + return; + } + await EventDb.purgeByUid(context, calKey, uid); + LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`); + return; + } + const overrides: LocalEvent[] = []; + for (const r of rows) { + if (r.id !== master.id && r.rrule === '' && r.recurrenceId > 0) { + overrides.push(r); + } + } + overrides.sort((a: LocalEvent, b: LocalEvent): number => a.recurrenceId - b.recurrenceId); + const ics: string = IcsUtil.buildSeries(master, overrides); + try { + const etag: string = await DavClient.putEvent(url, auth, ics); + // 清掉本系列全部行的 dirty(第一条写回 etag) + let first: boolean = true; + for (const r of rows) { + await EventDb.clearDirty(context, r.id, first ? etag : ''); + first = false; + } + LogUtil.write(`推送重复系列(标题已脱敏)uid=${uid} → ${url}(${ics.length} 字节,覆盖实例=${overrides.length})`); + } catch (err) { + if (err instanceof DavHttpError && (err as DavHttpError).status === 401) { + throw err as Error; + } + LogUtil.write(`推送重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`); + } + } + /** * 清理本机虚拟日历(calKey=local)的待推送状态: * 本机事件不参与 DAV 同步,直接落地 diff --git a/entry/src/main/ets/common/TimelineUtil.ets b/entry/src/main/ets/common/TimelineUtil.ets index 2e5a484..884646d 100644 --- a/entry/src/main/ets/common/TimelineUtil.ets +++ b/entry/src/main/ets/common/TimelineUtil.ets @@ -272,38 +272,43 @@ export class TimelineUtil { return out; } + /** 时间轴"基准可视区"的起止小时:8:00 ~ 18:00 **恒定显示**(与用户确认)。 */ + static readonly BASE_START_HOUR: number = 8; + static readonly BASE_END_HOUR: number = 18; + /** - * 计算"需要显示的小时区间"(视窗截断): - * - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点) - * - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见 + * 计算"需要显示的小时区间"(视窗截断)。规则(与用户确认): + * - **8:00 ~ 18:00 恒定显示**(即使当天没有任何日程,也至少显示这一段,避免"只有一条日程 + * 就只显示那 1 小时"的奇怪观感); + * - 若某天在 **8 点之前**有日程 → 起点向前扩到最早日程所在整点;否则 8 点之前的刻度不显示; + * - 若某天在 **18 点之后**有日程 → 终点向后扩到最晚日程结束时刻向上取整;否则 18 点之后的刻度不显示。 + * - includeNowHour=true(今天)时,把当前时刻所在小时并进来以保证红线可见 —— 但**仍遵守上面的 + * "无日程则不显示"约束**:只有当该侧本来就有日程(起点已 <8 / 终点已 >18)时才允许继续向外扩, + * 否则不会因为"当前时刻"而把 8 点前 / 18 点后的空档重新显示出来。 * 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。 */ static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange { const rg = new ViewRange(); - let s: number = -1; - let e: number = -1; + // 基准:8~18 恒显示 + let s: number = TimelineUtil.BASE_START_HOUR; + let e: number = TimelineUtil.BASE_END_HOUR; for (const b of blocks) { const bh: number = Math.floor(b.topRatio * 24); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); - if (s < 0 || bh < s) { + if (bh < s) { s = bh; } - if (e < 0 || eh > e) { + if (eh > e) { e = eh; } } - if (s < 0) { - s = 8; - } - if (e < 0) { - e = 20; - } if (includeNowHour) { const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24); - if (nh < s) { + // 只有"该侧本来就有日程"时才允许把当前小时并进来(否则会违反"8 点前/18 点后无日程则不显示") + if (nh < s && s < TimelineUtil.BASE_START_HOUR) { s = nh; } - if (nh + 1 > e) { + if (nh + 1 > e && e > TimelineUtil.BASE_END_HOUR) { e = nh + 1; } } diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index be71d91..6f158e3 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -11,6 +11,15 @@ import { DavClient } from '../common/DavClient'; import { SyncEngine } from '../common/SyncEngine'; import { ReminderService } from '../common/ReminderService'; import { LogUtil } from '../common/LogUtil'; +import { IcsUtil } from '../common/IcsUtil'; +import { RruleUtil } from '../common/RruleUtil'; + +/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */ +class SeriesBounds { + thisOcc: number = 0; // 本次目标的(原始)发生时刻 + beforeThis: number = 0; // 此次之前一点(截断 UNTIL 用),= 上一次与本次的中点或本次-1s + nextOcc: number = 0; // 下一次发生时刻(0 = 已是最后一次) +} /** 可选的日历本 */ class BookChoice { @@ -41,6 +50,12 @@ struct EventEditPage { @State pickDate: Date = new Date(); @State pickHour: number = 9; @State pickMin: number = 0; + /** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */ + @State scope: string = 'all'; + @State scopeConfirmShow: boolean = false; // 保存重复日程时的范围选择弹层 + private pendingDelete: boolean = false; // 该弹层是"保存"还是"删除"流程 + /** 本次编辑的"目标发生时刻"(未改期时 = 原发生时刻;用户改了开始时间 → 新时刻) */ + private occStartMs: number = 0; private event: LocalEvent | null = null; aboutToAppear(): Promise { @@ -92,6 +107,8 @@ struct EventEditPage { this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none'; this.reminderList = loaded.reminders.length > 0 ? loaded.reminders : (loaded.reminder > 0 ? [loaded.reminder] : []); + // ⭐ 目标发生时刻:优先用 RECURRENCE-ID(覆盖行=被改写的原始时刻),否则用行本身开始时间 + this.occStartMs = loaded.recurrenceId > 0 ? loaded.recurrenceId : loaded.startTime; return; } } @@ -226,13 +243,182 @@ struct EventEditPage { return true; } - private async save(): Promise { - if (this.isSaving || !this.validate()) { + /** + * 当前编辑的是不是一个"重复系列中的某一次发生"? + * - 主事件行(rrule 非空):用户点开的可能就是第一次发生或其展开实例 → 需要询问范围 + * - 覆盖实例行(recurring && rrule==''):明确就是被单独改写过的那一次 → 需要询问范围 + * 非重复的普通日程返回 false,直接按原逻辑保存/删除。 + */ + private isSeriesOccurrence(): boolean { + if (this.event === null) { + return false; + } + return this.event.recurring || this.event.rrule !== ''; + } + + /** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行) */ + private async loadMaster(context: common.Context): Promise { + if (this.event === null) { + return null; + } + const rows: LocalEvent[] = await EventDb.queryByUid(context, this.event.calKey, this.event.uid); + for (const r of rows) { + if (r.rrule !== '') { + return r; + } + } + return this.event; + } + + /** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分) */ + private async seriesBoundaries(context: common.Context): Promise { + const b = new SeriesBounds(); + const occ: number = this.occStartMs; + b.thisOcc = occ; + const master: LocalEvent | null = await this.loadMaster(context); + if (master === null) { + b.beforeThis = occ - 1000; + b.nextOcc = 0; + return b; + } + const exNums: number[] = []; + if (master.exdate !== '') { + for (const raw of master.exdate.split(';')) { + const t = IcsUtil.parseTime(raw, !raw.includes('T')); + if (t !== null) { + exNums.push(t.time); + } + } + } + // 展开窗口:主事件开始 ~ "该次"之后一年的发生,用于找"前后相邻"的两次 + const winEnd: number = occ + 366 * 86400000; + const occs: number[] = RruleUtil.expand(master.rrule, master.startTime, + master.startTime, winEnd, exNums, 3000); + let prev: number = 0; + let next: number = 0; + for (const o of occs) { + if (o < occ) { + prev = o; + } else if (o > occ) { + next = o; + break; + } + } + b.beforeThis = prev > 0 ? (prev + occ) / 2 : (occ - 1000); + b.nextOcc = next; + return b; + } + + /** + * ⭐ 按用户选择的"重复日程编辑范围"落库: + * - all :原逻辑,直接改这一行(若是覆盖实例则改覆盖行),整系列由 pushSeries 重建 + * - one :单次改写 —— + * · 原行是覆盖实例 → 直接更新它 + * · 原行是主事件(用户点开了系列)→ 为主事件加 EXDATE 排除该次,另插一条覆盖行 + * - future :此次及以后 —— 先把主事件的 RRULE 截断到"此次之前"(加 UNTIL), + * 再把"此次及以后"的内容作为**新的独立系列**(新 uid)插入。 + */ + private async applySeriesEdit(context: common.Context, e: LocalEvent, isNew: boolean, + scope: string): Promise { + const target: LocalEvent = e; + if (isNew || !this.isSeriesOccurrence() || this.event === null) { + if (isNew) { + await EventDb.insertLocal(context, target); + } else { + await EventDb.updateLocal(context, target); + } return; } - // 仅阻止"单次覆盖实例"(RECURRENCE-ID)的修改;重复主事件(含 RRULE)允许编辑 - if (this.event !== null && this.event.recurring && this.event.rrule === '') { - this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则'; + const cur: LocalEvent = this.event; + const bounds: SeriesBounds = await this.seriesBoundaries(context); + if (scope === 'all') { + // 全部:直接更新当前行。若当前行是某次覆盖实例 → 保留其 recurrenceId 改内容; + // 若当前行是主事件 → 更新主事件(DTSTART 若被改 → 整系列平移,符合"修改全部"直觉) + await EventDb.updateLocal(context, target); + return; + } + if (scope === 'one') { + if (cur.rrule === '' && cur.recurrenceId > 0) { + // 已经是覆盖实例 → 直接改它 + await EventDb.updateLocal(context, target); + return; + } + // 主事件上的"仅此次":加 EXDATE 排除该次 + 插入覆盖行 + const master: LocalEvent | null = await this.loadMaster(context); + if (master === null) { + return; + } + const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';'); + const occIcs: string = IcsUtil.fmtIcsTime(bounds.thisOcc, master.isAllDay); + if (!exList.includes(occIcs)) { + exList.push(occIcs); + } + await EventDb.setExdate(context, master.id, exList.join(';')); + // 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻 + const ov = new LocalEvent(); + ov.uid = master.uid; + ov.calKey = master.calKey; + ov.href = master.href; + ov.remotePath = master.remotePath; + ov.title = target.title; + ov.description = target.description; + ov.location = target.location; + ov.startTime = target.startTime; + ov.endTime = target.endTime; + ov.isAllDay = target.isAllDay; + ov.recurring = true; + ov.rrule = ''; + ov.recurrenceId = bounds.thisOcc; + ov.reminders = target.reminders; + ov.reminder = target.reminder; + await EventDb.insertLocal(context, ov); + return; + } + // scope === 'future' + const master: LocalEvent | null = await this.loadMaster(context); + if (master === null) { + return; + } + // 主事件本身被改期 → "此次及以后"里包含主事件(第一次发生)本身, + // 需要让新系列从"改到的时间"开始(否则原 DTSTART 会被原系列继续展开)。 + const startedAtThis: boolean = + (cur.rrule !== '' && Math.abs(cur.startTime - bounds.thisOcc) < 1000); + // 1) 先把"此次及以后"的覆盖实例行搬到新 uid(主事件行 recurrenceId=0,不受影响) + const newUid: string = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`; + const newPath: string = encodeURIComponent(newUid) + '.ics'; + await EventDb.reassignOverridesFrom(context, master.calKey, master.uid, + newUid, newPath, bounds.thisOcc); + // 2) 创建新系列主事件(本次编辑内容) + const newMaster = new LocalEvent(); + newMaster.uid = newUid; + newMaster.calKey = master.calKey; + newMaster.href = master.href; + newMaster.remotePath = newPath; + newMaster.title = target.title; + newMaster.description = target.description; + newMaster.location = target.location; + newMaster.startTime = target.startTime; + newMaster.endTime = target.endTime; + newMaster.isAllDay = target.isAllDay; + newMaster.recurring = true; + // 新的重复规则:沿用原系列(用户没显式改规则时),保持"此次及以后"的重复节奏 + newMaster.rrule = target.rrule !== '' ? target.rrule : master.rrule; + newMaster.reminders = target.reminders; + newMaster.reminder = target.reminder; + await EventDb.insertLocal(context, newMaster); + // 3) 原主事件系列截断到"此次之前"。 + // 若主事件本身就代表"此次"(第一次发生且被改期)→ 原系列整个让位给新系列, + // 直接标记原主事件删除(pushSeries 会 DELETE 原资源),避免旧时间残留成幽灵日程。 + if (startedAtThis) { + await EventDb.markDeleted(context, master.id); + } else { + const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis); + await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated); + } + } + + private async save(): Promise { + if (this.isSaving || !this.validate()) { return; } // 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下 @@ -243,6 +429,24 @@ struct EventEditPage { : '请选择要同步到的 CalDAV 日历本'; return; } + // ⭐ 重复日程:先让用户选择"改这一次 / 此次及以后 / 全部" + if (this.isSeriesOccurrence()) { + this.pendingDelete = false; + this.scopeConfirmShow = true; + return; + } + await this.doSave('all'); + } + + /** 真正执行保存(scope 已被用户确认) */ + private async doSave(scope: string): Promise { + if (this.isSaving) { + return; + } + const book = this.chosenBook(); + if (book === null) { + return; + } this.isSaving = true; this.statusMsg = ''; try { @@ -276,7 +480,8 @@ struct EventEditPage { e.remotePath = encodeURIComponent(e.uid) + '.ics'; await EventDb.insertLocal(context, e); } else { - await EventDb.updateLocal(context, e); + // 重复系列的"仅此次/此次及以后"需要特殊落库(EXDATE / UNTIL / 拆分) + await this.applySeriesEdit(context, e, false, scope); } // 立即推送(尽力而为,失败不打断,下次同步会再推) if (e.href !== '') { @@ -290,7 +495,7 @@ struct EventEditPage { await SyncEngine.settleLocalEvents(context); } // 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log) - LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`); + LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 范围=${scope} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`); // 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能, // 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗) if (e.reminders.length > 0) { @@ -344,6 +549,12 @@ struct EventEditPage { if (this.event === null || this.isSaving) { return; } + // ⭐ 重复日程:先让用户选择"删这一次 / 此次及以后 / 全部" + if (this.isSeriesOccurrence()) { + this.pendingDelete = true; + this.scopeConfirmShow = true; + return; + } const label: string = this.title.trim() === '' ? '该日程' : `「${this.title.trim()}」`; this.getUIContext().showAlertDialog({ title: '删除日程', @@ -358,30 +569,66 @@ struct EventEditPage { value: '删除', fontColor: $r('app.color.error'), action: (): void => { - this.removeEvent(); + this.removeEvent('all'); } } }); } - private async removeEvent(): Promise { + /** + * ⭐ 按范围删除:one = 仅此次(EXDATE 排除该次发生)/ future = 此次及以后(截断 RRULE)/ + * all = 删除全部(DELETE 整个系列资源)。 + */ + private async removeEvent(scope: string): Promise { if (this.event === null || this.isSaving) { return; } - if (this.event !== null && this.event.recurring && this.event.rrule === '') { - this.statusMsg = '重复日程的单次删除暂不支持,请到服务器端调整重复规则'; - return; - } this.isSaving = true; try { const context = this.getUIContext().getHostContext(); if (context === undefined) { return; } - await EventDb.markDeleted(context, this.event.id); - if (this.event.href !== '') { + const cur: LocalEvent = this.event; + const isSeries: boolean = this.isSeriesOccurrence(); + if (!isSeries || scope === 'all') { + // 普通日程 / 删除全部:整行标记删除(系列删除由 pushSeries 处理成 DELETE 资源) + await EventDb.markDeleted(context, cur.id); + } else if (scope === 'one') { + // 仅此次:主事件加 EXDATE 排除该次;若该行本身就是覆盖实例 → 直接删掉这一行 + if (cur.rrule === '' && cur.recurrenceId > 0) { + await EventDb.markDeleted(context, cur.id); + } else { + const master: LocalEvent | null = await this.loadMaster(context); + if (master !== null) { + const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';'); + const occIcs: string = IcsUtil.fmtIcsTime(this.occStartMs, master.isAllDay); + if (!exList.includes(occIcs)) { + exList.push(occIcs); + } + await EventDb.setExdate(context, master.id, exList.join(';')); + // 若该次此前被单独改写过(存在覆盖行),一并清掉 + const rows: LocalEvent[] = await EventDb.queryByUid(context, cur.calKey, cur.uid); + for (const r of rows) { + if (r.rrule === '' && r.recurrenceId > 0) { + await EventDb.markDeleted(context, r.id); + } + } + } + } + } else { + // 此次及以后:主事件 RRULE 截断到"此次之前";该次及以后的本地行物理删除 + const master: LocalEvent | null = await this.loadMaster(context); + if (master !== null) { + const bounds: SeriesBounds = await this.seriesBoundaries(context); + const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis); + await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated); + await EventDb.purgeByUidFrom(context, master.calKey, master.uid, bounds.thisOcc); + } + } + if (cur.href !== '') { const accounts: DavAccount[] = await AccountStore.loadAll(context); - const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(this.event?.href ?? '')); + const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(cur.href)); if (acc !== undefined) { const auth: string = DavClient.authHeader(acc.username, acc.password); await SyncEngine.pushDirtyForAccount(context, acc, auth); @@ -390,7 +637,7 @@ struct EventEditPage { await SyncEngine.settleLocalEvents(context); } // 日志不落盘日程标题,仅以 uid 追踪 - LogUtil.write(`本地删除日程(标题已脱敏)uid=${this.event?.uid ?? ''}`); + LogUtil.write(`本地删除日程(标题已脱敏)uid=${cur.uid} 范围=${scope}`); // 立即刷新提醒(取消已发布但日程已删的提醒) try { await ReminderService.refreshReminders(context as common.UIAbilityContext); @@ -657,6 +904,78 @@ struct EventEditPage { dragBar: true, backgroundColor: $r('app.color.card_bg') }) + .bindSheet($$this.scopeConfirmShow, this.scopeSheet(), { + height: 300, + showClose: false, + dragBar: true, + backgroundColor: $r('app.color.card_bg') + }) + } + + /** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */ + @Builder + scopeSheet() { + Column({ space: 8 }) { + Text(this.pendingDelete ? '删除重复日程' : '修改重复日程') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 14, bottom: 4 }) + Text('这是一个重复日程,请选择作用的范围') + .fontSize(13) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ bottom: 6 }) + this.scopeOption('one', this.pendingDelete ? '仅删除这一次' : '仅修改这一次', + '只影响所选日期的这一次,其余保持不变') + this.scopeOption('future', this.pendingDelete ? '删除此次及以后' : '修改此次及以后', + '从这一次开始到最后一次都' + (this.pendingDelete ? '删除' : '修改')) + this.scopeOption('all', this.pendingDelete ? '删除全部' : '修改全部', + '整条重复日程的所有发生都' + (this.pendingDelete ? '删除' : '修改')) + Text('取消') + .fontSize(15) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 12, bottom: 16 }) + .onClick(() => { + this.scopeConfirmShow = false; + }) + } + .width('100%') + .padding({ left: 20, right: 20 }) + } + + @Builder + scopeOption(mode: string, title: string, desc: string) { + Column({ space: 3 }) { + Text(title) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .width('100%') + .textAlign(TextAlign.Start) + Text(desc) + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Start) + } + .width('100%') + .padding({ left: 14, right: 14, top: 10, bottom: 10 }) + .borderRadius(12) + .backgroundColor($r('app.color.chip_off_bg')) + .onClick(() => { + this.scopeConfirmShow = false; + if (this.pendingDelete) { + this.removeEvent(mode); + } else { + this.doSave(mode); + } + }) } /** diff --git a/entry/src/main/ets/pages/widget/Widget4x4.ets b/entry/src/main/ets/pages/widget/Widget4x4.ets index 7b2415d..a83ba36 100644 --- a/entry/src/main/ets/pages/widget/Widget4x4.ets +++ b/entry/src/main/ets/pages/widget/Widget4x4.ets @@ -110,40 +110,32 @@ struct Widget4x4Card { return r >= 0 && Math.floor(r * 24) === h; } - /** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 - * 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ + /** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。 + * (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */ private w4Range(): W4Range { const rg = new W4Range(); const bs: TBlock[] = this.parseBlocks(); - let s: number = -1; - let e: number = -1; + let s: number = 8; + let e: number = 18; for (const b of bs) { const bh: number = Math.floor(b.topRatio * 24); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); - if (s < 0 || bh < s) { + if (bh < s) { s = bh; } - if (e < 0 || eh > e) { + if (eh > e) { e = eh; } } const r: number = this.w4NowR(); // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); - // 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 + // 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示) const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1; - if (s < 0) { - s = nh >= 0 ? nh : 8; + if (nh >= 0 && nh < s && s < 8) { + s = nh; } - if (e < 0) { - e = nh >= 0 ? nh + 1 : 20; - } - if (nh >= 0) { - if (nh < s) { - s = nh; - } - if (nh + 1 > e) { - e = nh + 1; - } + if (nh >= 0 && nh + 1 > e && e > 18) { + e = nh + 1; } if (s < 0) { s = 0; diff --git a/entry/src/main/ets/pages/widget/Widget6x4.ets b/entry/src/main/ets/pages/widget/Widget6x4.ets index 72ef4da..4c5b4d6 100644 --- a/entry/src/main/ets/pages/widget/Widget6x4.ets +++ b/entry/src/main/ets/pages/widget/Widget6x4.ets @@ -109,40 +109,32 @@ struct Widget6x4Card { return r >= 0 && Math.floor(r * 24) === h; } - /** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 - * 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ + /** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。 + * (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */ private w6Range(): W6Range { const rg = new W6Range(); const bs: TBlock6[] = this.parseBlocks(); - let s: number = -1; - let e: number = -1; + let s: number = 8; + let e: number = 18; for (const b of bs) { const bh: number = Math.floor(b.topRatio * 24); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); - if (s < 0 || bh < s) { + if (bh < s) { s = bh; } - if (e < 0 || eh > e) { + if (eh > e) { e = eh; } } const r: number = this.w6NowR(); // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); - // 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 + // 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示) const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1; - if (s < 0) { - s = nh >= 0 ? nh : 8; + if (nh >= 0 && nh < s && s < 8) { + s = nh; } - if (e < 0) { - e = nh >= 0 ? nh + 1 : 20; - } - if (nh >= 0) { - if (nh < s) { - s = nh; - } - if (nh + 1 > e) { - e = nh + 1; - } + if (nh >= 0 && nh + 1 > e && e > 18) { + e = nh + 1; } if (s < 0) { s = 0;