补齐待办模块最后两项协议能力。 ## #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:
@@ -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 !== '';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user