Compare commits
4
Commits
v0.1.0
..
98c4b6a96f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c4b6a96f | ||
|
|
1cc35f147d | ||
|
|
199b6c177f | ||
|
|
63b1a75b03 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||||
|
|
||||||
- 应用市场送审版本:**0.0.6** | 仓库当前:**0.0.6**(versionCode 600)
|
- 应用市场送审版本:**0.1.0** | 仓库当前:**0.1.0**(versionCode 10000)
|
||||||
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
|
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
|
||||||
- 开源协议:Apache License 2.0
|
- 开源协议:Apache License 2.0
|
||||||
|
|
||||||
@@ -32,10 +32,19 @@
|
|||||||
### 日程编辑与提醒
|
### 日程编辑与提醒
|
||||||
|
|
||||||
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
|
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
|
||||||
|
- **地点搜索补全**:在「地点」栏输入关键词即可列出匹配地点(华为 Map Kit / Site Kit),点一下填入完整地址;也可点「地图」进入系统地图界面搜索、拖动图钉选点。**不申请任何定位权限**,手动输入同样可用
|
||||||
|
- **一键导航**:日程详情中点击「导航 ›」,用系统地理编码把地点地址换算为坐标后唤起高德地图;未安装则回退网页版。**不申请定位权限**(`getAddressesFromLocationName` 无需该权限),不会读取设备实时位置
|
||||||
|
- **必填项校验强提醒**:未填标题等必填项时,以 Toast + 输入框标红 + 自动滚回定位三管齐下提示,不会出现"提示在屏幕外看不到"的情况
|
||||||
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
|
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
|
||||||
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
||||||
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
|
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
|
||||||
|
|
||||||
|
### CalDAV 地址自动发现
|
||||||
|
|
||||||
|
- 只填服务器域名也能用:按标准 **`/.well-known/caldav`** 自动发现端点,并在常见路径(Nextcloud、群晖等)中依次尝试
|
||||||
|
- 发现时只认 `resourcetype` 含 `calendar` 的集合,避免把 DAV 根或分享链接误当成日历
|
||||||
|
- 失败时给出**人话错误提示**(如"正确形式通常为 `https://域名/remote.php/dav/calendars/用户名/`"),而不是抛原始状态码
|
||||||
|
|
||||||
### 系统日历镜像(可选增强,默认关闭)
|
### 系统日历镜像(可选增强,默认关闭)
|
||||||
|
|
||||||
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
|
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
|
||||||
@@ -81,10 +90,12 @@
|
|||||||
|
|
||||||
我们遵循"**数据自持、最小必要**"原则:
|
我们遵循"**数据自持、最小必要**"原则:
|
||||||
|
|
||||||
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机)
|
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机,且以 **AES-256-GCM** 加密存储,密钥由系统 HUKS/TEE 保管、不可导出)
|
||||||
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
|
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
|
||||||
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
|
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
|
||||||
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒、点击地点导航)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
- **不申请定位权限**:本应用从不读取设备实时位置。「一键导航」所需的坐标由系统地理编码服务按**你填写的地址文本**换算,故 `module.json5` 中不含 `LOCATION` / `APPROXIMATELY_LOCATION`
|
||||||
|
- 地点关键词检索由 HarmonyOS 系统能力**华为地图服务**(Map Kit / Site Kit)提供,仅用于检索,不用于广告、画像或统计分析
|
||||||
|
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
||||||
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
|
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -699,4 +699,45 @@ export class AccountStore {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。
|
||||||
|
*
|
||||||
|
* 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`,
|
||||||
|
* 于是调用方无法从返回值区分两种情况:
|
||||||
|
* ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**);
|
||||||
|
* ② 密文存在但解不开,或读取过程抛错(临时故障)。
|
||||||
|
*
|
||||||
|
* 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理,
|
||||||
|
* 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。
|
||||||
|
*
|
||||||
|
* 判定口径(三者同时成立才算"确认空白"):
|
||||||
|
* - 偏好里**没有**账号密文(`data_b64` 为空);
|
||||||
|
* - 旧版明文存储也没有账号;
|
||||||
|
* - 读取过程没有抛异常。
|
||||||
|
*
|
||||||
|
* @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过
|
||||||
|
*/
|
||||||
|
static async isEmptyVaultConfirmed(context: common.Context): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences = await Vault.prefs(context);
|
||||||
|
const data: string = await store.get(VAULT_DATA, '') as string;
|
||||||
|
if (data !== '') {
|
||||||
|
// 有密文:只有能解出"0 个账号"才算确认空白;解不开一律视为拿不准
|
||||||
|
const plain: string | null = await Vault.decrypt(context, data);
|
||||||
|
if (plain === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const arr: Array<Record<string, Object>> = JSON.parse(plain) as Array<Record<string, Object>>;
|
||||||
|
return arr.length === 0;
|
||||||
|
}
|
||||||
|
// 无密文:再看旧版明文存储是否还有残留账号
|
||||||
|
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
|
||||||
|
return legacy === null || legacy.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
console.error(`[AccountStore] isEmptyVaultConfirmed 判定失败(按"拿不准"处理): ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,37 @@ export class DavClient {
|
|||||||
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
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(调试诊断用) */
|
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
||||||
static async getRaw(url: string, auth: string): Promise<string> {
|
static async getRaw(url: string, auth: string): Promise<string> {
|
||||||
const httpRequest = http.createHttp();
|
const httpRequest = http.createHttp();
|
||||||
|
|||||||
@@ -336,6 +336,36 @@ export class EventDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
|
/** 全部未删除的待办(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[]> {
|
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
|
||||||
const store = await EventDb.getDb(context);
|
const store = await EventDb.getDb(context);
|
||||||
const predicates = new relationalStore.RdbPredicates('events');
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
|||||||
@@ -170,6 +170,163 @@ export class IcsUtil {
|
|||||||
return 0;
|
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 待办列表(待办为只读展示,不参与推送) */
|
/** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
|
||||||
static parseTodos(ics: string): RemoteEvent[] {
|
static parseTodos(ics: string): RemoteEvent[] {
|
||||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||||
|
|||||||
@@ -795,10 +795,13 @@ export class SystemCalendarMirror {
|
|||||||
/**
|
/**
|
||||||
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
|
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
|
||||||
*
|
*
|
||||||
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与
|
* 三种情况(0.1.1 起,配合"卸载残留"问题):
|
||||||
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。
|
* ① **还有账号** → 只清"accId 不在当前账号集合里"的 —— 原逻辑,精准不误伤。
|
||||||
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。
|
* ② **账号库为空、且确认是真空**(`isEmptyVaultConfirmed()`)→ **全部清掉**。
|
||||||
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。
|
* 这是卸载重装 / 用户重置账号库后的场景:旧镜像账户的 accId 不可能再匹配上,
|
||||||
|
* 留着只会变成系统日历里永远删不掉的幽灵本,还会继续提醒。
|
||||||
|
* ③ **账号库为空、但拿不准**(读取异常 / 密文解不开)→ **仍然跳过**。
|
||||||
|
* `loadAll()` 会把异常吞掉返回 `[]`,若不区分就会把用户真实在用的镜像全删了。
|
||||||
*
|
*
|
||||||
* @returns 删除的账户数
|
* @returns 删除的账户数
|
||||||
*/
|
*/
|
||||||
@@ -812,8 +815,14 @@ export class SystemCalendarMirror {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return 0; // 读失败一律不动
|
return 0; // 读失败一律不动
|
||||||
}
|
}
|
||||||
|
let wipeAll: boolean = false;
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
return 0; // 安全阀:见注释
|
// 空账号库:只有"确认空白"才敢全清,否则保守跳过(见上方 ③)
|
||||||
|
wipeAll = await AccountStore.isEmptyVaultConfirmed(context);
|
||||||
|
if (!wipeAll) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
LogUtil.write('兜底清理孤儿镜像:账号库确认为空 → 清理全部镜像账户(卸载/重置后残留)');
|
||||||
}
|
}
|
||||||
const validAccIds: Set<string> = new Set<string>();
|
const validAccIds: Set<string> = new Set<string>();
|
||||||
for (const a of accounts) {
|
for (const a of accounts) {
|
||||||
@@ -838,11 +847,13 @@ export class SystemCalendarMirror {
|
|||||||
} else {
|
} else {
|
||||||
sep = dash >= 0 ? dash : under;
|
sep = dash >= 0 ? dash : under;
|
||||||
}
|
}
|
||||||
if (sep <= 0) {
|
// 非全清模式:必须能解析出 accId 才能比对,解析不出就跳过(宁可留,不误删)
|
||||||
continue; // 解析不出 accId → 不动(宁可留,不误删)
|
// 全清模式:前缀已命中就是本应用建的,脏账户(解析不出 accId)也要删干净
|
||||||
|
if (!wipeAll && sep <= 0) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const accId: string = bucket.substring(0, sep);
|
const accId: string = sep > 0 ? bucket.substring(0, sep) : bucket;
|
||||||
if (validAccIds.has(accId)) {
|
if (!wipeAll && validAccIds.has(accId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,8 @@ struct EventEditPage {
|
|||||||
@State sheetMode: string = 'picker';
|
@State sheetMode: string = 'picker';
|
||||||
@State pickerIsEnd: boolean = false;
|
@State pickerIsEnd: boolean = false;
|
||||||
@State pickDate: Date = new Date();
|
@State pickDate: Date = new Date();
|
||||||
|
/** 新建"非今天"日程时的默认开始小时(24 小时制) */
|
||||||
|
private static readonly DEFAULT_START_HOUR: number = 9;
|
||||||
@State pickHour: number = 9;
|
@State pickHour: number = 9;
|
||||||
@State pickMin: number = 0;
|
@State pickMin: number = 0;
|
||||||
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
|
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
|
||||||
@@ -271,12 +273,23 @@ struct EventEditPage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 新建:日期取所选日(未选则今天),时间取当前时刻;结束 = 开始 + 1 小时
|
// 新建:日期取所选日(未选则今天)。默认开始时刻分两种情况:
|
||||||
|
// ① 今天 → 取当前时刻("现在就记一笔"最自然;而且今天的 09:00 往往已经过了)
|
||||||
|
// ② 非今天 → 固定 09:00("现在几点"对别的日子毫无参考价值,09:00 才是通用默认)
|
||||||
|
// 结束时间统一 = 开始 + 1 小时。
|
||||||
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
|
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
|
||||||
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
|
const pickedDay = new Date(base);
|
||||||
new Date(base).getDate()).getTime();
|
const dayStart = new Date(pickedDay.getFullYear(), pickedDay.getMonth(),
|
||||||
|
pickedDay.getDate()).getTime();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
this.startMs = dayStart + now.getHours() * 3600000 + now.getMinutes() * 60000;
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
|
if (dayStart === todayStart) {
|
||||||
|
// ① 今天:当前时刻
|
||||||
|
this.startMs = dayStart + now.getHours() * 3600000 + now.getMinutes() * 60000;
|
||||||
|
} else {
|
||||||
|
// ② 非今天:09:00
|
||||||
|
this.startMs = dayStart + EventEditPage.DEFAULT_START_HOUR * 3600000;
|
||||||
|
}
|
||||||
this.endMs = this.startMs + 3600000;
|
this.endMs = this.startMs + 3600000;
|
||||||
if (choices.length > 0) {
|
if (choices.length > 0) {
|
||||||
this.chosenKey = choices[0].calKey;
|
this.chosenKey = choices[0].calKey;
|
||||||
@@ -686,7 +699,8 @@ struct EventEditPage {
|
|||||||
this.endMs = dayStart + 86399999;
|
this.endMs = dayStart + 86399999;
|
||||||
} else {
|
} else {
|
||||||
const s = new Date(this.startMs);
|
const s = new Date(this.startMs);
|
||||||
this.startMs = s.getTime() + 9 * 3600000;
|
this.startMs = new Date(s.getFullYear(), s.getMonth(), s.getDate()).getTime()
|
||||||
|
+ EventEditPage.DEFAULT_START_HOUR * 3600000;
|
||||||
this.endMs = this.startMs + 3600000;
|
this.endMs = this.startMs + 3600000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
|||||||
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||||
import { MirrorInbound } from '../common/MirrorInbound';
|
import { MirrorInbound } from '../common/MirrorInbound';
|
||||||
import { ScreenKeeper } from '../common/ScreenKeeper';
|
import { ScreenKeeper } from '../common/ScreenKeeper';
|
||||||
|
import { TodoService } from '../common/TodoService';
|
||||||
import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil';
|
import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil';
|
||||||
|
|
||||||
/** 月视图单元格 */
|
/** 月视图单元格 */
|
||||||
@@ -32,12 +33,16 @@ class MonthCell {
|
|||||||
inMonth: boolean = false;
|
inMonth: boolean = false;
|
||||||
isToday: boolean = false;
|
isToday: boolean = false;
|
||||||
lunar: string = ''; // 农历(初一时显示月名)
|
lunar: string = ''; // 农历(初一时显示月名)
|
||||||
|
/** ⭐ 该日期是否有任意事件 → 决定是否画一个小圆点。
|
||||||
|
* **只用一个布尔**:早先按"每个日历本一个点"渲染,3 个本就有 3 个点,
|
||||||
|
* 15 个本时整个月视图全是点,所以那个实现被整体砍掉了。 */
|
||||||
|
hasEvent: boolean = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
|
const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
|
|
||||||
/** 视图前后顺序(决定手势/标签切换时的转场方向):月 → 周 → 列表 */
|
/** 视图前后顺序(决定手势/标签切换时的转场方向):月 → 周 → 列表 */
|
||||||
const VIEW_ORDER: string[] = ['month', 'week', 'agenda'];
|
const VIEW_ORDER: string[] = ['month', 'week', 'agenda', 'todo'];
|
||||||
|
|
||||||
@Entry
|
@Entry
|
||||||
@Component
|
@Component
|
||||||
@@ -48,6 +53,7 @@ struct Index {
|
|||||||
@Watch('onSelectedDateChange') @State selectedDate: number = 0; // 当天 0 点毫秒
|
@Watch('onSelectedDateChange') @State selectedDate: number = 0; // 当天 0 点毫秒
|
||||||
@State events: DisplayEvent[] = [];
|
@State events: DisplayEvent[] = [];
|
||||||
@State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示)
|
@State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示)
|
||||||
|
@State todoBusy: boolean = false; // 正在写回待办状态(防连点)
|
||||||
@State agendaEvents: DisplayEvent[] = []; // 列表视图全量数据(今天 ~ 未来2年)
|
@State agendaEvents: DisplayEvent[] = []; // 列表视图全量数据(今天 ~ 未来2年)
|
||||||
private agendaStale: boolean = true; // 全量数据是否需要重新加载
|
private agendaStale: boolean = true; // 全量数据是否需要重新加载
|
||||||
@State monthPages: MonthCell[][] = [[], [], []]; // 上月/本月/下月
|
@State monthPages: MonthCell[][] = [[], [], []]; // 上月/本月/下月
|
||||||
@@ -449,12 +455,42 @@ struct Index {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 构建某年某月的 42 格 */
|
/** 构建某年某月的 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[] {
|
private buildMonthCells(year: number, month: number): MonthCell[] {
|
||||||
const first: number = new Date(year, month, 1).getTime();
|
const first: number = new Date(year, month, 1).getTime();
|
||||||
const firstDay = new Date(first).getDay();
|
const firstDay = new Date(first).getDay();
|
||||||
const offset: number = (firstDay + 6) % 7; // 周一为一周开始
|
const offset: number = (firstDay + 6) % 7; // 周一为一周开始
|
||||||
const gridStart: number = first - offset * 86400000;
|
const gridStart: number = first - offset * 86400000;
|
||||||
const todayStart: number = this.startOfDay(Date.now());
|
const todayStart: number = this.startOfDay(Date.now());
|
||||||
|
const eventDays: Set<number> = this.buildEventDaySet();
|
||||||
const cells: MonthCell[] = [];
|
const cells: MonthCell[] = [];
|
||||||
for (let i = 0; i < 42; i++) {
|
for (let i = 0; i < 42; i++) {
|
||||||
const dateMs: number = gridStart + i * 86400000;
|
const dateMs: number = gridStart + i * 86400000;
|
||||||
@@ -465,6 +501,7 @@ struct Index {
|
|||||||
cell.inMonth = d.getMonth() === month;
|
cell.inMonth = d.getMonth() === month;
|
||||||
cell.isToday = dateMs === todayStart;
|
cell.isToday = dateMs === todayStart;
|
||||||
cell.lunar = LunarUtil.lunarDayText(dateMs);
|
cell.lunar = LunarUtil.lunarDayText(dateMs);
|
||||||
|
cell.hasEvent = eventDays.has(this.startOfDay(dateMs));
|
||||||
cells.push(cell);
|
cells.push(cell);
|
||||||
}
|
}
|
||||||
return cells;
|
return cells;
|
||||||
@@ -1387,12 +1424,20 @@ struct Index {
|
|||||||
this.modeTab('month', '月')
|
this.modeTab('month', '月')
|
||||||
this.modeTab('week', '周')
|
this.modeTab('week', '周')
|
||||||
this.modeTab('agenda', '列表')
|
this.modeTab('agenda', '列表')
|
||||||
|
// ⭐ TODO_TAB 待办页签:与列表同一套布局(未完成在前、已完成在后),点击可勾选完成
|
||||||
|
this.modeTab('todo', '待办')
|
||||||
Blank()
|
Blank()
|
||||||
if (this.mode === 'month') {
|
if (this.mode === 'month') {
|
||||||
Text(this.fmtMonthTitle())
|
Text(this.fmtMonthTitle())
|
||||||
.fontSize(15)
|
.fontSize(15)
|
||||||
.fontWeight(FontWeight.Medium)
|
.fontWeight(FontWeight.Medium)
|
||||||
.fontColor($r('app.color.text_primary'))
|
.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 {
|
} else {
|
||||||
Text(this.fmtDateCn(this.selectedDate))
|
Text(this.fmtDateCn(this.selectedDate))
|
||||||
.fontSize(15)
|
.fontSize(15)
|
||||||
@@ -2509,6 +2554,11 @@ struct Index {
|
|||||||
.fontSize(12)
|
.fontSize(12)
|
||||||
.fontColor($r('app.color.text_hint'))
|
.fontColor($r('app.color.text_hint'))
|
||||||
.textAlign(TextAlign.Center)
|
.textAlign(TextAlign.Center)
|
||||||
|
Text('提示:系统日历未开放待办接口,这里的待办不会出现在系统「待办」页签中')
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.text_hint'))
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.margin({ top: 6 })
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding(32)
|
.padding(32)
|
||||||
@@ -2565,11 +2615,39 @@ struct Index {
|
|||||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||||
.opacity(t.completed ? 0.7 : 1)
|
.opacity(t.completed ? 0.7 : 1)
|
||||||
.onClick(() => {
|
.onClick(() => {
|
||||||
this.getUIContext().getPromptAction()
|
this.toggleTodo(t);
|
||||||
.showToast({ message: '待办来自服务器,请在群晖日历等源端修改' });
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 待办完成状态切换:写回服务器成功后刷新列表;失败保持原状并提示。
|
||||||
|
* ⚠️ 不做"先改界面再回滚":待办写回要走网络,若先改后失败,
|
||||||
|
* 用户会看到状态闪一下又跳回去,容易误以为操作没生效。这里直接等结果。 */
|
||||||
|
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 {
|
private spansDays(e: DisplayEvent): boolean {
|
||||||
return this.startOfDay(e.endTime) > this.startOfDay(e.startTime);
|
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'))
|
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
|
||||||
.maxLines(1)
|
.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)
|
.layoutWeight(1)
|
||||||
.padding({ top: 4, bottom: 4 })
|
.padding({ top: this.wideLayout ? 2 : 3, bottom: this.wideLayout ? 2 : 3 })
|
||||||
.borderRadius(8)
|
.borderRadius(8)
|
||||||
.onClick(() => {
|
.onClick(() => {
|
||||||
this.selectedDate = cell.dateMs;
|
this.selectedDate = cell.dateMs;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ struct SettingsPage {
|
|||||||
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
|
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
|
||||||
@State mirrorRunning: boolean = false; // 正在执行镜像
|
@State mirrorRunning: boolean = false; // 正在执行镜像
|
||||||
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
|
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
|
||||||
|
@State wipeAllRunning: boolean = false; // 正在清除全部镜像(卸载前清干净)
|
||||||
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
|
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
|
||||||
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
|
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
|
||||||
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
|
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
|
||||||
@@ -681,6 +682,81 @@ struct SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐「彻底清除全部镜像数据」(卸载前清干净用)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:鸿蒙**没有**「应用即将被卸载」的回调,系统日历属于**沙箱外**数据,
|
||||||
|
* 卸载时不会自动清理。用户卸载后,系统日历里会永久留着一堆
|
||||||
|
* 「同步日历·xxx」账户(还会继续提醒),只有重装并重新同步才能被当孤儿清掉。
|
||||||
|
* ⇒ 提供显式入口,让用户在卸载前主动清干净。
|
||||||
|
*
|
||||||
|
* ⚠️ 清除范围(务必与文案一致):
|
||||||
|
* - 只删**系统日历里本应用建立的镜像账户及其日程**
|
||||||
|
* - **不动** CalDAV 账号配置(账号密码仍保留,重装后可直接用)
|
||||||
|
* - **不动** 服务器上的任何日程(同步过去的内容一条都不会少)
|
||||||
|
*/
|
||||||
|
private confirmWipeAllMirrors(): void {
|
||||||
|
if (this.context === undefined || this.wipeAllRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.getUIContext().showAlertDialog({
|
||||||
|
title: '彻底清除系统日历镜像数据',
|
||||||
|
message: '将删除本应用写入系统日历的全部镜像日历本及其日程。\n\n'
|
||||||
|
+ '只影响系统日历:你的 CalDAV 账号与服务器上的日程完全不受影响。\n'
|
||||||
|
+ '清除后系统日历里不再显示这些日程,也不再提醒;镜像开关会一并关闭。\n\n'
|
||||||
|
+ '准备卸载本应用时,请先点这里清干净(卸载时系统不会自动清除)。',
|
||||||
|
autoCancel: true,
|
||||||
|
alignment: DialogAlignment.Center,
|
||||||
|
primaryButton: {
|
||||||
|
value: '取消',
|
||||||
|
action: (): void => {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
value: '确认清除',
|
||||||
|
action: (): void => {
|
||||||
|
this.doWipeAllMirrors();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doWipeAllMirrors(): Promise<void> {
|
||||||
|
if (this.context === undefined || this.wipeAllRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.wipeAllRunning = true;
|
||||||
|
try {
|
||||||
|
const n: number = await SystemCalendarMirror.removeAll(this.context);
|
||||||
|
// ⭐ 必须同时把"镜像开关"关掉并落库:否则下一轮同步会立刻按勾选重新建回来,
|
||||||
|
// 用户会以为"清了等于没清"。
|
||||||
|
this.mirrorEnabled = false;
|
||||||
|
await AppSettings.setMirrorEnabled(this.context, false);
|
||||||
|
// 镜像本没了 → 提醒要交回 App 负责,否则这些本会既没系统提醒、也没 App 提醒(漏提醒)
|
||||||
|
ReminderService.invalidateMirrorCache();
|
||||||
|
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
|
||||||
|
// ⭐ 清空结果必须说清楚:用户点这个按钮就是为了"确认卸载后不留东西",
|
||||||
|
// 所以"0 个"也要区分是权限没给(没清成)还是本来就干净(放心)
|
||||||
|
const granted: boolean = await SystemCalendarMirror.hasPermission();
|
||||||
|
let msg: string = '';
|
||||||
|
if (n > 0) {
|
||||||
|
msg = `已清除 ${n} 个镜像日历本,系统日历中不再有本应用的日程;镜像开关已关闭`;
|
||||||
|
} else if (!granted) {
|
||||||
|
msg = '未授予"写入系统日历"权限,无法清除。请先在设置中允许本应用读写日历后重试';
|
||||||
|
} else {
|
||||||
|
msg = '系统日历中已没有本应用的镜像数据,无需清除';
|
||||||
|
}
|
||||||
|
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 5000 });
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
this.getUIContext().getPromptAction().showToast({
|
||||||
|
message: `清除失败:${e.message ?? '请确认已授予"写入系统日历"权限'}`
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.wipeAllRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async saveInterval(minutes: number): Promise<void> {
|
private async saveInterval(minutes: number): Promise<void> {
|
||||||
if (this.context === undefined) {
|
if (this.context === undefined) {
|
||||||
return;
|
return;
|
||||||
@@ -1163,6 +1239,7 @@ struct SettingsPage {
|
|||||||
this.runMirrorNow();
|
this.runMirrorNow();
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
||||||
.fontSize(13)
|
.fontSize(13)
|
||||||
.fontColor($r('app.color.brand_text'))
|
.fontColor($r('app.color.brand_text'))
|
||||||
@@ -1206,6 +1283,82 @@ struct SettingsPage {
|
|||||||
.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') })
|
||||||
|
|
||||||
|
// ⭐ WIPE_CARD_MOVED:数据清除独立成卡(不依赖镜像开关是否开启)
|
||||||
|
// 鸿蒙没有"应用即将被卸载"的回调,而系统日历属于沙箱外的系统数据,
|
||||||
|
// 卸载时不会自动清理 ⇒ 必须提供显式入口,让用户卸载前主动清干净。
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text('清除系统日历数据')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text('卸载本应用前请先清除,否则系统日历里会一直留着这些日程(还会提醒)')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Button(this.wipeAllRunning ? '正在清除…' : '彻底清除系统日历镜像数据')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.error'))
|
||||||
|
.backgroundColor(Color.Transparent)
|
||||||
|
.border({ width: 1, color: $r('app.color.error') })
|
||||||
|
.width('100%')
|
||||||
|
.enabled(!this.wipeAllRunning)
|
||||||
|
.onClick(() => {
|
||||||
|
this.confirmWipeAllMirrors();
|
||||||
|
})
|
||||||
|
|
||||||
|
Text('只删系统日历里的镜像本;CalDAV 账号与服务器上的日程一条都不会少。清除后镜像开关会自动关闭。')
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.text_hint'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.brand_text'))
|
||||||
|
.backgroundColor(Color.Transparent)
|
||||||
|
.width('100%')
|
||||||
|
.enabled(!this.purgeRunning)
|
||||||
|
.onClick(() => {
|
||||||
|
this.runPurgeOrphans();
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.accountDump !== '') {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):')
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
Scroll() {
|
||||||
|
Text(this.accountDump)
|
||||||
|
.fontSize(10)
|
||||||
|
.lineHeight(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.copyOption(CopyOptions.LocalDevice)
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.height(150)
|
||||||
|
.width('100%')
|
||||||
|
.backgroundColor($r('app.color.page_bg'))
|
||||||
|
.borderRadius(8)
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 4 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.borderRadius(12)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||||
|
|
||||||
// 默认视图:打开 App 后默认展示的视图(月/周/列表)
|
// 默认视图:打开 App 后默认展示的视图(月/周/列表)
|
||||||
Column({ space: 8 }) {
|
Column({ space: 8 }) {
|
||||||
Row({ space: 10 }) {
|
Row({ space: 10 }) {
|
||||||
|
|||||||
@@ -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