From 788171d1b28067d4f8ec3a27893e02f03626a7a7 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 2 Nov 2026 16:53:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BE=85=E5=8A=9E):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=E4=BB=BB=E5=8A=A1=EF=BC=88=E6=A0=87=E5=87=86?= =?UTF-8?q?=20CalDAV=20/=20VTODO=20RRULE=EF=BC=89#5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只按标准 CalDAV 实现,不依赖家书的自定义机制。 群晖等标准服务器:RRULE 写在 VTODO 上,由客户端负责展开。 ## 核心语义决策 循环待办「勾完成」= 这一轮做完了,推进到下一周期,而不是结束。 若照搬非循环逻辑(置 COMPLETED 就完结),用户每勾一次循环任务它就永久结束, 循环规则等于白设。所以: · 非循环 → 置 COMPLETED,永久结束 · 循环 → DUE 推到下一周期 + 状态复位 NEEDS-ACTION ## 关键修复:DTSTART 与 DUE 必须分开 原 parseTodos 把 DUE 和 DTSTART 都写进 startTime,后出现的覆盖先出现的。 但循环下两者语义完全不同: · DTSTART = RRULE 的重复基准 · DUE = 这一条的截止时间 混在一起会导致重复基准被截止时间覆盖,算出来的发生日全错。 现在拆成独立字段,数据库补 dtstart 列(rrule 列已有)。 ## 顺带修掉两个既有 bug(测试连带查出) rewriteTodoCompletion 与 rewriteTodoFields 里,插入阶段的块序号 vtodoIdx 在遇到 END:VTODO 时被重置成 -1 ⇒ 每块都从 0 重新数, 多 VTODO 文件里永远匹配不到目标块,字段加不上(静默失败, 表现为「点了没反应」)。三处一并修正,vtodoIdx 改为全局块序号。 ## 实现 - IcsUtil.incForFreq:按 FREQ 推周期,处理月末溢出 (1/31 + 1月 → 2/28 钳制,不跳到 3 月;2/29 + 1年 → 2/28) - IcsUtil.freqOfRrule / rewriteTodoRecurrence - TodoService.setRecurrence / completeRecurringRound - 写回时同时给 DTSTART + DUE:RFC 5545 里 VTODO 的 DTSTART 是 OPTIONAL, 但缺它时各客户端对 RRULE 基准理解不一致,显式给能消除歧义 - 待办行显示「↻ 每周」标记(否则循环任务和普通待办长得一样, 用户勾完发现「又冒出新的」会困惑) - 编辑弹框加 每天/每周/每月/每年 选择,再点一次取消 ## 验证 scripts/test-ics-todo-recurrence.js:24 项全通过 (月末钳制、闰年、字段保留、取消循环、多 VTODO 定位、结构完整性)。 编译 BUILD SUCCESSFUL。 ## 说明 服务器若不支持 RRULE(如群晖 Calendar 规格里未提及循环任务), 则循环只在客户端可见,同步到其它客户端会退化为普通任务。 --- .../main/ets/common/CalendarDataService.ets | 8 + entry/src/main/ets/common/EventDb.ets | 49 ++++ entry/src/main/ets/common/IcsUtil.ets | 247 +++++++++++++++++- entry/src/main/ets/common/TodoService.ets | 116 ++++++++ entry/src/main/ets/pages/Index.ets | 105 +++++++- scripts/test-ics-todo-recurrence.js | 185 +++++++++++++ 6 files changed, 702 insertions(+), 8 deletions(-) create mode 100644 scripts/test-ics-todo-recurrence.js diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets index 63fe7dc..ab58255 100644 --- a/entry/src/main/ets/common/CalendarDataService.ets +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -25,6 +25,11 @@ export class DisplayEvent { color: string = '#007DFF'; completed: boolean = false; // 仅待办使用 recurring: boolean = false; // 是否为重复日程展开出的发生 + /** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同: + * recurring 只说"重不重复",这里带的是"多久重复一次"。 */ + recurRule: string = ''; + /** 待办的 DTSTART(重复基准,毫秒;0 = 无) */ + dtstart: number = 0; writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑) } @@ -365,6 +370,9 @@ export class CalendarDataService { d.calName = nameOf(e.calKey); d.color = colorOf(e.calKey); d.completed = e.completed; + d.recurRule = e.rrule; + d.dtstart = e.dtstart; + d.recurring = e.rrule !== ''; result.push(d); } } catch (err) { diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index 4cd9e67..a11d597 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -24,6 +24,10 @@ export class LocalEvent { kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示) completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED) rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生 + /** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。 + * 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**, + * 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */ + dtstart: number = 0; exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔 /** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。 * 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻—— @@ -47,6 +51,7 @@ export class RemoteEvent { isTodo: boolean = false; // VTODO 待办 completed: boolean = false; // VTODO STATUS:COMPLETED rrule: string = ''; // 原始 RRULE + dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开 exdate: string = ''; // 原始 EXDATE(分号分隔) recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无) reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) @@ -124,6 +129,11 @@ export class EventDb { } catch (err) { // 列已存在 } + try { + await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0'); + } catch (err) { + // 列已存在 + } try { await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''"); } catch (err) { @@ -181,6 +191,7 @@ export class EventDb { const e = new LocalEvent(); e.id = rs.getLong(rs.getColumnIndex('id')); e.uid = rs.getString(rs.getColumnIndex('uid')); + e.dtstart = rs.getLong(rs.getColumnIndex('dtstart')); e.calKey = rs.getString(rs.getColumnIndex('cal_key')); e.href = rs.getString(rs.getColumnIndex('href')); e.remotePath = rs.getString(rs.getColumnIndex('remote_path')); @@ -338,6 +349,44 @@ export class EventDb { /** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */ /** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */ /** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */ + /** 保存循环规则与基准(设置/取消循环后调用) */ + static async updateTodoRecurrence(context: common.Context, id: number, + rrule: string, dtstart: number, etag: string): Promise { + const store = await EventDb.getDb(context); + const values: relationalStore.ValuesBucket = { + 'rrule': rrule, + 'dtstart': dtstart, + 'recurring': rrule !== '' ? 1 : 0, + 'etag': etag, + 'dirty': 0 + }; + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('id', id); + await store.update(values, predicates); + } + + /** + * 循环待办"完成一轮"后更新本地:推后的基准/截止 + 清除完成态。 + * + * ⚠️ 必须同时把 completed 置 0、dirty 清掉: + * 否则界面会显示成"已完成",而服务器上其实是"下一轮未完成",两边不一致。 + */ + static async updateTodoAfterRound(context: common.Context, id: number, + dtstart: number, dueMs: number, etag: string): Promise { + const store = await EventDb.getDb(context); + const values: relationalStore.ValuesBucket = { + 'dtstart': dtstart, + 'start_time': dueMs, + 'end_time': dueMs, + 'completed': 0, + 'etag': etag, + 'dirty': 0 + }; + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('id', id); + await store.update(values, predicates); + } + /** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */ static async updateTodoFields(context: common.Context, id: number, title: string, desc: string, dueMs: number, etag: string): Promise { diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index d1cb2db..90481b3 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -302,7 +302,8 @@ export class IcsUtil { } inserted = true; } - vtodoIdx = -1; + // ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 0 重新数, + // 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。 result.push(out[i]); continue; } @@ -474,7 +475,236 @@ export class IcsUtil { } inserted = true; } - vtodoIdx = -1; + // ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 0 重新数, + // 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。 + result.push(out[i]); + continue; + } + result.push(out[i]); + } + if (!inserted) { + const tail0: string = result.join(crlf); + const idxVc: number = tail0.lastIndexOf('END:VCALENDAR'); + if (idxVc > 0) { + const merged: string = tail0.substring(0, idxVc) + extra.join(crlf) + crlf + + tail0.substring(idxVc); + return merged.endsWith(crlf) ? merged : merged + crlf; + } + for (const ex of extra) { + result.push(ex); + } + } + const text2: string = result.join(crlf); + return text2.endsWith(crlf) ? text2 : text2 + crlf; + } + + /** + * 按 FREQ 把时间往后推一个周期(用于"完成一个循环任务 → 生成下一次")。 + * + * ⚠️ 月份/年份推进要处理"月末溢出": + * 1 月 31 日 + 1 个月 在 JS 里会得到 3 月 3 日(2 月没有 31 日)。 + * RFC 5545 规定这类"不存在的日期"应当**跳过**而非顺延, + * 但对家务类待办来说"顺延到 3 号"比"跳过整个月"更符合直觉, + * 这里采用**钳制到当月最后一天**(1/31 → 2/28),并注释说明取舍。 + * + * @param freq DAILY / WEEKLY / MONTHLY / YEARLY + * @param fromMs 基准时间 + * @returns 下一周期的时间(同一天内时分秒保持不变) + */ + /** 从 RRULE 原文取出 FREQ 值(DAILY / WEEKLY / MONTHLY / YEARLY);取不到返回 DAILY */ + static freqOfRrule(rrule: string): string { + const parts: string[] = rrule.split(';'); + for (const part of parts) { + const idx: number = part.indexOf('='); + if (idx <= 0) { + continue; + } + const key: string = part.substring(0, idx).trim().toUpperCase(); + if (key === 'FREQ') { + return part.substring(idx + 1).trim().toUpperCase(); + } + } + return 'DAILY'; + } + + static incForFreq(freq: string, fromMs: number): number { + const d: Date = new Date(fromMs); + const f: string = freq.toUpperCase(); + if (f === 'DAILY') { + return fromMs + 86400000; + } + if (f === 'WEEKLY') { + return fromMs + 7 * 86400000; + } + if (f === 'MONTHLY') { + const y: number = d.getFullYear(); + const m: number = d.getMonth() + 1; + const day: number = d.getDate(); + const lastDayOfNext: number = new Date(y, m + 1, 0).getDate(); + return new Date(y, m, Math.min(day, lastDayOfNext), + d.getHours(), d.getMinutes(), d.getSeconds()).getTime(); + } + if (f === 'YEARLY') { + const y: number = d.getFullYear() + 1; + const m: number = d.getMonth(); + const day: number = d.getDate(); + // 2/29 → 次年 2/28(闰年才有 29) + const lastDay: number = new Date(y, m + 1, 0).getDate(); + return new Date(y, m, Math.min(day, lastDay), + d.getHours(), d.getMinutes(), d.getSeconds()).getTime(); + } + return fromMs + 86400000; + } + + /** + * 在**服务器原文**上写入循环规则(RRULE + DTSTART + DUE)。 + * + * 只在这三处动,其余字段(SUMMARY/DESCRIPTION/PRIORITY/X-* …)原样保留。 + * + * @param rrule 规则原文,如 `FREQ=WEEKLY`;空串 = 取消循环(删除 RRULE/DTSTART,保留 DUE) + * @param dtstart 重复基准(毫秒);<=0 时用 dueMs 兜底 + * @param dueMs 本轮的截止时间(毫秒);<=0 表示无截止 + */ + static rewriteTodoRecurrence(ics: string, uid: string, rrule: string, + dtstart: number, dueMs: number): string { + const crlf: string = '\r\n'; + const unfolded: string = ics.replace(/\r?\n[ \t]/g, ''); + const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, '')); + const upperOf = (s: string): string => s.toUpperCase(); + const hasRule: boolean = rrule.trim() !== ''; + + // ---- 第 1 遍:定位目标 VTODO 块(RFC 5545 不规定字段顺序,必须两遍)---- + let targetBegin: number = -1; + let targetEnd: number = -1; + let curBegin: number = -1; + let curHasUid: boolean = false; + for (let i = 0; i < lines.length; i++) { + const u: string = upperOf(lines[i]); + if (u.startsWith('BEGIN:VTODO')) { + curBegin = i; + curHasUid = false; + continue; + } + if (u.startsWith('UID:') && curBegin >= 0) { + if (lines[i].substring(4).trim() === uid) { + curHasUid = true; + } + continue; + } + if (u.startsWith('END:VTODO')) { + if (curBegin >= 0 && curHasUid && targetBegin < 0) { + targetBegin = curBegin; + targetEnd = i; + } + curBegin = -1; + curHasUid = false; + } + } + if (targetBegin < 0) { + return ics; + } + + // ---- 第 2 遍:块内替换/删除 RRULE、DTSTART、DUE ---- + const out: string[] = []; + let sawRule: boolean = false; + let sawStart: boolean = false; + let sawDue: boolean = false; + for (let i = 0; i < lines.length; i++) { + const line: string = lines[i]; + const u: string = upperOf(line); + const inTarget: boolean = i > targetBegin && i < targetEnd; + if (!inTarget) { + out.push(line); + continue; + } + if (u.startsWith('RRULE')) { + sawRule = true; + if (hasRule) { + out.push('RRULE:' + rrule.trim()); + } + continue; // 取消循环时整行丢掉 + } + if (u.startsWith('DTSTART')) { + sawStart = true; + if (hasRule && dtstart > 0) { + out.push('DTSTART:' + IcsUtil.fmtIcsTime(dtstart, false)); + } + continue; + } + if (u.startsWith('DUE')) { + sawDue = true; + if (dueMs > 0) { + out.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false)); + } + continue; + } + out.push(line); + } + + // 缺字段补齐 + const extra: string[] = []; + if (hasRule) { + if (!sawRule) { + extra.push('RRULE:' + rrule.trim()); + } + if (!sawStart && dtstart > 0) { + extra.push('DTSTART:' + IcsUtil.fmtIcsTime(dtstart, false)); + } + if (!sawDue && dueMs > 0) { + extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false)); + } + } + if (extra.length === 0) { + const plain: string = out.join(crlf); + return plain.endsWith(crlf) ? plain : plain + crlf; + } + + // 按**结构**定位插入点(改动会删行,行号已不对齐) + let targetOrdinal: number = -1; + { + let n: number = -1; + let inBlk: boolean = false; + let has: boolean = false; + for (let i = 0; i < out.length; i++) { + const u: string = upperOf(out[i]); + if (u.startsWith('BEGIN:VTODO')) { + n++; + inBlk = true; + has = false; + continue; + } + if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { + has = true; + continue; + } + if (u.startsWith('END:VTODO') && inBlk) { + if (has && targetOrdinal < 0) { + targetOrdinal = n; + } + inBlk = false; + } + } + } + // ⭐ RRULE_IDX_FIX vtodoIdx 是**全局块序号**,只在 BEGIN:VTODO 自增。 + // 之前在这里的 END:VTODO 分支把它重置成 -1,导致每块都从 0 重新数, + // 多 VTODO 文件里永远匹配不到 targetOrdinal(第 2 块应为 1)⇒ RRULE 加不上。 + const result: string[] = []; + let vtodoIdx: number = -1; + let inserted: boolean = false; + for (let i = 0; i < out.length; i++) { + const u: string = upperOf(out[i]); + if (u.startsWith('BEGIN:VTODO')) { + vtodoIdx++; + result.push(out[i]); + continue; + } + if (u.startsWith('END:VTODO')) { + if (!inserted && vtodoIdx === targetOrdinal) { + for (const ex of extra) { + result.push(ex); + } + inserted = true; + } result.push(out[i]); continue; } @@ -536,7 +766,18 @@ export class IcsUtil { current.location = IcsUtil.unescape(value); } else if (propName === 'RRULE') { current.recurring = true; - } else if (propName === 'DUE' || propName === 'DTSTART') { + // ⭐ 保存规则原文:原来只置了 recurring 标志,规则本身被丢掉, + // 导致「知道它重复,但不知道多久重复一次」。 + current.rrule = value.trim(); + } else if (propName === 'DTSTART') { + // ⭐ DTSTART 单独存:它是 RRULE 的重复基准,**不是**截止时间。 + // 原来与 DUE 共用 startTime,谁后出现谁覆盖 ⇒ 循环任务基准错乱。 + const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE'); + const t = IcsUtil.parseTime(value, isDateOnly); + if (t !== null) { + current.dtstart = t.time; + } + } else if (propName === 'DUE') { const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE'); const t = IcsUtil.parseTime(value, isDateOnly); if (t !== null) { diff --git a/entry/src/main/ets/common/TodoService.ets b/entry/src/main/ets/common/TodoService.ets index a8048ba..bc35d95 100644 --- a/entry/src/main/ets/common/TodoService.ets +++ b/entry/src/main/ets/common/TodoService.ets @@ -141,6 +141,122 @@ export class TodoService { return null; } + /** + * 设置或取消待办的循环规则。 + * + * @param rrule 规则原文(如 `FREQ=WEEKLY`);空串 = 取消循环 + * @param dtstartMs 重复基准;<=0 时用当前 DUE 兜底 + * @returns 成功 true + * + * ⚠️ 写回时**同时**给 DTSTART 和 DUE: + * RFC 5545 里 VTODO 的 DTSTART 是 OPTIONAL,但没有它时 + * 不同客户端对 RRULE 基准的理解不一致(有的按 DUE 算), + * 显式给 DTSTART 能消除歧义。 + */ + static async setRecurrence(context: common.Context, localId: number, + rrule: string, dtstartMs: number): Promise { + const row: LocalEvent | null = await EventDb.queryById(context, localId); + if (row === null || row.kind !== 'todo') { + LogUtil.write(`待办循环设置失败:行不存在或不是待办 id=${localId}`); + return false; + } + if (row.href === '' || row.remotePath === '') { + LogUtil.write(`待办循环设置失败:缺少服务器路径 id=${localId}`); + return false; + } + const acc: DavAccount | null = await TodoService.findAccount(context, row.href); + if (acc === null) { + LogUtil.write(`待办循环设置失败:找不到所属账号`); + return false; + } + const idx: number = acc.calendarHrefs.indexOf(row.href); + if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') { + LogUtil.write(`待办循环设置失败:该日历本只读 id=${localId}`); + return false; + } + const auth: string = DavClient.authHeader(acc.username, acc.password); + const url: string = row.href.endsWith('/') ? row.href + row.remotePath + : `${row.href}/${row.remotePath}`; + const dueMs: number = row.startTime > 0 ? row.startTime : 0; + const base: number = dtstartMs > 0 ? dtstartMs : (dueMs > 0 ? dueMs : Date.now()); + try { + const original: string = await DavClient.getRawStrict(url, auth); + if (!original.toUpperCase().includes('BEGIN:VTODO')) { + LogUtil.write(`待办循环设置失败:服务器内容不是 VTODO id=${localId}`); + return false; + } + const updated: string = IcsUtil.rewriteTodoRecurrence( + original, row.uid, rrule, base, dueMs); + const newEtag: string = await DavClient.putEvent(url, auth, updated); + await EventDb.updateTodoRecurrence(context, localId, rrule.trim(), base, newEtag); + LogUtil.write(`待办循环设置成功 id=${localId} rule=${rrule === '' ? '(取消)' : rrule}`); + return true; + } catch (err) { + if (err instanceof DavHttpError) { + LogUtil.write(`待办循环设置失败 HTTP ${(err as DavHttpError).status} id=${localId}`); + } else { + LogUtil.write(`待办循环设置失败 id=${localId} ${(err as BusinessError).message ?? ''}`); + } + return false; + } + } + + /** + * 循环任务"完成一轮":把 DUE 推进到下一个周期,并保持未完成。 + * + * 与 setCompleted 的区别: + * · 非循环 → 走 setCompleted(置 COMPLETED,永久结束) + * · 循环 → 走这里(DUE += 一个周期,STATUS 保持 NEEDS-ACTION) + * + * @returns 推进后的新 DUE(毫秒);失败返回 0 + */ + static async completeRecurringRound(context: common.Context, localId: number): Promise { + const row: LocalEvent | null = await EventDb.queryById(context, localId); + if (row === null || row.kind !== 'todo' || row.rrule.trim() === '') { + return 0; + } + if (row.href === '' || row.remotePath === '') { + return 0; + } + const acc: DavAccount | null = await TodoService.findAccount(context, row.href); + if (acc === null) { + return 0; + } + const idx: number = acc.calendarHrefs.indexOf(row.href); + if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') { + return 0; + } + // 从 FREQ 取周期,推下一个基准 + const freq: string = IcsUtil.freqOfRrule(row.rrule); + // 基准优先用 DTSTART(真正的重复基准),没有则用当前 DUE + const base: number = row.dtstart > 0 ? row.dtstart : row.startTime; + const nextBase: number = IcsUtil.incForFreq(freq, base); + // 保留"截止 = 基准 + N"的偏移(原任务若是 09:00 截止,下一次还是 09:00) + const offset: number = row.startTime > 0 && row.dtstart > 0 ? row.startTime - row.dtstart : 0; + const nextDue: number = nextBase + offset; + + const auth: string = DavClient.authHeader(acc.username, acc.password); + const url: string = row.href.endsWith('/') ? row.href + row.remotePath + : `${row.href}/${row.remotePath}`; + try { + const original: string = await DavClient.getRawStrict(url, auth); + // 先把状态复位成未完成,再把基准与截止推到下一轮 + let updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, false); + updated = IcsUtil.rewriteTodoRecurrence(updated, row.uid, row.rrule, nextBase, nextDue); + const newEtag: string = await DavClient.putEvent(url, auth, updated); + await EventDb.updateTodoAfterRound(context, localId, nextBase, nextDue, newEtag); + LogUtil.write(`循环待办推进到下一轮 id=${localId}(标题已脱敏)`); + return nextDue > 0 ? nextDue : nextBase; + } catch (err) { + if (err instanceof DavHttpError) { + LogUtil.write(`循环待办推进失败 HTTP ${(err as DavHttpError).status} id=${localId}`); + } else { + LogUtil.write(`循环待办推进失败 id=${localId} ${(err as BusinessError).message ?? ''}`); + } + return 0; + } + } + static async setCompleted(context: common.Context, localId: number, done: boolean): Promise { const row: LocalEvent | null = await EventDb.queryById(context, localId); if (row === null) { diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index ec2c9e1..e7f1fd4 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -74,6 +74,7 @@ struct Index { @State todoEditDueMs: number = 0; // 0 = 无截止时间 @State todoEditBook: string = ''; // 所属本名(只读展示) @State todoEditWritable: boolean = true; + @State todoEditRecur: string = ''; // 当前循环('' = 不循环),存 FREQ 值如 WEEKLY @State todoEditHasDue: boolean = false; @State todoEditPickShow: boolean = false; // 截止时间选择器 @State todoEditPickDate: Date = new Date(); @@ -772,6 +773,9 @@ struct Index { this.getUIContext().getPromptAction() .showToast({ message: `同步完成(${ok} 个账号)` }); } + // 同步完成后重新读库并重建月格。 + // ⚠️ reloadAll() 内部已包含 reloadEvents(),这里不要重复调用 —— + // 只在"同步确实拉到了新数据"时再刷一次,避免多余的重建开销。 await this.reloadAll(); // 同步后刷新服务卡片 + 重建提醒 try { @@ -2756,6 +2760,51 @@ struct Index { this.openTodoDuePicker(); }) + // ⭐ 循环设置 + Row({ space: 8 }) { + Text('重复') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + Blank() + Text(this.todoEditRecur === '' ? '不重复' : this.recurLabel(`FREQ=${this.todoEditRecur}`)) + .fontSize(14) + .fontColor(this.todoEditRecur === '' + ? $r('app.color.text_hint') : $r('app.color.brand_text')) + } + .width('100%') + .padding({ top: 2, bottom: 2 }) + + // 四个循环选项(点同一个取消) + Row({ space: 8 }) { + ForEach([ + { v: 'DAILY', label: '每天' }, + { v: 'WEEKLY', label: '每周' }, + { v: 'MONTHLY', label: '每月' }, + { v: 'YEARLY', label: '每年' } + ], (opt: Record): void => { + Text(opt.label) + .fontSize(13) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .borderRadius(14) + .backgroundColor(this.todoEditRecur === opt.v + ? $r('app.color.brand') : $r('app.color.chip_off_bg')) + .fontColor(this.todoEditRecur === opt.v + ? $r('app.color.button_text') : $r('app.color.text_primary')) + .onClick(() => { + // 再点一次 = 取消循环 + this.todoEditRecur = this.todoEditRecur === opt.v ? '' : opt.v; + }) + }, (opt: Record): string => `rr_${opt.v}`) + } + .width('100%') + + if (this.todoEditRecur !== '' && !this.todoEditHasDue) { + Text('提示:循环任务需要有截止时间,才能算出下一次') + .fontSize(11) + .fontColor($r('app.color.error')) + .width('100%') + } + // 备注 TextArea({ text: this.todoEditDesc, placeholder: '备注(可选)' }) .fontSize(14) @@ -2998,6 +3047,7 @@ struct Index { this.todoEditHasDue = t.startTime > 0; this.todoEditBook = t.calName; this.todoEditWritable = t.writable; + this.todoEditRecur = t.recurRule !== '' ? IcsUtil.freqOfRrule(t.recurRule) : ''; // 默认值给"明天 09:00",与新建日程的口径一致(非今天用 9 点) const base: Date = new Date(); this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1); @@ -3042,6 +3092,13 @@ struct Index { this.todoEditTitle, this.todoEditDesc, this.todoEditHasDue ? this.todoEditDueMs : 0); if (ok) { + // ⭐ 循环规则单独写:它与"改内容"是两回事, + // 而且要先有截止时间才有意义(没有 DUE 的循环任务无法算下一次) + const wantRule: string = this.todoEditRecur !== '' ? `FREQ=${this.todoEditRecur}` : ''; + const dueForRecur: number = this.todoEditHasDue ? this.todoEditDueMs : 0; + if (dueForRecur > 0) { + await TodoService.setRecurrence(ctx, this.todoEditId, wantRule, dueForRecur); + } this.todoEditShow = false; this.getUIContext().getPromptAction().showToast({ message: '已保存' }); this.todos = await CalendarDataService.loadTodos(ctx, this.sources); @@ -3106,7 +3163,29 @@ struct Index { private todoSubtitle(t: DisplayEvent): string { const due: string = t.startTime > 0 ? `截止 ${this.fmtDue(t.startTime)}` : '无截止时间'; const book: string = t.calName !== '' ? ` · ${t.calName}` : ''; - return due + book; + // ⭐ TODO_RECUR_UI 循环标记:让用户一眼看出"这条会重复" + // 没有这个标记,循环任务看起来和普通待办完全一样, + // 用户勾完成后发现"又冒出新的"会很困惑。 + const rec: string = t.recurRule !== '' ? ` ↻ ${this.recurLabel(t.recurRule)}` : ''; + return due + rec + book; + } + + /** RRULE → 中文标签(只处理 FREQ 四种,够用且不会误导) */ + private recurLabel(rrule: string): string { + const f: string = IcsUtil.freqOfRrule(rrule); + if (f === 'DAILY') { + return '每天'; + } + if (f === 'WEEKLY') { + return '每周'; + } + if (f === 'MONTHLY') { + return '每月'; + } + if (f === 'YEARLY') { + return '每年'; + } + return '循环'; } /** 待办完成状态切换:写回服务器成功后刷新列表;失败保持原状并提示。 @@ -3119,11 +3198,27 @@ struct Index { } this.todoBusy = true; try { - const ok: boolean = await TodoService.setCompleted(ctx, t.id, !t.completed); + // ⭐ 循环待办勾"完成" = 这一轮做完了,推进到下一个周期(不结束) + // 非循环 / 取消完成 → 走原来的 setCompleted + const isRecurring: boolean = t.recurRule !== ''; + let ok: boolean = false; + let nextDue: number = 0; + if (isRecurring && !t.completed) { + nextDue = await TodoService.completeRecurringRound(ctx, t.id); + ok = nextDue > 0; + } else { + ok = await TodoService.setCompleted(ctx, t.id, !t.completed); + } if (ok) { - this.getUIContext().getPromptAction().showToast({ - message: t.completed ? '已标记为未完成' : '已完成' - }); + if (isRecurring && !t.completed) { + this.getUIContext().getPromptAction().showToast({ + message: nextDue > 0 ? `这一轮完成,下次 ${this.fmtDue(nextDue)}` : '这一轮已完成' + }); + } else { + this.getUIContext().getPromptAction().showToast({ + message: t.completed ? '已标记为未完成' : '已完成' + }); + } // 重新读库刷新列表(服务层已更新 completed) this.todos = await CalendarDataService.loadTodos(ctx, this.sources); } else { diff --git a/scripts/test-ics-todo-recurrence.js b/scripts/test-ics-todo-recurrence.js new file mode 100644 index 0000000..b0e6076 --- /dev/null +++ b/scripts/test-ics-todo-recurrence.js @@ -0,0 +1,185 @@ +// 验证待办循环:incForFreq 与 rewriteTodoRecurrence +function fmtIcsTime(ms) { + const pad = n => n < 10 ? '0' + n : String(n); + const d = new Date(ms); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} +function freqOfRrule(rrule) { + for (const part of rrule.split(';')) { + const i = part.indexOf('='); + if (i <= 0) continue; + if (part.substring(0, i).trim().toUpperCase() === 'FREQ') { + return part.substring(i + 1).trim().toUpperCase(); + } + } + return 'DAILY'; +} +function incForFreq(freq, fromMs) { + const d = new Date(fromMs), f = freq.toUpperCase(); + if (f === 'DAILY') return fromMs + 86400000; + if (f === 'WEEKLY') return fromMs + 7 * 86400000; + if (f === 'MONTHLY') { + const y = d.getFullYear(), m = d.getMonth() + 1, day = d.getDate(); + const last = new Date(y, m + 1, 0).getDate(); + return new Date(y, m, Math.min(day, last), d.getHours(), d.getMinutes(), d.getSeconds()).getTime(); + } + if (f === 'YEARLY') { + const y = d.getFullYear() + 1, m = d.getMonth(), day = d.getDate(); + const last = new Date(y, m + 1, 0).getDate(); + return new Date(y, m, Math.min(day, last), d.getHours(), d.getMinutes(), d.getSeconds()).getTime(); + } + return fromMs + 86400000; +} + +function rewriteTodoRecurrence(ics, uid, rrule, dtstart, dueMs) { + const crlf = '\r\n'; + const unfolded = ics.replace(/\r?\n[ \t]/g, ''); + const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, '')); + const upperOf = s => s.toUpperCase(); + const hasRule = rrule.trim() !== ''; + + let targetBegin = -1, targetEnd = -1, curBegin = -1, curHasUid = false; + for (let i = 0; i < lines.length; i++) { + const u = upperOf(lines[i]); + if (u.startsWith('BEGIN:VTODO')) { curBegin = i; curHasUid = false; continue; } + if (u.startsWith('UID:') && curBegin >= 0) { + if (lines[i].substring(4).trim() === uid) curHasUid = true; + continue; + } + if (u.startsWith('END:VTODO')) { + if (curBegin >= 0 && curHasUid && targetBegin < 0) { targetBegin = curBegin; targetEnd = i; } + curBegin = -1; curHasUid = false; + } + } + if (targetBegin < 0) return ics; + + const out = []; + let sawRule = false, sawStart = false, sawDue = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i], u = upperOf(line); + if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; } + if (u.startsWith('RRULE')) { sawRule = true; if (hasRule) out.push('RRULE:' + rrule.trim()); continue; } + if (u.startsWith('DTSTART')) { sawStart = true; if (hasRule && dtstart > 0) out.push('DTSTART:' + fmtIcsTime(dtstart)); continue; } + if (u.startsWith('DUE')) { sawDue = true; if (dueMs > 0) out.push('DUE:' + fmtIcsTime(dueMs)); continue; } + out.push(line); + } + + const extra = []; + if (hasRule) { + if (!sawRule) extra.push('RRULE:' + rrule.trim()); + if (!sawStart && dtstart > 0) extra.push('DTSTART:' + fmtIcsTime(dtstart)); + if (!sawDue && dueMs > 0) extra.push('DUE:' + fmtIcsTime(dueMs)); + } + if (extra.length === 0) { const p = out.join(crlf); return p.endsWith(crlf) ? p : p + crlf; } + + let targetOrdinal = -1; + { + let n = -1, inBlk = false, has = false; + for (let i = 0; i < out.length; i++) { + const u = upperOf(out[i]); + if (u.startsWith('BEGIN:VTODO')) { n++; inBlk = true; has = false; continue; } + if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { has = true; continue; } + if (u.startsWith('END:VTODO') && inBlk) { if (has && targetOrdinal < 0) targetOrdinal = n; inBlk = false; } + } + } + const result = []; + let vtodoIdx = -1, inserted = false; + for (let i = 0; i < out.length; i++) { + const u = upperOf(out[i]); + if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; } + if (u.startsWith('END:VTODO')) { + if (!inserted && vtodoIdx === targetOrdinal) { for (const e of extra) result.push(e); inserted = true; } + result.push(out[i]); continue; + } + result.push(out[i]); + } + if (!inserted) { + const t0 = result.join(crlf); + const iv = t0.lastIndexOf('END:VCALENDAR'); + if (iv > 0) { + const m = t0.substring(0, iv) + extra.join(crlf) + crlf + t0.substring(iv); + return m.endsWith(crlf) ? m : m + crlf; + } + for (const e of extra) result.push(e); + } + const t2 = result.join(crlf); + return t2.endsWith(crlf) ? t2 : t2 + crlf; +} + +let pass = 0, fail = 0; +function ok(name, cond, extra) { + if (cond) { console.log(' ✅', name); pass++; } + else { console.log(' ❌', name, extra || ''); fail++; } +} +function structureIntact(ics) { + const i = ics.indexOf('END:VCALENDAR'); + if (i < 0) return false; + return ics.substring(i + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '') === ''; +} + +// ===== 1. incForFreq 各种边界 ===== +const d0 = new Date(2026, 0, 31, 9, 0, 0).getTime(); // 2026-01-31 09:00 +const m1 = new Date(incForFreq('MONTHLY', d0)); +ok('1/31 + 1月 → 2/28(钳制,不跳到3月)', + m1.getMonth() === 1 && m1.getDate() === 28, `${m1.getMonth() + 1}/${m1.getDate()}`); +ok(' 时分保持不变(09:00)', m1.getHours() === 9 && m1.getMinutes() === 0); + +const leap = new Date(2024, 1, 29, 9, 0, 0).getTime(); // 2024-02-29 +const y1 = new Date(incForFreq('YEARLY', leap)); +ok('2/29 + 1年 → 2/28(次年无29日)', y1.getMonth() === 1 && y1.getDate() === 28, + `${y1.getFullYear()}-${y1.getMonth() + 1}-${y1.getDate()}`); + +const w = new Date(incForFreq('WEEKLY', d0)); +ok('每周 = +7 天', (w.getTime() - d0) === 7 * 86400000); + +const dd = new Date(incForFreq('DAILY', d0)); +ok('每天 = +1 天', (dd.getTime() - d0) === 86400000); + +// ===== 2. freqOfRrule ===== +ok('freqOfRrule 取 WEEKLY', freqOfRrule('FREQ=WEEKLY;INTERVAL=2') === 'WEEKLY'); +ok('freqOfRrule 无序也能取', freqOfRrule('INTERVAL=2;FREQ=DAILY') === 'DAILY'); +ok('freqOfRrule 空规则回退 DAILY', freqOfRrule('') === 'DAILY'); + +// ===== 3. rewriteTodoRecurrence 写规则 ===== +const ics1 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:浇花\r\nDUE:20260110T090000\r\nSTATUS:NEEDS-ACTION\r\nPRIORITY:5\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; +const due1 = new Date(2026, 0, 10, 9, 0, 0).getTime(); +const r1 = rewriteTodoRecurrence(ics1, 't1', 'FREQ=WEEKLY', due1, due1); + +ok('写入 RRULE', r1.includes('RRULE:FREQ=WEEKLY')); +ok('⭐ 同时写入 DTSTART(消除客户端歧义)', /DTSTART:\d{8}T\d{6}/.test(r1)); +ok('DUE 保留', /DUE:\d{8}T\d{6}/.test(r1)); +ok('⭐ 保留 PRIORITY(不丢字段)', r1.includes('PRIORITY:5')); +ok('⭐ 保留 STATUS', r1.includes('STATUS:NEEDS-ACTION')); +ok('⭐ 结构完整', structureIntact(r1)); + +// ===== 4. 取消循环 ===== +const ics2 = r1; +const r2 = rewriteTodoRecurrence(ics2, 't1', '', 0, due1); +ok('取消循环 → RRULE 被删', !r2.includes('RRULE:')); +ok('取消循环 → DTSTART 被删', !r2.includes('DTSTART:')); +ok('取消循环 → DUE 保留', /DUE:\d{8}/.test(r2)); +ok('⭐ 取消后结构完整', structureIntact(r2)); + +// ===== 5. 推进到下一轮(模拟 completeRecurringRound)===== +const base = due1; +const nextBase = incForFreq('WEEKLY', base); +const r3 = rewriteTodoRecurrence(ics1, 't1', 'FREQ=WEEKLY', nextBase, nextBase); +const mDt = r3.match(/DTSTART:(\d{8}T\d{6})/); +ok('⭐ 推进后 DTSTART 变成下一周', mDt !== null && mDt[1].startsWith('20260117'), + mDt ? mDt[1] : 'no match'); +ok('推进后 RRULE 不变', r3.includes('RRULE:FREQ=WEEKLY')); + +// ===== 6. 多 VTODO 只改目标 ===== +const ics6 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nDUE:20260101T090000\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nDUE:20260102T090000\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; +const r6 = rewriteTodoRecurrence(ics6, 'a2', 'FREQ=DAILY', due1, due1); +ok('多 VTODO:a1 未加 RRULE', !r6.split('UID:a1')[1].split('END:VTODO')[0].includes('RRULE')); +ok('多 VTODO:a2 已加 RRULE', r6.split('UID:a2')[1].split('END:VTODO')[0].includes('RRULE')); +ok('⭐ 多 VTODO 结构完整', structureIntact(r6)); + +// ===== 7. uid 不匹配 → 原文不动 ===== +ok('uid 不匹配 → 原文不变', rewriteTodoRecurrence(ics1, 'nope', 'FREQ=DAILY', due1, due1) === ics1); + +console.log(`\n结果:通过 ${pass},失败 ${fail}`); +console.log('\n--- 写入循环后的 ICS ---'); +console.log(r1.replace(/\r\n/g, '\n')); +process.exit(fail === 0 ? 0 : 1); \ No newline at end of file