== #6 月视图事件标记(每日期最多一个圆点)== 应用市场反馈希望有事件的日期带标记。此前该功能存在但被移除, 原因是**按日历本逐个渲染**:3 个本 3 个点,15 个本满屏都是点。 - MonthCell 只加**一个布尔** hasEvent,不存每本一个的集合 - 用 buildEventDaySet() 一次遍历把有事件的日期收进 Set, 渲染时 O(1) 查(避免 42 格 × N 事件的 O(n²),本机近 3000 条数据会卡) - 判定口径与 eventsOfDate 一致(区间相交)→ 跨天事件覆盖的每一天都有点 - 待办不计入(无时间点语义,混进来点会失去意义) - 视觉用主题色 5vp 小点而非纯红:42 格里 30 个红点会吵, 正是用户嫌乱的同类问题;选中日深底下换白色保证对比度 - 无事件时用等高透明占位,避免行高跳动 == #5 待办勾选完成 == 先回答关键问题:**系统未开放待办 API**。 本机 SDK 检索 @ohos.calendarManager 全文,todo/VTODO/task 零命中, Calendar/Event 只处理 VEVENT ⇒ 第三方应用无法读写系统「待办」页签。 故只能在本应用内实现,并已在待办页注明不会出现在系统待办中。 现状是**只读**(点击提示请在源端修改),本次补上写回: - TodoService.setCompleted():GET 原文 → 改状态字段 → PUT 回写 - EventDb: queryById / setTodoCompleted - DavClient.getRawStrict():失败**抛错**(原 getRaw 失败返回 HTTP 4xx 字符串,若当 ICS 内容 PUT 回去会把待办覆盖成一句错误信息) - IcsUtil.rewriteTodoCompletion() ⚠️ 两个关键设计: 1. **不重建 VTODO,只在原文上改字段** VTODO 有 SUMMARY/DESCRIPTION/DUE/PRIORITY/CATEGORIES/X-* 等字段, 本地库只存了少数几个。重建后 PUT 会把没存的字段全部抹掉(丢数据)。 2. **两遍处理,与字段顺序无关** RFC 5545 不规定字段顺序。单遍实现若 STATUS 出现在 UID 之前, matched 仍为 false → 状态改不到 → 用户点完成没反应。 改为第 1 遍定位目标 VTODO 块、第 2 遍在块内改写。 插入补字段时按**块序号**定位而非行号(改动会删掉旧 COMPLETED 行, 行号不再对齐,会让字段落到 END:VCALENDAR 之后,部分服务端直接 400)。 同步维护三个字段(按 RFC 5545,缺一个各客户端显示就不一致): STATUS / COMPLETED / PERCENT-COMPLETE。 UI:顶部新增「待办」标签(月/周/列表/待办),未完成在前、已完成在后, 点击勾选即写回;写回失败本地状态不变(避免界面与服务器不一致)。 验证:scripts/test-ics-todo.js 覆盖 26 项(状态在 UID 前/后、取消完成、 多 VTODO 只改目标、缺 STATUS 补字段、折行、结构完整性),全部通过。 编译 BUILD SUCCESSFUL。
This commit is contained in:
@@ -250,6 +250,37 @@ export class DavClient {
|
||||
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 拉取单个资源原文,**失败抛错**(与 getRaw 相反)。
|
||||
*
|
||||
* 为什么需要严格版:待办写回要"先 GET 原文、改字段、再 PUT"。
|
||||
* 若 GET 失败却拿到一个 `"HTTP 404"` 之类的字符串并当 ICS 内容 PUT 回去,
|
||||
* 会把服务器上那条待办**覆盖成一句错误信息**(数据破坏)。
|
||||
* 原 getRaw 保留原行为不动(诊断用,已有调用方)。
|
||||
*/
|
||||
static async getRawStrict(url: string, auth: string): Promise<string> {
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: http.RequestMethod.GET,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 20000,
|
||||
expectDataType: http.HttpDataType.STRING
|
||||
});
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
throw new DavHttpError(resp.responseCode, `拉取待办原文失败,服务器返回 ${resp.responseCode}`);
|
||||
}
|
||||
return resp.result as string;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
||||
static async getRaw(url: string, auth: string): Promise<string> {
|
||||
const httpRequest = http.createHttp();
|
||||
|
||||
@@ -336,6 +336,36 @@ export class EventDb {
|
||||
}
|
||||
|
||||
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
|
||||
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
|
||||
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
|
||||
static async setTodoCompleted(context: common.Context, id: number, completed: boolean,
|
||||
etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const values: relationalStore.ValuesBucket = {
|
||||
'completed': completed ? 1 : 0,
|
||||
'dirty': 0,
|
||||
'etag': etag
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(values, predicates);
|
||||
}
|
||||
|
||||
static async queryById(context: common.Context, id: number): Promise<LocalEvent | null> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
const rs = await store.query(predicates);
|
||||
try {
|
||||
if (rs.goToNextRow()) {
|
||||
return EventDb.fromRow(rs);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
|
||||
@@ -170,6 +170,163 @@ export class IcsUtil {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 在**服务器原文**上改写某个 VTODO 的完成状态,返回新的整份 ICS。
|
||||
*
|
||||
* 为什么不从本地字段重建:VTODO 上还有 SUMMARY / DESCRIPTION / DUE / PRIORITY /
|
||||
* CATEGORIES / X-* 等大量字段,本地库只保存了其中少数几个。
|
||||
* 若按本地字段重新拼一份 VTODO 再 PUT,等于**把没保存的字段全部删掉**(丢用户数据)。
|
||||
* 因此这里只做"在原文上精确改那几行",其余内容原样保留。
|
||||
*
|
||||
* 改动范围(按 RFC 5545,三者要一起维护,否则各客户端显示不一致):
|
||||
* - `STATUS:COMPLETED` ⇄ `STATUS:NEEDS-ACTION`
|
||||
* - `COMPLETED:<UTC时间>`(完成时写入,取消完成时删除)
|
||||
* - `PERCENT-COMPLETE:100` ⇄ `PERCENT-COMPLETE:0`
|
||||
*
|
||||
* @param ics 服务器上的原始 .ics 全文
|
||||
* @param uid 目标待办的 UID(一个资源里理论上只有一个 VTODO,但仍按 uid 定位更稳)
|
||||
* @param done true=标记完成;false=取消完成
|
||||
* @returns 改写后的完整 ICS(调用方直接 PUT)
|
||||
*/
|
||||
static rewriteTodoCompletion(ics: string, uid: string, done: boolean): string {
|
||||
const crlf: string = '\r\n';
|
||||
// 折行还原(RFC 5545 允许续行,续行以空格/Tab 开头)
|
||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
|
||||
const upperOf = (s: string): string => s.toUpperCase();
|
||||
const nowUtc: string = IcsUtil.fmtUtcNow();
|
||||
|
||||
// ---------- 第 1 遍:找出"含目标 uid 的 VTODO"的 [begin, end] 行号区间 ----------
|
||||
// 必须两遍:RFC 5545 不规定字段顺序,UID 可能出现在 STATUS 之后。
|
||||
// 单遍实现会漏改(实测风险:STATUS 在前时 matched 仍为 false → 用户点了没反应)。
|
||||
let targetBegin: number = -1;
|
||||
let targetEnd: number = -1;
|
||||
let targetOrdinal: number = -1; // 目标 VTODO 是第几个块(0 基),供插入段按结构定位
|
||||
let curBegin: number = -1;
|
||||
let curHasUid: boolean = false;
|
||||
let blockIdx: number = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u: string = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) {
|
||||
blockIdx++;
|
||||
curBegin = i;
|
||||
curHasUid = false;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) {
|
||||
curHasUid = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||
targetBegin = curBegin;
|
||||
targetEnd = i;
|
||||
targetOrdinal = blockIdx;
|
||||
}
|
||||
curBegin = -1;
|
||||
curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) {
|
||||
// 找不到目标 uid:原样返回(调用方会 PUT 回去,内容未变,等于无操作)
|
||||
return ics;
|
||||
}
|
||||
|
||||
// ---------- 第 2 遍:在区间内改写状态字段 ----------
|
||||
const out: string[] = [];
|
||||
let sawStatus: boolean = false;
|
||||
let sawPercent: boolean = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line: string = lines[i];
|
||||
const u: string = upperOf(line);
|
||||
const inTarget: boolean = i > targetBegin && i < targetEnd;
|
||||
|
||||
if (!inTarget) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('STATUS:')) {
|
||||
sawStatus = true;
|
||||
out.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('COMPLETED')) {
|
||||
continue; // 丢弃旧的,完成时下面统一补
|
||||
}
|
||||
if (u.startsWith('PERCENT-COMPLETE')) {
|
||||
sawPercent = true;
|
||||
out.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
// 缺字段则在目标 VTODO 的 END 之前补齐(有的客户端缺 STATUS 就不显示完成态)
|
||||
const extra: string[] = [];
|
||||
if (!sawStatus) {
|
||||
extra.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
|
||||
}
|
||||
if (done) {
|
||||
extra.push('COMPLETED:' + nowUtc);
|
||||
}
|
||||
if (!sawPercent) {
|
||||
extra.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
|
||||
}
|
||||
if (extra.length === 0) {
|
||||
const plain: string = out.join(crlf);
|
||||
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||
}
|
||||
|
||||
// ⭐ 插入点必须按"结构"定位,不能按行号:
|
||||
// 改动可能删掉了旧的 COMPLETED 行,out 与第一遍的 lines 行号已不对齐,
|
||||
// 用行号定位会把字段写到 END:VTODO 之外(实测会落到 END:VCALENDAR 之后,
|
||||
// 结构非法,部分服务端直接 400)。
|
||||
// 这里重新数 VTODO 块,找到"第 targetOrdinal 个"块的 END 行插进去。
|
||||
const result: string[] = [];
|
||||
let vtodoIdx: number = -1; // 当前处于第几个 VTODO 块内(-1=不在块内)
|
||||
let inserted: boolean = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u: string = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) {
|
||||
vtodoIdx++;
|
||||
result.push(out[i]);
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
// 走到目标块的 END 之前先补字段
|
||||
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||
for (const ex of extra) {
|
||||
result.push(ex);
|
||||
}
|
||||
inserted = true;
|
||||
}
|
||||
vtodoIdx = -1;
|
||||
result.push(out[i]);
|
||||
continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
// 兜底(理论上不会发生):退回到"整份文本末尾 END:VCALENDAR 之前"插入
|
||||
const tail0: string = result.join(crlf);
|
||||
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
|
||||
if (idxVc > 0) {
|
||||
const head: string = tail0.substring(0, idxVc);
|
||||
const tail: string = tail0.substring(idxVc);
|
||||
const merged: string = head + extra.join(crlf) + crlf + tail;
|
||||
return merged.endsWith(crlf) ? merged : merged + crlf;
|
||||
}
|
||||
for (const ex of extra) {
|
||||
result.push(ex);
|
||||
}
|
||||
}
|
||||
|
||||
const text2: string = result.join(crlf);
|
||||
return text2.endsWith(crlf) ? text2 : text2 + crlf;
|
||||
}
|
||||
|
||||
/** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
|
||||
static parseTodos(ics: string): RemoteEvent[] {
|
||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount } from './AccountStore';
|
||||
import { AccountStore } from './AccountStore';
|
||||
import { LocalEvent } from './EventDb';
|
||||
import { EventDb } from './EventDb';
|
||||
import { DavClient, DavHttpError } from './DavClient';
|
||||
import { IcsUtil } from './IcsUtil';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/**
|
||||
* 待办(VTODO)操作。
|
||||
*
|
||||
* 现状:待办**只读展示**已实现(拉取/解析/入库/列表),本类补上唯一的写操作
|
||||
* ——「勾选完成 / 取消完成」。
|
||||
*
|
||||
* ⚠️ 为什么不做"从本地字段重建 VTODO 再 PUT":
|
||||
* VTODO 上有 SUMMARY / DESCRIPTION / DUE / PRIORITY / CATEGORIES / X-* 等字段,
|
||||
* 本地库只存了其中几个。重建后 PUT 等于把没存的字段**全部抹掉**。
|
||||
* 所以走"GET 原文 → 就地改 STATUS/COMPLETED/PERCENT-COMPLETE → PUT"。
|
||||
*
|
||||
* ⚠️ 为什么不能同步到手机系统待办:
|
||||
* 系统未开放待办 API(@ohos.calendarManager 只有日程/VEVENT,无 Todo 对象),
|
||||
* 第三方应用无法读写系统日历的「待办」页签。详见工单 #5。
|
||||
*/
|
||||
export class TodoService {
|
||||
/**
|
||||
* 设置待办完成状态并回写服务器。
|
||||
*
|
||||
* @param localId 本地 events 表行 id(DisplayEvent.id)
|
||||
* @param done true=标记完成;false=取消完成
|
||||
* @returns 成功返回 true;失败返回 false(**本地状态不变**,避免界面与服务器不一致)
|
||||
*/
|
||||
static async setCompleted(context: common.Context, localId: number, done: boolean): Promise<boolean> {
|
||||
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||
if (row === null) {
|
||||
LogUtil.write(`待办完成失败:本地行不存在 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
if (row.kind !== 'todo') {
|
||||
LogUtil.write(`待办完成失败:该行不是待办 id=${localId} kind=${row.kind}`);
|
||||
return false;
|
||||
}
|
||||
if (row.href === '' || row.remotePath === '') {
|
||||
LogUtil.write(`待办完成失败:缺少服务器路径(不可写)id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
if (row.completed === done) {
|
||||
return true; // 状态没变化,直接算成功(幂等)
|
||||
}
|
||||
|
||||
// 1) 找到该 href 所属账号,构造鉴权
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
let acc: DavAccount | null = null;
|
||||
for (const a of accounts) {
|
||||
if (a.calendarHrefs.includes(row.href)) {
|
||||
acc = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (acc === null) {
|
||||
LogUtil.write(`待办完成失败:找不到所属账号 href=${row.href}`);
|
||||
return false;
|
||||
}
|
||||
// 只读日历本:推送必然 403,提前拦下给出明确结果
|
||||
const idx: number = acc.calendarHrefs.indexOf(row.href);
|
||||
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
|
||||
LogUtil.write(`待办完成失败:该日历本只读 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||
const url: string = row.href.endsWith('/') ? row.href + row.remotePath : `${row.href}/${row.remotePath}`;
|
||||
|
||||
try {
|
||||
// 2) GET 原文(保住本地没存的字段)
|
||||
const original: string = await DavClient.getRawStrict(url, auth);
|
||||
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
|
||||
LogUtil.write(`待办完成失败:服务器返回的内容不是 VTODO id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3) 只改状态相关字段
|
||||
const updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, done);
|
||||
|
||||
// 4) PUT 回去,拿新 etag
|
||||
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||
|
||||
// 5) 同步本地(成功后才改,保证两边一致)
|
||||
await EventDb.setTodoCompleted(context, localId, done, newEtag);
|
||||
LogUtil.write(`待办${done ? '完成' : '取消完成'}成功 id=${localId}(标题已脱敏)`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError) {
|
||||
const he = err as DavHttpError;
|
||||
LogUtil.write(`待办写回失败 HTTP ${he.status}:id=${localId}(本地状态未改)`);
|
||||
} else {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`待办写回失败:id=${localId} ${e.message ?? ''}(本地状态未改)`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
||||
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||
import { MirrorInbound } from '../common/MirrorInbound';
|
||||
import { ScreenKeeper } from '../common/ScreenKeeper';
|
||||
import { TodoService } from '../common/TodoService';
|
||||
import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil';
|
||||
|
||||
/** 月视图单元格 */
|
||||
@@ -32,12 +33,16 @@ class MonthCell {
|
||||
inMonth: boolean = false;
|
||||
isToday: boolean = false;
|
||||
lunar: string = ''; // 农历(初一时显示月名)
|
||||
/** ⭐ 该日期是否有任意事件 → 决定是否画一个小圆点。
|
||||
* **只用一个布尔**:早先按"每个日历本一个点"渲染,3 个本就有 3 个点,
|
||||
* 15 个本时整个月视图全是点,所以那个实现被整体砍掉了。 */
|
||||
hasEvent: boolean = false;
|
||||
}
|
||||
|
||||
const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
/** 视图前后顺序(决定手势/标签切换时的转场方向):月 → 周 → 列表 */
|
||||
const VIEW_ORDER: string[] = ['month', 'week', 'agenda'];
|
||||
const VIEW_ORDER: string[] = ['month', 'week', 'agenda', 'todo'];
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@@ -48,6 +53,7 @@ struct Index {
|
||||
@Watch('onSelectedDateChange') @State selectedDate: number = 0; // 当天 0 点毫秒
|
||||
@State events: DisplayEvent[] = [];
|
||||
@State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示)
|
||||
@State todoBusy: boolean = false; // 正在写回待办状态(防连点)
|
||||
@State agendaEvents: DisplayEvent[] = []; // 列表视图全量数据(今天 ~ 未来2年)
|
||||
private agendaStale: boolean = true; // 全量数据是否需要重新加载
|
||||
@State monthPages: MonthCell[][] = [[], [], []]; // 上月/本月/下月
|
||||
@@ -449,12 +455,42 @@ struct Index {
|
||||
}
|
||||
|
||||
/** 构建某年某月的 42 格 */
|
||||
/**
|
||||
* ⭐ 预先算出"有事件的日期"集合(按 dayStart 归一化)。
|
||||
*
|
||||
* 为什么要预计算:月视图一次渲染 42 个格子,若每格都去 filter 一遍全部事件,
|
||||
* 就是 O(42 × 事件数);本机 15 个本、近 3000 条数据,翻月会明显卡顿。
|
||||
* 这里只遍历事件一次,把区间相交的每一天都塞进 Set,之后每格 O(1) 查询。
|
||||
*
|
||||
* 与 `eventsOfDate()` 用**完全相同**的区间相交口径
|
||||
* (`startOfDay(start) <= day && end >= day`),
|
||||
* 因此跨天/多天事件覆盖的每一天都会有点,不会出现"点进去有日程、格子上却没点"的不一致。
|
||||
* 另:待办(isTodo)不计入 —— 待办没有时间点语义,混进月视图会让点失去意义。
|
||||
*/
|
||||
private buildEventDaySet(): Set<number> {
|
||||
const set: Set<number> = new Set<number>();
|
||||
for (const e of this.events) {
|
||||
const s: number = this.startOfDay(e.startTime);
|
||||
const end: number = e.endTime > 0 ? e.endTime : s;
|
||||
// 兜底:异常数据(end < start)或跨度大得离谱(>1 年)不入集合,避免循环失控
|
||||
if (end < s || end - s > 366 * 86400000) {
|
||||
set.add(s);
|
||||
continue;
|
||||
}
|
||||
for (let t = s; t <= end; t += 86400000) {
|
||||
set.add(this.startOfDay(t));
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private buildMonthCells(year: number, month: number): MonthCell[] {
|
||||
const first: number = new Date(year, month, 1).getTime();
|
||||
const firstDay = new Date(first).getDay();
|
||||
const offset: number = (firstDay + 6) % 7; // 周一为一周开始
|
||||
const gridStart: number = first - offset * 86400000;
|
||||
const todayStart: number = this.startOfDay(Date.now());
|
||||
const eventDays: Set<number> = this.buildEventDaySet();
|
||||
const cells: MonthCell[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const dateMs: number = gridStart + i * 86400000;
|
||||
@@ -465,6 +501,7 @@ struct Index {
|
||||
cell.inMonth = d.getMonth() === month;
|
||||
cell.isToday = dateMs === todayStart;
|
||||
cell.lunar = LunarUtil.lunarDayText(dateMs);
|
||||
cell.hasEvent = eventDays.has(this.startOfDay(dateMs));
|
||||
cells.push(cell);
|
||||
}
|
||||
return cells;
|
||||
@@ -1387,12 +1424,20 @@ struct Index {
|
||||
this.modeTab('month', '月')
|
||||
this.modeTab('week', '周')
|
||||
this.modeTab('agenda', '列表')
|
||||
// ⭐ TODO_TAB 待办页签:与列表同一套布局(未完成在前、已完成在后),点击可勾选完成
|
||||
this.modeTab('todo', '待办')
|
||||
Blank()
|
||||
if (this.mode === 'month') {
|
||||
Text(this.fmtMonthTitle())
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
} else if (this.mode === 'todo') {
|
||||
// 待办与具体日期无关,显示剩余未完成数量更有用
|
||||
Text(`${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length} 项待办`)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
} else {
|
||||
Text(this.fmtDateCn(this.selectedDate))
|
||||
.fontSize(15)
|
||||
@@ -2509,6 +2554,11 @@ struct Index {
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
.textAlign(TextAlign.Center)
|
||||
Text('提示:系统日历未开放待办接口,这里的待办不会出现在系统「待办」页签中')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(32)
|
||||
@@ -2565,11 +2615,39 @@ struct Index {
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
.opacity(t.completed ? 0.7 : 1)
|
||||
.onClick(() => {
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: '待办来自服务器,请在群晖日历等源端修改' });
|
||||
this.toggleTodo(t);
|
||||
})
|
||||
}
|
||||
|
||||
/** 待办完成状态切换:写回服务器成功后刷新列表;失败保持原状并提示。
|
||||
* ⚠️ 不做"先改界面再回滚":待办写回要走网络,若先改后失败,
|
||||
* 用户会看到状态闪一下又跳回去,容易误以为操作没生效。这里直接等结果。 */
|
||||
private async toggleTodo(t: DisplayEvent): Promise<void> {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined || this.todoBusy) {
|
||||
return;
|
||||
}
|
||||
this.todoBusy = true;
|
||||
try {
|
||||
const ok: boolean = await TodoService.setCompleted(ctx, t.id, !t.completed);
|
||||
if (ok) {
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: t.completed ? '已标记为未完成' : '已完成'
|
||||
});
|
||||
// 重新读库刷新列表(服务层已更新 completed)
|
||||
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
||||
} else {
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: '更新失败:需联网,且该日历本需可写'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.getUIContext().getPromptAction().showToast({ message: '更新失败,请稍后重试' });
|
||||
} finally {
|
||||
this.todoBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否跨天(结束日期晚于开始日期) */
|
||||
private spansDays(e: DisplayEvent): boolean {
|
||||
return this.startOfDay(e.endTime) > this.startOfDay(e.startTime);
|
||||
@@ -2599,9 +2677,22 @@ struct Index {
|
||||
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
|
||||
.maxLines(1)
|
||||
}
|
||||
// ⭐ EVENT_DOT 有事件的日期标记:**每个日期最多一个**圆点。
|
||||
// 只表达"这天有安排",不区分是哪个日历本、有几条。
|
||||
// (旧实现按日历本逐个画,3 个本 3 个点,15 个本满屏都是点,故被废弃。)
|
||||
// 无事件时用等高的透明占位,避免有/无点导致格子高度跳动、整月行高不一致。
|
||||
Row()
|
||||
.width(this.wideLayout ? 4 : 5)
|
||||
.height(this.wideLayout ? 4 : 5)
|
||||
.borderRadius(this.wideLayout ? 2 : 2.5)
|
||||
.backgroundColor(cell.hasEvent
|
||||
? (cell.dateMs === this.selectedDate
|
||||
? $r('app.color.button_text') // 选中日深底 → 用白点保证对比度
|
||||
: $r('app.color.brand')) // 其余用主题色,比纯红柔和
|
||||
: Color.Transparent)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
.padding({ top: this.wideLayout ? 2 : 3, bottom: this.wideLayout ? 2 : 3 })
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.selectedDate = cell.dateMs;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// 验证 IcsUtil.rewriteTodoCompletion(第二版:按结构定位插入点)
|
||||
function fmtUtcNow() {
|
||||
return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
}
|
||||
|
||||
function rewrite(ics, uid, done) {
|
||||
const crlf = '\r\n';
|
||||
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
|
||||
const upperOf = s => s.toUpperCase();
|
||||
const nowUtc = fmtUtcNow();
|
||||
|
||||
let targetBegin = -1, targetEnd = -1, targetOrdinal = -1;
|
||||
let curBegin = -1, curHasUid = false, blockIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { blockIdx++; curBegin = i; curHasUid = false; continue; }
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) curHasUid = true;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||
targetBegin = curBegin; targetEnd = i; targetOrdinal = blockIdx;
|
||||
}
|
||||
curBegin = -1; curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) return ics;
|
||||
|
||||
const out = [];
|
||||
let sawStatus = false, sawPercent = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i], u = upperOf(line);
|
||||
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
|
||||
if (u.startsWith('STATUS:')) { sawStatus = true; out.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION'); continue; }
|
||||
if (u.startsWith('COMPLETED')) { continue; }
|
||||
if (u.startsWith('PERCENT-COMPLETE')) { sawPercent = true; out.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0'); continue; }
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const extra = [];
|
||||
if (!sawStatus) extra.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
|
||||
if (done) extra.push('COMPLETED:' + nowUtc);
|
||||
if (!sawPercent) extra.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
|
||||
if (extra.length === 0) {
|
||||
const plain = out.join(crlf);
|
||||
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
let vtodoIdx = -1, inserted = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||
for (const ex of extra) result.push(ex);
|
||||
inserted = true;
|
||||
}
|
||||
vtodoIdx = -1; result.push(out[i]); continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
const tail0 = result.join(crlf);
|
||||
const idxVc = tail0.lastIndexOf('END:VCALENDAR');
|
||||
if (idxVc > 0) {
|
||||
const merged = tail0.substring(0, idxVc) + extra.join(crlf) + crlf + tail0.substring(idxVc);
|
||||
return merged.endsWith(crlf) ? merged : merged + crlf;
|
||||
}
|
||||
for (const ex of extra) result.push(ex);
|
||||
}
|
||||
const text2 = result.join(crlf);
|
||||
return text2.endsWith(crlf) ? text2 : text2 + crlf;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function assert(name, cond, detail) {
|
||||
if (cond) { console.log(' ✅', name); pass++; }
|
||||
else { console.log(' ❌', name, detail || ''); fail++; }
|
||||
}
|
||||
|
||||
/** 关键结构校验:任何字段都不能跑到 END:VCALENDAR 之后 */
|
||||
function structureIntact(ics) {
|
||||
const iEnd = ics.indexOf('END:VCALENDAR');
|
||||
if (iEnd < 0) return false;
|
||||
const after = ics.substring(iEnd + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '');
|
||||
return after === ''; // END:VCALENDAR 之后不应有任何内容
|
||||
}
|
||||
function balanced(ics, tag) {
|
||||
const b = (ics.match(new RegExp('BEGIN:' + tag, 'g')) || []).length;
|
||||
const e = (ics.match(new RegExp('END:' + tag, 'g')) || []).length;
|
||||
return b === e;
|
||||
}
|
||||
|
||||
// 1. STATUS 在 UID 之前(触发补字段的路径)
|
||||
const ics1 = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nDTSTAMP:20260101T000000Z\r\nSTATUS:NEEDS-ACTION\r\nSUMMARY:买菜\r\nUID:todo-abc-123\r\nDUE:20260105T090000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r1 = rewrite(ics1, 'todo-abc-123', true);
|
||||
assert('STATUS 在 UID 之前 → COMPLETED', r1.includes('STATUS:COMPLETED'));
|
||||
assert('旧状态消失', !r1.includes('STATUS:NEEDS-ACTION'));
|
||||
assert('有 COMPLETED 时间戳', /COMPLETED:\d{8}T\d{6}Z/.test(r1));
|
||||
assert('补 PERCENT-COMPLETE:100', r1.includes('PERCENT-COMPLETE:100'));
|
||||
assert('保留 SUMMARY', r1.includes('SUMMARY:买菜'));
|
||||
assert('保留 DUE', r1.includes('DUE:20260105T090000Z'));
|
||||
assert('⭐ 结构完整(END:VCALENDAR 后无残留)', structureIntact(r1), JSON.stringify(r1.slice(r1.indexOf('END:VCALENDAR'))));
|
||||
assert('BEGIN/END VTODO 配对', balanced(r1, 'VTODO'));
|
||||
|
||||
// 2. UID 在前 + 已有 STATUS/PERCENT(不触发补字段)
|
||||
const ics2 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:todo-xyz\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:写报告\r\nPRIORITY:1\r\nSTATUS:NEEDS-ACTION\r\nPERCENT-COMPLETE:0\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r2 = rewrite(ics2, 'todo-xyz', true);
|
||||
assert('UID 在前 → COMPLETED', r2.includes('STATUS:COMPLETED'));
|
||||
assert('PERCENT 改 100', r2.includes('PERCENT-COMPLETE:100'));
|
||||
assert('保留 SUMMARY/PRIORITY', r2.includes('SUMMARY:写报告') && r2.includes('PRIORITY:1'));
|
||||
assert('⭐ 结构完整', structureIntact(r2));
|
||||
assert('COMPLETED 时间戳在 VTODO 内部', (() => {
|
||||
const body = r2.substring(r2.indexOf('BEGIN:VTODO'), r2.indexOf('END:VTODO'));
|
||||
return /COMPLETED:\d{8}/.test(body);
|
||||
})());
|
||||
|
||||
// 3. 取消完成
|
||||
const ics3 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:todo-xyz\r\nSTATUS:COMPLETED\r\nCOMPLETED:20260101T000000Z\r\nPERCENT-COMPLETE:100\r\nSUMMARY:写报告\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r3 = rewrite(ics3, 'todo-xyz', false);
|
||||
assert('取消完成 → NEEDS-ACTION', r3.includes('STATUS:NEEDS-ACTION'));
|
||||
assert('PERCENT 回 0', r3.includes('PERCENT-COMPLETE:0'));
|
||||
assert('COMPLETED 行被移除', !/COMPLETED:\d{8}/.test(r3));
|
||||
assert('⭐ 结构完整', structureIntact(r3));
|
||||
|
||||
// 4. uid 不匹配 → 原文不动
|
||||
assert('uid 不匹配 → 原文不变', rewrite(ics1, 'nope', true) === ics1);
|
||||
|
||||
// 5. 多 VTODO 只改目标
|
||||
const ics5 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r5 = rewrite(ics5, 'a2', true);
|
||||
assert('多 VTODO:a1 未动', r5.split('UID:a1')[1].split('END:VTODO')[0].includes('STATUS:NEEDS-ACTION'));
|
||||
assert('多 VTODO:a2 已改', r5.split('UID:a2')[1].split('END:VTODO')[0].includes('STATUS:COMPLETED'));
|
||||
assert('⭐ 多 VTODO 结构完整', structureIntact(r5));
|
||||
assert('两个 VTODO 都配对', balanced(r5, 'VTODO'));
|
||||
|
||||
// 6. 目标 VTODO 缺 STATUS(走"补字段"最极端路径)
|
||||
const ics6 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:bare\r\nSUMMARY:没有状态字段\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r6 = rewrite(ics6, 'bare', true);
|
||||
assert('缺 STATUS 时能补上', r6.includes('STATUS:COMPLETED'));
|
||||
assert('⭐ 补在 VTODO 内而非 VCALENDAR 后', structureIntact(r6) && (() => {
|
||||
const body = r6.substring(r6.indexOf('BEGIN:VTODO'), r6.indexOf('END:VTODO'));
|
||||
return body.includes('STATUS:COMPLETED');
|
||||
})());
|
||||
|
||||
// 7. 折行保留
|
||||
const ics7 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:long\r\nSUMMARY:很长的标题\r\n 需要折行\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
assert('折行被还原保留', rewrite(ics7, 'long', true).includes('很长的标题需要折行'));
|
||||
|
||||
// 8. 末尾多个空行也不应让结构崩
|
||||
const ics8 = ics1 + '\r\n\r\n';
|
||||
assert('⭐ 尾部空行场景结构完整', structureIntact(rewrite(ics8, 'todo-abc-123', true)));
|
||||
|
||||
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
|
||||
console.log('\n--- 测试1 输出 ---');
|
||||
console.log(r1.replace(/\r\n/g, '\n'));
|
||||
console.log('--- 测试6 输出(最极端:无 STATUS)---');
|
||||
console.log(r6.replace(/\r\n/g, '\n'));
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user