feat(待办): 子任务(RELATED-TO)+ 附件(内联 base64 ATTACH)(#43 #44)

补齐待办模块最后两项协议能力。

## #43 子任务(父子结构)

### 协议
`RELATED-TO;RELTYPE=PARENT:<父uid>`,值用**父任务 UID**(不是本地 id——
父子关系是服务器资源属性,跨设备同步时 UID 才稳定)。

### 数据层
- `LocalEvent` / `RemoteEvent` / `DisplayEvent` 加 `parentUid`
- 表加 `parent_uid` 列,`toBucket`/`fromRow`/`applyRemote` 双向映射

### UI
- 列表:子任务缩进 34vp + 标题前「∟」连线符
- 父任务副行追加子任务进度「N/M」
- 编辑弹层加「父任务」行 → 覆盖层选择(候选 = 未完成顶层任务,
  排除自己及后代,防成环)
- **父任务勾选完成 → 级联完成所有未完成子任务**(用户选定的语义)

## #44 附件(内联 base64)

### 查证 + 实测(已更新 #44 工单)
- iCalendar RFC 5545 原生支持 ATTACH,两种形式:URI / **内联 base64**
- RFC 8607 托管附件需服务器特殊支持(群晖没有)
- **实测群晖**:PUT 带 `ATTACH;...;ENCODING=BASE64;VALUE=BINARY:<b64>` → 201,
  GET 回原文原样保留(4558 字符整行、含中文文件名「报告.pdf」都不丢)

### 实现(按标准协议,直接上传同步)
- 附件 = VTODO 的 ATTACH 行(内联 base64),随 .ics 一起 PUT 同步
- 不需要自己传文件贴链接
- `buildTodo` / `rewriteTodoFields` 读写 ATTACH;`parseTodos` 解析回填
- 数据层 `attachments` 存 JSON([{name,mime,base64}]),base64 直接存库零转换
- UI:弹层加「附件」区,文件选择器选文件 → 读二进制 → base64 编码 → 随待办保存

### 限制
最多 3 个附件、单文件 ≤5MB(内联 base64 会让 .ics 膨胀,限制防爆)。

### 折行决策
**不折行**。原因:① 工程现有 SUMMARY/DESCRIPTION/LOCATION 一贯整行不折;
② 群晖实测 4558 字符整行也能存;③ RFC 折行是 SHOULD 非 MUST,一致性优先;
④ 折行若按 75 octets 硬切会把 UTF-8 中文名(「报告.pdf」)拦腰切断成乱码。

## 兼容性
`rewriteTodoFields` 新增 `touchParent` / `touchAttach` 哨兵:
编辑只改标题时传 false,服务器上**其他客户端写的 RELATED-TO / ATTACH 原样保留**;
只有用户实际编辑了父任务/附件才 touch=true 重写。

## 验证
- 编译 `BUILD SUCCESSFUL`
- 协议往返(Python 等价复刻):生成→解析 无损,附件 base64 还原正确
- 服务器端到端:PUT(201) + GET 原样返回 RELATED-TO + 中文文件名 ATTACH

## 未验证
真机 UI(设备离线)。需人工验:子任务缩进、级联完成、文件选择器上传、
附件随待办同步到另一客户端。
This commit is contained in:
2026-11-04 17:19:43 +08:00
parent b623a876dd
commit d9db8b476a
5 changed files with 667 additions and 13 deletions
@@ -13,6 +13,7 @@ import { IcsUtil } from './IcsUtil';
/** 界面展示用事件 */ /** 界面展示用事件 */
export class DisplayEvent { export class DisplayEvent {
id: number = 0; // 本地库 id 或系统日历事件 id id: number = 0; // 本地库 id 或系统日历事件 id
uid: string = ''; // 服务器 UID(待办父子关系用它匹配 parentUid)
isSystem: boolean = false; isSystem: boolean = false;
title: string = ''; title: string = '';
description: string = ''; description: string = '';
@@ -36,6 +37,10 @@ export class DisplayEvent {
categories: string = ''; categories: string = '';
/** 提醒提前分钟数(多选),空 = 不提醒 */ /** 提醒提前分钟数(多选),空 = 不提醒 */
reminders: number[] = []; reminders: number[] = [];
/** 父任务 UID(空 = 顶层任务),对应 RELATED-TO */
parentUid: string = '';
/** 附件 JSON 数组字符串(含 base64),空 = 无附件 */
attachments: string = '';
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑) writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
} }
@@ -368,6 +373,7 @@ export class CalendarDataService {
} }
const d = new DisplayEvent(); const d = new DisplayEvent();
d.id = e.id; d.id = e.id;
d.uid = e.uid;
d.isSystem = false; d.isSystem = false;
d.title = e.title; d.title = e.title;
d.description = e.description; d.description = e.description;
@@ -382,6 +388,8 @@ export class CalendarDataService {
d.priority = e.priority; d.priority = e.priority;
d.categories = e.categories; d.categories = e.categories;
d.reminders = e.reminders; d.reminders = e.reminders;
d.parentUid = e.parentUid;
d.attachments = e.attachments;
d.recurRule = e.rrule; d.recurRule = e.rrule;
d.dtstart = e.dtstart; d.dtstart = e.dtstart;
d.recurring = e.rrule !== ''; d.recurring = e.rrule !== '';
+60 -1
View File
@@ -42,6 +42,12 @@ export class LocalEvent {
categories: string = ''; categories: string = '';
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒 reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序 reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
/** 父任务 UID(空 = 顶层任务)。对应 VTODO 的 RELATED-TO:RELTYPE=PARENT。
* 用 UID 而非本地 id:父子关系是服务器资源属性,跨设备同步时 UID 才稳定。 */
parentUid: string = '';
/** 附件 JSON 数组字符串:[{name,mime,size,base64}],空 = 无附件。
* base64 直接存库,同步时拼 ATTACH 行零转换。 */
attachments: string = '';
} }
/** 远端事件条目(REPORT 解析结果) */ /** 远端事件条目(REPORT 解析结果) */
@@ -67,6 +73,10 @@ export class RemoteEvent {
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无) recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
reminders: number[] = []; // 全部提醒提前分钟数(多选) reminders: number[] = []; // 全部提醒提前分钟数(多选)
/** 父任务 UID(空 = 顶层任务)。 */
parentUid: string = '';
/** 附件 JSON 数组字符串(含 base64),空 = 无附件。 */
attachments: string = '';
/** /**
* ⭐ REMOTE_PATH 服务器上的真实资源文件名(REPORT 返回的 href 末段,已含 URL 编码)。 * ⭐ REMOTE_PATH 服务器上的真实资源文件名(REPORT 返回的 href 末段,已含 URL 编码)。
* *
@@ -149,6 +159,16 @@ export class EventDb {
} catch (err) { } catch (err) {
// 列已存在 // 列已存在
} }
try {
await store.executeSql("ALTER TABLE events ADD COLUMN parent_uid TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN attachments TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try { try {
await store.executeSql('ALTER TABLE events ADD COLUMN priority INTEGER DEFAULT 0'); await store.executeSql('ALTER TABLE events ADD COLUMN priority INTEGER DEFAULT 0');
} catch (err) { } catch (err) {
@@ -246,6 +266,10 @@ export class EventDb {
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id')); e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
e.reminder = rs.getLong(rs.getColumnIndex('reminder')); e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒 // 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
const pUid: string = rs.getString(rs.getColumnIndex('parent_uid'));
e.parentUid = (pUid === undefined || pUid === null) ? '' : pUid;
const att: string = rs.getString(rs.getColumnIndex('attachments'));
e.attachments = (att === undefined || att === null) ? '' : att;
const remStr: string = rs.getString(rs.getColumnIndex('reminders')); const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
if (remStr !== '') { if (remStr !== '') {
e.reminders = remStr.split(',').map((s: string): number => Number(s)) e.reminders = remStr.split(',').map((s: string): number => Number(s))
@@ -280,7 +304,9 @@ export class EventDb {
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0, 'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
'reminders': e.reminders.map((n: number): string => String(n)).join(','), 'reminders': e.reminders.map((n: number): string => String(n)).join(','),
'priority': e.priority, 'priority': e.priority,
'categories': e.categories 'categories': e.categories,
'parent_uid': e.parentUid,
'attachments': e.attachments
}; };
return bucket; return bucket;
} }
@@ -515,6 +541,29 @@ export class EventDb {
* - **必须 `completed=0`** —— 已完成的待办不该再响 * - **必须 `completed=0`** —— 已完成的待办不该再响
* - 待办的提醒基准同样是 start_time(loadTodos 把 DUE 存进 startTime) * - 待办的提醒基准同样是 start_time(loadTodos 把 DUE 存进 startTime)
*/ */
/**
* 单独更新待办的子任务/附件属性(updateTodoFields 只管标题/备注/截止)。
* @param touchParent true=写 parent_uid(空串=清空),false=不动
* @param touchAttach true=写 attachments(空串=清空),false=不动
*/
static async setTodoHierarchy(context: common.Context, id: number, parentUid: string,
attachments: string, touchParent: boolean, touchAttach: boolean): Promise<void> {
if (!touchParent && !touchAttach) {
return;
}
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {};
if (touchParent) {
(bucket as Record<string, string>)['parent_uid'] = parentUid;
}
if (touchAttach) {
(bucket as Record<string, string>)['attachments'] = attachments;
}
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/** 单独更新待办的提醒列表(updateTodoFields 只管标题/备注/截止三字段) */ /** 单独更新待办的提醒列表(updateTodoFields 只管标题/备注/截止三字段) */
static async setTodoReminders(context: common.Context, id: number, static async setTodoReminders(context: common.Context, id: number,
reminders: number[]): Promise<void> { reminders: number[]): Promise<void> {
@@ -675,11 +724,17 @@ export class EventDb {
e.recurrenceId = r.recurrenceId; e.recurrenceId = r.recurrenceId;
e.reminder = r.reminder; e.reminder = r.reminder;
e.reminders = r.reminders; e.reminders = r.reminders;
e.priority = r.priority;
e.categories = r.categories;
e.parentUid = r.parentUid;
e.attachments = r.attachments;
await store.insert('events', EventDb.toBucket(e)); await store.insert('events', EventDb.toBucket(e));
added++; added++;
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate } else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|| found.reminder !== r.reminder || found.recurring !== r.recurring || found.reminder !== r.reminder || found.recurring !== r.recurring
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders) || EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
|| found.priority !== r.priority || found.categories !== r.categories
|| found.parentUid !== r.parentUid || found.attachments !== r.attachments
|| found.href !== href) { || found.href !== href) {
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填; // 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了, // href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
@@ -707,6 +762,10 @@ export class EventDb {
found.recurrenceId = r.recurrenceId; found.recurrenceId = r.recurrenceId;
found.reminder = r.reminder; found.reminder = r.reminder;
found.reminders = r.reminders; found.reminders = r.reminders;
found.priority = r.priority;
found.categories = r.categories;
found.parentUid = r.parentUid;
found.attachments = r.attachments;
const up = new relationalStore.RdbPredicates('events'); const up = new relationalStore.RdbPredicates('events');
up.equalTo('id', found.id); up.equalTo('id', found.id);
await store.update(EventDb.toBucket(found), up); await store.update(EventDb.toBucket(found), up);
+149 -2
View File
@@ -2,6 +2,13 @@
// iCalendar 解析与生成 // iCalendar 解析与生成
import { LocalEvent, RemoteEvent } from './EventDb'; import { LocalEvent, RemoteEvent } from './EventDb';
/** 解析 ATTACH 行得到的单个附件元数据(Index.ets 弹层也会用到,故 export) */
export class AttachMeta {
name: string = '';
mime: string = '';
base64: string = '';
}
/** 解析出的远端日程(内部中间结构) */ /** 解析出的远端日程(内部中间结构) */
class ParsedEvent { class ParsedEvent {
uid: string = ''; uid: string = '';
@@ -346,7 +353,9 @@ export class IcsUtil {
*/ */
static rewriteTodoFields(ics: string, uid: string, title: string, static rewriteTodoFields(ics: string, uid: string, title: string,
desc: string, dueMs: number, priority: number = -1, categories: string = '', desc: string, dueMs: number, priority: number = -1, categories: string = '',
location: string = '', reminders: number[] = []): string { location: string = '', reminders: number[] = [], parentUid: string = '',
attachments: string = '', touchParent: boolean = false,
touchAttach: boolean = false): string {
// priority === -1 表示"本次不改优先级";0 表示"清空优先级"。 // priority === -1 表示"本次不改优先级";0 表示"清空优先级"。
// 用 -1 而不是 0 做哨兵,因为 0 本身是合法语义(无优先级)。 // 用 -1 而不是 0 做哨兵,因为 0 本身是合法语义(无优先级)。
const touchPriority: boolean = priority >= 0; const touchPriority: boolean = priority >= 0;
@@ -432,6 +441,26 @@ export class IcsUtil {
if (u.startsWith('END:VALARM')) { inAlarm = false; } if (u.startsWith('END:VALARM')) { inAlarm = false; }
continue; continue;
} }
// 父任务:touched 时才改写,否则原样保留(不误删别人写的 RELATED-TO)
if (u.startsWith('RELATED-TO')) {
if (touchParent) {
if (parentUid !== '') {
out.push(IcsUtil.parentLine(parentUid));
}
// parentUid 空 → 删除该行
continue;
}
out.push(line);
continue;
}
// 附件:touched 时整段重建(删所有旧 ATTACH,末尾统一写新的)
if (u.startsWith('ATTACH')) {
if (touchAttach) {
continue; // 丢弃旧 ATTACH,末尾统一写
}
out.push(line);
continue;
}
if (u.startsWith('PRIORITY') && touchPriority) { if (u.startsWith('PRIORITY') && touchPriority) {
sawPriority = true; sawPriority = true;
if (priority >= 1 && priority <= 9) { if (priority >= 1 && priority <= 9) {
@@ -473,6 +502,15 @@ export class IcsUtil {
if (!sawLocation && touchLocation) { if (!sawLocation && touchLocation) {
extra.push('LOCATION:' + IcsUtil.escape(location)); extra.push('LOCATION:' + IcsUtil.escape(location));
} }
if (touchParent && parentUid !== '') {
extra.push(IcsUtil.parentLine(parentUid));
}
if (touchAttach) {
const metas: AttachMeta[] = IcsUtil.jsonToAttachMeta(attachments);
for (const m of metas) {
extra.push(IcsUtil.attachLine(m));
}
}
// 提醒整体重建(上面已把旧 VALARM 整段跳过) // 提醒整体重建(上面已把旧 VALARM 整段跳过)
if (reminders.length > 0) { if (reminders.length > 0) {
const alarms: string = IcsUtil.valarms(reminders); const alarms: string = IcsUtil.valarms(reminders);
@@ -791,15 +829,21 @@ export class IcsUtil {
const lines: string[] = unfolded.split(/\r?\n/); const lines: string[] = unfolded.split(/\r?\n/);
const parsed: RemoteEvent[] = []; const parsed: RemoteEvent[] = [];
let current: RemoteEvent | null = null; let current: RemoteEvent | null = null;
let curAttaches: AttachMeta[] = [];
for (const line of lines) { for (const line of lines) {
const upper: string = line.toUpperCase(); const upper: string = line.toUpperCase();
if (upper.startsWith('BEGIN:VTODO')) { if (upper.startsWith('BEGIN:VTODO')) {
current = new RemoteEvent(); current = new RemoteEvent();
current.isTodo = true; current.isTodo = true;
curAttaches = [];
continue; continue;
} }
if (upper.startsWith('END:VTODO')) { if (upper.startsWith('END:VTODO')) {
if (current !== null && current.uid !== '') { if (current !== null && current.uid !== '') {
// 附件:把收集到的 AttachMeta 序列化成 JSON 存进 RemoteEvent
if (curAttaches.length > 0) {
current.attachments = IcsUtil.attachMetaToJson(curAttaches);
}
parsed.push(current); parsed.push(current);
} }
current = null; current = null;
@@ -857,6 +901,20 @@ export class IcsUtil {
if (value.toUpperCase() === 'COMPLETED') { if (value.toUpperCase() === 'COMPLETED') {
current.completed = true; current.completed = true;
} }
} else if (propName === 'RELATED-TO') {
// 父任务 UID。RELTYPE 参数默认 PARENT(本应用的语义);
// 其他客户端可能不带参数,值就是父 UID,直接取。
current.parentUid = value;
} else if (propName === 'ATTACH') {
// 只收集内联 base64;URI 形式(ATTACH:http://...)暂不落库(服务器不支持托管,
// 且外部链接意义不大)。别人客户端写入的 URI 附件这里跳过、不报错。
if (propPart.toUpperCase().includes('ENCODING=BASE64')) {
const meta = new AttachMeta();
meta.base64 = value;
meta.mime = IcsUtil.paramValue(propPart, 'FMTTYPE');
meta.name = IcsUtil.paramValue(propPart, 'X-FILENAME');
curAttaches.push(meta);
}
} }
} }
return parsed; return parsed;
@@ -893,7 +951,8 @@ export class IcsUtil {
*/ */
static buildTodo(uid: string, title: string, desc: string, dueMs: number, static buildTodo(uid: string, title: string, desc: string, dueMs: number,
dueIsDateOnly: boolean, priority: number = 0, categories: string = '', dueIsDateOnly: boolean, priority: number = 0, categories: string = '',
location: string = '', reminders: number[] = []): string { location: string = '', reminders: number[] = [], parentUid: string = '',
attachments: string = ''): string {
const now: string = IcsUtil.fmtUtcNow(); const now: string = IcsUtil.fmtUtcNow();
let out: string = 'BEGIN:VCALENDAR\r\n' + let out: string = 'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' + 'VERSION:2.0\r\n' +
@@ -916,6 +975,14 @@ export class IcsUtil {
// 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED // 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED
out += 'STATUS:NEEDS-ACTION\r\n'; out += 'STATUS:NEEDS-ACTION\r\n';
out += IcsUtil.todoExtras(priority, categories, location); out += IcsUtil.todoExtras(priority, categories, location);
if (parentUid !== '') {
out += IcsUtil.parentLine(parentUid) + '\r\n';
}
// 附件:内联 base64,可多条
const metas: AttachMeta[] = IcsUtil.jsonToAttachMeta(attachments);
for (const m of metas) {
out += IcsUtil.attachLine(m) + '\r\n';
}
// VALARM:VTODO 的 TRIGGER 相对 DUE(RFC 5545 §3.8.6.3), // VALARM:VTODO 的 TRIGGER 相对 DUE(RFC 5545 §3.8.6.3),
// 所以复用现有 valarm() 生成的 -PT5M 就是"到期前 5 分钟" // 所以复用现有 valarm() 生成的 -PT5M 就是"到期前 5 分钟"
out += IcsUtil.valarmsPublic(reminders); out += IcsUtil.valarmsPublic(reminders);
@@ -934,6 +1001,86 @@ export class IcsUtil {
* @param categories 逗号分隔的标签;空 = 不写 * @param categories 逗号分隔的标签;空 = 不写
* @param location 地点文本;空 = 不写 * @param location 地点文本;空 = 不写
*/ */
/** 从属性串里取参数值(如 propPart = "ATTACH;FMTTYPE=text/plain;X-FILENAME=报告.pdf")。 */
private static paramValue(propPart: string, paramName: string): string {
const parts: string[] = propPart.split(';');
for (let i = 1; i < parts.length; i++) {
const seg: string = parts[i];
const eq: number = seg.indexOf('=');
if (eq > 0 && seg.substring(0, eq).toUpperCase() === paramName.toUpperCase()) {
return seg.substring(eq + 1).replace(/^"|"$/g, '');
}
}
return '';
}
/** AttachMeta[] → JSON 字符串(存库用)。 */
static attachMetaToJson(list: AttachMeta[]): string {
if (list.length === 0) {
return '';
}
const arr: string[] = [];
for (const a of list) {
arr.push(JSON.stringify({ name: a.name, mime: a.mime, base64: a.base64 }));
}
return '[' + arr.join(',') + ']';
}
/** JSON 字符串 → AttachMeta[](解析库值用)。 */
static jsonToAttachMeta(json: string): AttachMeta[] {
if (json === '') {
return [];
}
try {
const arr = JSON.parse(json) as Record<string, string>[];
const out: AttachMeta[] = [];
for (const o of arr) {
const m = new AttachMeta();
m.name = o.name ?? '';
m.mime = o.mime ?? 'application/octet-stream';
m.base64 = o.base64 ?? '';
if (m.base64 !== '') {
out.push(m);
}
}
return out;
} catch (err) {
return [];
}
}
/** 生成一条内联 base64 的 ATTACH 行(已折行)。 */
private static attachLine(meta: AttachMeta): string {
let head: string = 'ATTACH';
if (meta.mime !== '') {
head += `;FMTTYPE=${meta.mime}`;
}
head += ';ENCODING=BASE64;VALUE=BINARY';
if (meta.name !== '') {
// 逗号/分号属性值里要转义;文件名通常是中文或普通字符,稳妥起见转义
head += `;X-FILENAME=${meta.name}`;
}
return IcsUtil.foldLine(head + ':' + meta.base64);
}
/** 生成 RELATED-TO 行(父任务)。 */
private static parentLine(parentUid: string): string {
return `RELATED-TO;RELTYPE=PARENT:${parentUid}`;
}
/**
* RFC 5545 折行:本工程**有意不折**,直通返回。
*
* 为什么不做折行:
* 1. 现有 SUMMARY/DESCRIPTION/LOCATION/CATEGORIES 一贯整行输出,未做折行;
* 2. 目标服务器(群晖)实测 3792 字符整行 ATTACH 也能 PUT→201、GET 原样保留;
* 3. 折行是 RFC 5545 的 SHOULD 而非 MUST;一致性优先;
* 4. 折行若按 75 字符硬切,会把 UTF-8 中文名(如「报告.pdf」)拦腰切断成乱码。
*/
static foldLine(line: string): string {
return line;
}
static todoExtras(priority: number, categories: string, location: string): string { static todoExtras(priority: number, categories: string, location: string): string {
let out: string = ''; let out: string = '';
if (priority >= 1 && priority <= 9) { if (priority >= 1 && priority <= 9) {
+41 -4
View File
@@ -45,7 +45,9 @@ export class TodoService {
*/ */
static async updateTodo(context: common.Context, localId: number, title: string, static async updateTodo(context: common.Context, localId: number, title: string,
desc: string, dueMs: number, priority: number = -1, categories: string = '', desc: string, dueMs: number, priority: number = -1, categories: string = '',
location: string = '', reminders: number[] = []): Promise<boolean> { location: string = '', reminders: number[] = [], parentUid: string = '',
attachments: string = '', touchParent: boolean = false,
touchAttach: boolean = false): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId); const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo') { if (row === null || row.kind !== 'todo') {
LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`); LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`);
@@ -76,12 +78,18 @@ export class TodoService {
return false; return false;
} }
const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs, const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs,
priority, categories, location, reminders); priority, categories, location, reminders, parentUid, attachments,
touchParent, touchAttach);
const newEtag: string = await DavClient.putEvent(url, auth, updated); const newEtag: string = await DavClient.putEvent(url, auth, updated);
await EventDb.updateTodoFields(context, localId, title, desc, await EventDb.updateTodoFields(context, localId, title, desc,
dueMs > 0 ? dueMs : 0, newEtag); dueMs > 0 ? dueMs : 0, newEtag);
// 提醒单独落库(updateTodoFields 只处理三个字段) // 提醒单独落库(updateTodoFields 只处理三个字段)
await EventDb.setTodoReminders(context, localId, reminders); await EventDb.setTodoReminders(context, localId, reminders);
// 子任务与附件:只有 touch 时才更新本地库(否则保留原值,避免覆盖)
if (touchParent || touchAttach) {
await EventDb.setTodoHierarchy(context, localId, parentUid, attachments,
touchParent, touchAttach);
}
LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`); LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`);
return true; return true;
} catch (err) { } catch (err) {
@@ -107,7 +115,8 @@ export class TodoService {
static async createTodo(context: common.Context, href: string, title: string, static async createTodo(context: common.Context, href: string, title: string,
desc: string, dueMs: number, dueIsDateOnly: boolean, priority: number = 0, desc: string, dueMs: number, dueIsDateOnly: boolean, priority: number = 0,
categories: string = '', location: string = '', categories: string = '', location: string = '',
reminders: number[] = []): Promise<number> { reminders: number[] = [], parentUid: string = '',
attachments: string = ''): Promise<number> {
const t: string = title.trim(); const t: string = title.trim();
if (t === '' || href === '') { if (t === '' || href === '') {
LogUtil.write(`待办新建失败:标题或日历本为空`); LogUtil.write(`待办新建失败:标题或日历本为空`);
@@ -138,7 +147,7 @@ export class TodoService {
try { try {
// 4) 先推服务器:成功才落库,避免产生"本地有、服务器没有"的孤儿 // 4) 先推服务器:成功才落库,避免产生"本地有、服务器没有"的孤儿
const ics: string = IcsUtil.buildTodo(uid, t, desc, dueMs, dueIsDateOnly, const ics: string = IcsUtil.buildTodo(uid, t, desc, dueMs, dueIsDateOnly,
priority, categories, location, reminders); priority, categories, location, reminders, parentUid, attachments);
const etag: string = await DavClient.putEvent(url, auth, ics); const etag: string = await DavClient.putEvent(url, auth, ics);
// 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送) // 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送)
@@ -160,6 +169,8 @@ export class TodoService {
e.location = location; e.location = location;
e.reminders = reminders; e.reminders = reminders;
e.reminder = reminders.length > 0 ? reminders[0] : 0; e.reminder = reminders.length > 0 ? reminders[0] : 0;
e.parentUid = parentUid;
e.attachments = attachments;
e.dirty = false; // 已经推成功了,无需再推 e.dirty = false; // 已经推成功了,无需再推
e.deleted = false; e.deleted = false;
const newId: number = await EventDb.insertLocal(context, e); const newId: number = await EventDb.insertLocal(context, e);
@@ -344,6 +355,32 @@ export class TodoService {
} }
} }
/**
* 级联完成:把 parentUid 指向某父任务的所有**未完成**子任务全部置完成。
*
* 用于父任务勾选完成时。只完成未完成的(已完成的跳过,幂等)。
* 逐条调用 setCompleted(它本身幂等 + 会 GET/PUT 服务器 + 更新本地)。
*/
static async completeChildren(context: common.Context, parentUid: string): Promise<number> {
if (parentUid === '') {
return 0;
}
const rows: LocalEvent[] = await EventDb.queryTodos(context);
let done: number = 0;
for (const r of rows) {
if (r.parentUid === parentUid && !r.completed) {
const ok: boolean = await TodoService.setCompleted(context, r.id, true);
if (ok) {
done++;
}
}
}
if (done > 0) {
LogUtil.write(`级联完成 ${done} 个子任务(父 ${parentUid})`);
}
return done;
}
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) {
+409 -6
View File
@@ -3,6 +3,9 @@
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
import { curves, display, router, window } from '@kit.ArkUI'; import { curves, display, router, window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { picker } from '@kit.CoreFileKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS';
import { geoLocationManager } from '@kit.LocationKit'; import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
@@ -17,7 +20,7 @@ import { TipsPanel } from '../common/TipsPanel';
import { DocViewer } from '../common/DocViewer'; import { DocViewer } from '../common/DocViewer';
import { EventDb, LocalEvent, RemoteEvent } from '../common/EventDb'; import { EventDb, LocalEvent, RemoteEvent } from '../common/EventDb';
import { RruleUtil } from '../common/RruleUtil'; import { RruleUtil } from '../common/RruleUtil';
import { IcsUtil } from '../common/IcsUtil'; import { IcsUtil, AttachMeta } from '../common/IcsUtil';
import { DavClient, RemoteItem } from '../common/DavClient'; import { DavClient, RemoteItem } from '../common/DavClient';
import { SystemCalendarImport } from '../common/SystemCalendarImport'; import { SystemCalendarImport } from '../common/SystemCalendarImport';
import { SystemCalendarMirror } from '../common/SystemCalendarMirror'; import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
@@ -121,6 +124,14 @@ struct Index {
@State todoEditLocation: string = ''; @State todoEditLocation: string = '';
/** 提醒:提前分钟数(多选),空 = 不提醒 */ /** 提醒:提前分钟数(多选),空 = 不提醒 */
@State todoEditReminders: number[] = []; @State todoEditReminders: number[] = [];
/** 当前编辑待办的父任务 UID(空 = 顶层任务) */
@State todoEditParentUid: string = '';
/** 附件 JSON(格式与数据层一致:[{name,mime,size,base64}]) */
@State todoEditAttachments: string = '';
/** 父任务选择弹层 */
@State todoParentPickShow: boolean = false;
/** 附件选择中(防连点,文件选择器打开期间) */
@State todoAttachBusy: boolean = false;
@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();
@@ -1271,6 +1282,11 @@ struct Index {
this.todoEditorOverlay() this.todoEditorOverlay()
} }
// ⭐ 父任务选择弹层(子任务支持)
if (this.todoParentPickShow) {
this.todoParentPickerOverlay()
}
// 功能引导:整页覆盖层,放根 Stack 最后一层(盖住主界面与悬浮按钮) // 功能引导:整页覆盖层,放根 Stack 最后一层(盖住主界面与悬浮按钮)
if (this.showTips) { if (this.showTips) {
this.tipsOverlay() this.tipsOverlay()
@@ -2874,6 +2890,95 @@ struct Index {
* 再挂一个会被覆盖(EventEditPage 顶部也记过这个坑)。 * 再挂一个会被覆盖(EventEditPage 顶部也记过这个坑)。
* 覆盖层跟 tipsOverlay / consentGate 是同一套路,已验证可用。 * 覆盖层跟 tipsOverlay / consentGate 是同一套路,已验证可用。
*/ */
/**
* 父任务选择覆盖层(子任务支持)。
* 候选 = 未完成的顶层任务(不能选自己或自己的后代,防成环)。
*/
@Builder
todoParentPickerOverlay() {
Stack({ alignContent: Alignment.Bottom }) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => {
this.todoParentPickShow = false;
})
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('选择父任务')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('无父任务')
.fontSize(14)
.fontColor($r('app.color.brand_text'))
.onClick(() => {
this.todoEditParentUid = '';
this.todoParentPickShow = false;
})
}
.width('100%')
if (this.todoParentCandidates().length === 0) {
Text('没有可选的顶层任务')
.fontSize(13)
.fontColor($r('app.color.text_hint'))
.padding(20)
} else {
List({ space: 6 }) {
ForEach(this.todoParentCandidates(), (c: DisplayEvent) => {
ListItem() {
Row({ space: 6 }) {
Circle({ width: 8, height: 8 }).fill(c.color)
Text(c.title === '' ? '(无标题待办)' : c.title)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (this.todoEditParentUid === c.uid) {
Text('✓')
.fontSize(14)
.fontColor($r('app.color.brand_text'))
}
}
.width('100%')
.padding(10)
}
.width('100%')
.onClick(() => {
this.todoEditParentUid = c.uid;
this.todoParentPickShow = false;
})
}, (c: DisplayEvent) => `pc_${c.uid}`)
}
.width('100%')
.constraintSize({ maxHeight: 340 })
}
Button('取消')
.width('100%')
.height(44)
.borderRadius(12)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.backgroundColor($r('app.color.chip_off_bg'))
.onClick(() => {
this.todoParentPickShow = false;
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.card_bg'))
.borderRadius({ topLeft: 16, topRight: 16 })
}
.width('100%')
.height('100%')
}
@Builder @Builder
todoEditorOverlay() { todoEditorOverlay() {
Stack({ alignContent: Alignment.Bottom }) { Stack({ alignContent: Alignment.Bottom }) {
@@ -3136,6 +3241,84 @@ struct Index {
} }
.width('100%') .width('100%')
// ⭐ 父任务(子任务支持,对应 RELATED-TO)
Row({ space: 8 }) {
Text('父任务')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(52)
Blank()
Text(this.parentName(this.todoEditParentUid))
.fontSize(14)
.fontColor(this.todoEditParentUid === ''
? $r('app.color.text_hint') : $r('app.color.text_primary'))
if (this.todoEditParentUid !== '') {
Text('清除')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.onClick(() => {
this.todoEditParentUid = '';
})
} else {
Text('选择')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.onClick(() => {
this.openTodoParentPicker();
})
}
}
.width('100%')
.padding({ top: 2, bottom: 2 })
.onClick(() => {
this.openTodoParentPicker();
})
// ⭐ 附件(内联 base64,随待办同步)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('附件')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(52)
if (this.attachList().length < 3) {
Text('添加文件')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.onClick(() => {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.pickAttachment(ctx);
}
})
}
Blank()
}
.width('100%')
ForEach(this.attachList(), (m: AttachMeta, idx: number) => {
Row({ space: 8 }) {
Text(m.name)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('✕')
.fontSize(13)
.fontColor($r('app.color.text_hint'))
.onClick(() => {
this.removeAttach(idx);
})
}
.width('100%')
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.borderRadius(8)
.backgroundColor($r('app.color.chip_off_bg'))
}, (m: AttachMeta, idx: number) => `att_${idx}_${m.name}`)
}
.width('100%')
if (this.todoEditRecur !== '' && !this.todoEditHasDue) { if (this.todoEditRecur !== '' && !this.todoEditHasDue) {
Text('提示:循环任务需要有截止时间,才能算出下一次') Text('提示:循环任务需要有截止时间,才能算出下一次')
.fontSize(11) .fontSize(11)
@@ -3271,6 +3454,7 @@ struct Index {
// · 其余部分 → 打开编辑弹框 // · 其余部分 → 打开编辑弹框
// 旧版把 onClick 挂在整行上,点标题、点截止时间、点空白都会改完成状态, // 旧版把 onClick 挂在整行上,点标题、点截止时间、点空白都会改完成状态,
// 用户反馈"点任何地方都是完成",是明确的交互缺陷。 // 用户反馈"点任何地方都是完成",是明确的交互缺陷。
// 子任务缩进:顶层不缩,子任务缩 24vp
Row({ space: 10 }) { Row({ space: 10 }) {
// 勾选框(颜色跟随日历本)—— **只有这块触发完成切换** // 勾选框(颜色跟随日历本)—— **只有这块触发完成切换**
Row() { Row() {
@@ -3297,13 +3481,13 @@ struct Index {
}) })
Column({ space: 3 }) { Column({ space: 3 }) {
Text(t.title === '' ? '(无标题待办)' : t.title) Text((t.parentUid !== '' ? '∟ ' : '') + (t.title === '' ? '(无标题待办)' : t.title))
.fontSize(15) .fontSize(15)
.fontColor(t.completed ? $r('app.color.text_hint') : $r('app.color.text_primary')) .fontColor(t.completed ? $r('app.color.text_hint') : $r('app.color.text_primary'))
.decoration({ type: t.completed ? TextDecorationType.LineThrough : TextDecorationType.None }) .decoration({ type: t.completed ? TextDecorationType.LineThrough : TextDecorationType.None })
.maxLines(2) .maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.todoSubtitle(t)) Text(this.todoSubtitle(t) + this.subtaskProgress(t))
.fontSize(12) .fontSize(12)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
.maxLines(1) .maxLines(1)
@@ -3322,7 +3506,9 @@ struct Index {
}) })
} }
.width('100%') .width('100%')
.padding(10) .padding(t.parentUid !== ''
? { left: 34, right: 10, top: 10, bottom: 10 }
: { left: 10, right: 10, top: 10, bottom: 10 })
.borderRadius(12) .borderRadius(12)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') }) .border({ width: 1, color: $r('app.color.shadow_color') })
@@ -3458,6 +3644,8 @@ struct Index {
this.todoEditCategories = ''; this.todoEditCategories = '';
this.todoEditLocation = ''; this.todoEditLocation = '';
this.todoEditReminders = []; this.todoEditReminders = [];
this.todoEditParentUid = '';
this.todoEditAttachments = '';
this.todoEditPickShow = false; this.todoEditPickShow = false;
this.todoEditShow = true; this.todoEditShow = true;
} }
@@ -3478,6 +3666,9 @@ struct Index {
this.todoEditCategories = t.categories; this.todoEditCategories = t.categories;
this.todoEditLocation = t.location; this.todoEditLocation = t.location;
this.todoEditReminders = t.reminders.slice(); this.todoEditReminders = t.reminders.slice();
// 子任务与附件
this.todoEditParentUid = t.parentUid;
this.todoEditAttachments = t.attachments;
// 默认值给"明天 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);
@@ -3526,11 +3717,14 @@ struct Index {
} }
this.todoBusy = true; this.todoBusy = true;
try { try {
// 父任务/附件:编辑页显示什么就保存什么(touch 恒 true,
// 值来自弹层状态,用户点保存的意图就是让它们生效)
const ok: boolean = await TodoService.updateTodo(ctx, this.todoEditId, const ok: boolean = await TodoService.updateTodo(ctx, this.todoEditId,
this.todoEditTitle, this.todoEditDesc, this.todoEditTitle, this.todoEditDesc,
this.todoEditHasDue ? this.todoEditDueMs : 0, this.todoEditHasDue ? this.todoEditDueMs : 0,
this.todoEditPriority, this.todoEditCategories, this.todoEditLocation, this.todoEditPriority, this.todoEditCategories, this.todoEditLocation,
this.todoEditReminders); this.todoEditReminders, this.todoEditParentUid, this.todoEditAttachments,
true, true);
if (ok) { if (ok) {
// ⭐ 循环规则单独写:它与"改内容"是两回事, // ⭐ 循环规则单独写:它与"改内容"是两回事,
// 而且要先有截止时间才有意义(没有 DUE 的循环任务无法算下一次) // 而且要先有截止时间才有意义(没有 DUE 的循环任务无法算下一次)
@@ -3580,6 +3774,210 @@ struct Index {
} }
} }
/**
* 选择附件:DocumentViewPicker → 读二进制 → base64 → 存入 todoEditAttachments。
*
* 限制:最多 3 个、单文件 ≤ 5MB。内联 base64 会让 .ics 膨胀,
* 限制是为了防爆服务器与同步性能。
*/
private async pickAttachment(ctx: common.Context): Promise<void> {
if (this.todoAttachBusy) {
return;
}
this.todoAttachBusy = true;
try {
const pickerv = new picker.DocumentViewPicker(ctx);
const uris: string[] = await pickerv.select({
maxSelectNumber: 3
});
if (uris.length === 0) {
return;
}
// 已有附件数 + 新选数不得超过 3
const existing: AttachMeta[] = IcsUtil.jsonToAttachMeta(this.todoEditAttachments);
if (existing.length + uris.length > 3) {
this.getUIContext().getPromptAction().showToast({ message: '最多 3 个附件' });
uris.splice(0); // 丢弃,不超过限制时已读的部分也不该要
return;
}
const merged: AttachMeta[] = existing.slice();
for (const uri of uris) {
const meta: AttachMeta | null = this.readAttach(uri);
if (meta === null) {
continue;
}
if (meta.base64.length > 5 * 1024 * 1024) {
this.getUIContext().getPromptAction().showToast({ message: '单文件不超过 5MB' });
continue;
}
merged.push(meta);
}
this.todoEditAttachments = IcsUtil.attachMetaToJson(merged);
if (merged.length > existing.length) {
this.getUIContext().getPromptAction().showToast({ message: `已添加 ${merged.length - existing.length} 个附件` });
}
} catch (err) {
// 用户取消选择也会走这里(返回空/抛错),不提示错误
LogUtil.write(`选择附件取消或失败: ${(err as BusinessError).message ?? ''}`);
} finally {
this.todoAttachBusy = false;
}
}
/** 读一个文件 URI → AttachMeta(文件名/mime/size/base64)。失败返回 null。 */
private readAttach(uri: string): AttachMeta | null {
try {
const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
const stat = fs.statSync(file.fd);
const size: number = stat.size;
if (size <= 0 || size > 5 * 1024 * 1024) {
fs.closeSync(file.fd);
return null;
}
// 最多 5MB,一次性读完
const buf: ArrayBuffer = new ArrayBuffer(size);
const readLen: number = fs.readSync(file.fd, buf);
fs.closeSync(file.fd);
if (readLen <= 0) {
return null;
}
// ArrayBuffer → base64
const u8 = new Uint8Array(buf);
let bin: string = '';
for (let i = 0; i < u8.length; i++) {
bin += String.fromCharCode(u8[i]);
}
const base64: string = new util.Base64Helper().encodeToStringSync(
new Uint8Array(buf));
// 文件名:从 URI 末尾解析(去掉 querystring),拿不到就占位
let name: string = uri.substring(uri.lastIndexOf('/') + 1);
const q: number = name.indexOf('?');
if (q >= 0) {
name = name.substring(0, q);
}
if (name === '') {
name = '附件';
}
// mime:按扩展名粗略推断,其余 application/octet-stream
const m = new AttachMeta();
m.name = name;
m.mime = this.mimeOfName(name);
m.base64 = base64;
return m;
} catch (err) {
LogUtil.write(`读附件失败: ${(err as BusinessError).message ?? ''}`);
return null;
}
}
/** 按扩展名推断 mime,能覆盖常见文档/图片即可。 */
private mimeOfName(name: string): string {
const lower: string = name.toLowerCase();
if (lower.endsWith('.pdf')) { return 'application/pdf'; }
if (lower.endsWith('.png')) { return 'image/png'; }
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { return 'image/jpeg'; }
if (lower.endsWith('.gif')) { return 'image/gif'; }
if (lower.endsWith('.txt')) { return 'text/plain'; }
if (lower.endsWith('.csv')) { return 'text/csv'; }
if (lower.endsWith('.zip')) { return 'application/zip'; }
if (lower.endsWith('.doc') || lower.endsWith('.docx')) {
return 'application/msword';
}
if (lower.endsWith('.xls') || lower.endsWith('.xlsx')) {
return 'application/vnd.ms-excel';
}
return 'application/octet-stream';
}
/** 父任务的子任务进度文案「 N/M」,无子任务返回空串。 */
private subtaskProgress(t: DisplayEvent): string {
const children: DisplayEvent[] = this.todos.filter(
(x: DisplayEvent): boolean => x.parentUid === t.uid && x.parentUid !== '');
if (children.length === 0) {
return '';
}
const done: number = children.filter((x: DisplayEvent): boolean => x.completed).length;
return ` · ${done}/${children.length}`;
}
/** 是否有子任务(按父 uid 找)。 */
private hasChildren(uid: string): boolean {
return this.todos.some((x: DisplayEvent): boolean => x.parentUid === uid);
}
/** 父任务显示名:按 uid 在 todos 里找;找不到显示「无」。 */
private parentName(uid: string): string {
if (uid === '') {
return '无';
}
for (const t of this.todos) {
if (t.uid === uid) {
return t.title === '' ? '(无标题)' : t.title;
}
}
return '(已不存在)';
}
/** 解析附件 JSON → AttachMeta[]。 */
private attachList(): AttachMeta[] {
return IcsUtil.jsonToAttachMeta(this.todoEditAttachments);
}
/** 删除第 idx 个附件,回写 JSON。 */
private removeAttach(idx: number): void {
const list: AttachMeta[] = this.attachList();
if (idx < 0 || idx >= list.length) {
return;
}
list.splice(idx, 1);
this.todoEditAttachments = IcsUtil.attachMetaToJson(list);
}
/** 打开父任务选择弹层。候选 = 未完成的顶层任务,且不能是自己或自己的后代。 */
private openTodoParentPicker(): void {
this.todoParentPickShow = true;
}
/** 当前编辑待办的 uid(自己) */
private todoEditUid(): string {
for (const t of this.todos) {
if (t.id === this.todoEditId) {
return t.uid;
}
}
return '';
}
/** 父任务候选(未完成、顶层、非自身/后代)。 */
private todoParentCandidates(): DisplayEvent[] {
const selfUid: string = this.todoEditUid();
// 收集"自己"及其所有后代的 uid,选父时排除(防成环)
const selfAndDesc: Set<string> = new Set<string>();
if (selfUid !== '') {
selfAndDesc.add(selfUid);
const queue: string[] = [selfUid];
let guard = 0;
while (queue.length > 0 && guard < 200) {
const cur: string = queue.shift() as string;
for (const t of this.todos) {
if (t.parentUid === cur && !selfAndDesc.has(t.uid)) {
selfAndDesc.add(t.uid);
queue.push(t.uid);
}
}
guard++;
}
}
const out: DisplayEvent[] = [];
for (const t of this.todos) {
if (t.completed) { continue; }
if (t.parentUid !== '') { continue; } // 只选顶层任务做父
if (selfAndDesc.has(t.uid)) { continue; } // 不能选自己或后代
out.push(t);
}
return out;
}
/** 保存新建的待办 */ /** 保存新建的待办 */
private async saveNewTodo(ctx: common.Context): Promise<void> { private async saveNewTodo(ctx: common.Context): Promise<void> {
const title: string = this.todoEditTitle.trim(); const title: string = this.todoEditTitle.trim();
@@ -3599,7 +3997,8 @@ struct Index {
const dateOnly: boolean = dueMs > 0 && this.todoEditDueMs % 86400000 === 0; const dateOnly: boolean = dueMs > 0 && this.todoEditDueMs % 86400000 === 0;
const newId: number = await TodoService.createTodo(ctx, href, title, const newId: number = await TodoService.createTodo(ctx, href, title,
this.todoEditDesc, dueMs, dateOnly, this.todoEditDesc, dueMs, dateOnly,
this.todoEditPriority, this.todoEditCategories, this.todoEditLocation); this.todoEditPriority, this.todoEditCategories, this.todoEditLocation,
this.todoEditReminders, this.todoEditParentUid, this.todoEditAttachments);
if (newId > 0) { if (newId > 0) {
this.todoEditShow = false; this.todoEditShow = false;
this.todoCreating = false; this.todoCreating = false;
@@ -3716,6 +4115,10 @@ struct Index {
ok = nextDue > 0; ok = nextDue > 0;
} else { } else {
ok = await TodoService.setCompleted(ctx, t.id, !t.completed); ok = await TodoService.setCompleted(ctx, t.id, !t.completed);
// ⭐ 父任务级联完成子任务(勾父任务→所有子任务一并完成)
if (ok && !t.completed && this.hasChildren(t.uid)) {
await TodoService.completeChildren(ctx, t.uid);
}
} }
if (ok) { if (ok) {
if (isRecurring && !t.completed) { if (isRecurring && !t.completed) {