feat(待办): 支持循环任务(标准 CalDAV / VTODO RRULE)#5

只按标准 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 规格里未提及循环任务),
则循环只在客户端可见,同步到其它客户端会退化为普通任务。
This commit is contained in:
2026-11-02 16:53:30 +08:00
parent 9066a12bbc
commit 788171d1b2
6 changed files with 702 additions and 8 deletions
@@ -25,6 +25,11 @@ export class DisplayEvent {
color: string = '#007DFF'; color: string = '#007DFF';
completed: boolean = false; // 仅待办使用 completed: boolean = false; // 仅待办使用
recurring: boolean = false; // 是否为重复日程展开出的发生 recurring: boolean = false; // 是否为重复日程展开出的发生
/** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同:
* recurring 只说"重不重复",这里带的是"多久重复一次"。 */
recurRule: string = '';
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
dtstart: number = 0;
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑) writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
} }
@@ -365,6 +370,9 @@ export class CalendarDataService {
d.calName = nameOf(e.calKey); d.calName = nameOf(e.calKey);
d.color = colorOf(e.calKey); d.color = colorOf(e.calKey);
d.completed = e.completed; d.completed = e.completed;
d.recurRule = e.rrule;
d.dtstart = e.dtstart;
d.recurring = e.rrule !== '';
result.push(d); result.push(d);
} }
} catch (err) { } catch (err) {
+49
View File
@@ -24,6 +24,10 @@ export class LocalEvent {
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示) kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED) completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生 rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
/** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。
* 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**,
* 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */
dtstart: number = 0;
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔 exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。 /** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻—— * 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
@@ -47,6 +51,7 @@ export class RemoteEvent {
isTodo: boolean = false; // VTODO 待办 isTodo: boolean = false; // VTODO 待办
completed: boolean = false; // VTODO STATUS:COMPLETED completed: boolean = false; // VTODO STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE rrule: string = ''; // 原始 RRULE
dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开
exdate: string = ''; // 原始 EXDATE(分号分隔) exdate: string = ''; // 原始 EXDATE(分号分隔)
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无) recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
@@ -124,6 +129,11 @@ export class EventDb {
} catch (err) { } catch (err) {
// 列已存在 // 列已存在
} }
try {
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
try { try {
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''"); await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
} catch (err) { } catch (err) {
@@ -181,6 +191,7 @@ export class EventDb {
const e = new LocalEvent(); const e = new LocalEvent();
e.id = rs.getLong(rs.getColumnIndex('id')); e.id = rs.getLong(rs.getColumnIndex('id'));
e.uid = rs.getString(rs.getColumnIndex('uid')); e.uid = rs.getString(rs.getColumnIndex('uid'));
e.dtstart = rs.getLong(rs.getColumnIndex('dtstart'));
e.calKey = rs.getString(rs.getColumnIndex('cal_key')); e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
e.href = rs.getString(rs.getColumnIndex('href')); e.href = rs.getString(rs.getColumnIndex('href'));
e.remotePath = rs.getString(rs.getColumnIndex('remote_path')); e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
@@ -338,6 +349,44 @@ export class EventDb {
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */ /** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */ /** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */ /** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
/** 保存循环规则与基准(设置/取消循环后调用) */
static async updateTodoRecurrence(context: common.Context, id: number,
rrule: string, dtstart: number, etag: string): Promise<void> {
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<void> {
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 */ /** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */
static async updateTodoFields(context: common.Context, id: number, title: string, static async updateTodoFields(context: common.Context, id: number, title: string,
desc: string, dueMs: number, etag: string): Promise<void> { desc: string, dueMs: number, etag: string): Promise<void> {
+244 -3
View File
@@ -302,7 +302,8 @@ export class IcsUtil {
} }
inserted = true; inserted = true;
} }
vtodoIdx = -1; // ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 0 重新数,
// 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。
result.push(out[i]); result.push(out[i]);
continue; continue;
} }
@@ -474,7 +475,236 @@ export class IcsUtil {
} }
inserted = true; 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]); result.push(out[i]);
continue; continue;
} }
@@ -536,7 +766,18 @@ export class IcsUtil {
current.location = IcsUtil.unescape(value); current.location = IcsUtil.unescape(value);
} else if (propName === 'RRULE') { } else if (propName === 'RRULE') {
current.recurring = true; 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 isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
const t = IcsUtil.parseTime(value, isDateOnly); const t = IcsUtil.parseTime(value, isDateOnly);
if (t !== null) { if (t !== null) {
+116
View File
@@ -141,6 +141,122 @@ export class TodoService {
return null; 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<boolean> {
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<number> {
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<boolean> { static async setCompleted(context: common.Context, localId: number, done: boolean): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId); const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null) { if (row === null) {
+100 -5
View File
@@ -74,6 +74,7 @@ struct Index {
@State todoEditDueMs: number = 0; // 0 = 无截止时间 @State todoEditDueMs: number = 0; // 0 = 无截止时间
@State todoEditBook: string = ''; // 所属本名(只读展示) @State todoEditBook: string = ''; // 所属本名(只读展示)
@State todoEditWritable: boolean = true; @State todoEditWritable: boolean = true;
@State todoEditRecur: string = ''; // 当前循环('' = 不循环),存 FREQ 值如 WEEKLY
@State todoEditHasDue: boolean = false; @State todoEditHasDue: boolean = false;
@State todoEditPickShow: boolean = false; // 截止时间选择器 @State todoEditPickShow: boolean = false; // 截止时间选择器
@State todoEditPickDate: Date = new Date(); @State todoEditPickDate: Date = new Date();
@@ -772,6 +773,9 @@ struct Index {
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `同步完成(${ok} 个账号)` }); .showToast({ message: `同步完成(${ok} 个账号)` });
} }
// 同步完成后重新读库并重建月格。
// ⚠️ reloadAll() 内部已包含 reloadEvents(),这里不要重复调用 ——
// 只在"同步确实拉到了新数据"时再刷一次,避免多余的重建开销。
await this.reloadAll(); await this.reloadAll();
// 同步后刷新服务卡片 + 重建提醒 // 同步后刷新服务卡片 + 重建提醒
try { try {
@@ -2756,6 +2760,51 @@ struct Index {
this.openTodoDuePicker(); 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<string, string>): 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, string>): 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: '备注(可选)' }) TextArea({ text: this.todoEditDesc, placeholder: '备注(可选)' })
.fontSize(14) .fontSize(14)
@@ -2998,6 +3047,7 @@ struct Index {
this.todoEditHasDue = t.startTime > 0; this.todoEditHasDue = t.startTime > 0;
this.todoEditBook = t.calName; this.todoEditBook = t.calName;
this.todoEditWritable = t.writable; this.todoEditWritable = t.writable;
this.todoEditRecur = t.recurRule !== '' ? IcsUtil.freqOfRrule(t.recurRule) : '';
// 默认值给"明天 09:00",与新建日程的口径一致(非今天用 9 点) // 默认值给"明天 09:00",与新建日程的口径一致(非今天用 9 点)
const base: Date = new Date(); const base: Date = new Date();
this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1); this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1);
@@ -3042,6 +3092,13 @@ struct Index {
this.todoEditTitle, this.todoEditDesc, this.todoEditTitle, this.todoEditDesc,
this.todoEditHasDue ? this.todoEditDueMs : 0); this.todoEditHasDue ? this.todoEditDueMs : 0);
if (ok) { 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.todoEditShow = false;
this.getUIContext().getPromptAction().showToast({ message: '已保存' }); this.getUIContext().getPromptAction().showToast({ message: '已保存' });
this.todos = await CalendarDataService.loadTodos(ctx, this.sources); this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
@@ -3106,7 +3163,29 @@ struct Index {
private todoSubtitle(t: DisplayEvent): string { private todoSubtitle(t: DisplayEvent): string {
const due: string = t.startTime > 0 ? `截止 ${this.fmtDue(t.startTime)}` : '无截止时间'; const due: string = t.startTime > 0 ? `截止 ${this.fmtDue(t.startTime)}` : '无截止时间';
const book: string = t.calName !== '' ? ` · ${t.calName}` : ''; 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; this.todoBusy = true;
try { 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) { if (ok) {
this.getUIContext().getPromptAction().showToast({ if (isRecurring && !t.completed) {
message: t.completed ? '已标记为未完成' : '已完成' this.getUIContext().getPromptAction().showToast({
}); message: nextDue > 0 ? `这一轮完成,下次 ${this.fmtDue(nextDue)}` : '这一轮已完成'
});
} else {
this.getUIContext().getPromptAction().showToast({
message: t.completed ? '已标记为未完成' : '已完成'
});
}
// 重新读库刷新列表(服务层已更新 completed) // 重新读库刷新列表(服务层已更新 completed)
this.todos = await CalendarDataService.loadTodos(ctx, this.sources); this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
} else { } else {
+185
View File
@@ -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);