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:
@@ -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) {
|
||||
|
||||
@@ -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<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 */
|
||||
static async updateTodoFields(context: common.Context, id: number, title: string,
|
||||
desc: string, dueMs: number, etag: string): Promise<void> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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> {
|
||||
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||
if (row === null) {
|
||||
|
||||
@@ -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<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: '备注(可选)' })
|
||||
.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) {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user