Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37062cc75b | ||
|
|
c39a48e95f | ||
|
|
ffaffff9d6 | ||
|
|
24cd51970c | ||
|
|
09cb0f9664 | ||
|
|
24e33ed5c9 | ||
|
|
6305b4a7f0 | ||
|
|
d8ee2aa2be | ||
|
|
ddaf41fee8 | ||
|
|
2cc3b1ba42 | ||
|
|
22db069bee | ||
|
|
4f19c9482d | ||
|
|
b41eaabc41 | ||
|
|
e92daf5297 | ||
|
|
8ca1eb76e0 | ||
|
|
d49b041a2c | ||
|
|
fa3dbc6157 | ||
|
|
ff62cf7f00 | ||
|
|
788171d1b2 | ||
|
|
9066a12bbc | ||
|
|
98c4b6a96f | ||
|
|
1cc35f147d | ||
|
|
199b6c177f | ||
|
|
63b1a75b03 |
+2
-2
@@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "synccalendar.yangyq.net",
|
||||
"vendor": "yangyq",
|
||||
"versionCode": 10000,
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": 10200,
|
||||
"versionName": "0.2.0",
|
||||
"buildVersion": "1",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||
|
||||
- 应用市场送审版本:**0.0.6** | 仓库当前:**0.0.6**(versionCode 600)
|
||||
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
|
||||
- 应用市场送审版本:**0.1.0** | 仓库当前:**0.1.0**(versionCode 10000)
|
||||
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://feedlite.yangyq.net/?sw=synccalendar>
|
||||
- 开源协议:Apache License 2.0
|
||||
|
||||
---
|
||||
@@ -32,10 +32,19 @@
|
||||
### 日程编辑与提醒
|
||||
|
||||
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
|
||||
- **地点搜索补全**:在「地点」栏输入关键词即可列出匹配地点(华为 Map Kit / Site Kit),点一下填入完整地址;也可点「地图」进入系统地图界面搜索、拖动图钉选点。**不申请任何定位权限**,手动输入同样可用
|
||||
- **一键导航**:日程详情中点击「导航 ›」,用系统地理编码把地点地址换算为坐标后唤起高德地图;未安装则回退网页版。**不申请定位权限**(`getAddressesFromLocationName` 无需该权限),不会读取设备实时位置
|
||||
- **必填项校验强提醒**:未填标题等必填项时,以 Toast + 输入框标红 + 自动滚回定位三管齐下提示,不会出现"提示在屏幕外看不到"的情况
|
||||
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
|
||||
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
||||
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
|
||||
|
||||
### CalDAV 地址自动发现
|
||||
|
||||
- 只填服务器域名也能用:按标准 **`/.well-known/caldav`** 自动发现端点,并在常见路径(Nextcloud、群晖等)中依次尝试
|
||||
- 发现时只认 `resourcetype` 含 `calendar` 的集合,避免把 DAV 根或分享链接误当成日历
|
||||
- 失败时给出**人话错误提示**(如"正确形式通常为 `https://域名/remote.php/dav/calendars/用户名/`"),而不是抛原始状态码
|
||||
|
||||
### 系统日历镜像(可选增强,默认关闭)
|
||||
|
||||
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
|
||||
@@ -81,10 +90,12 @@
|
||||
|
||||
我们遵循"**数据自持、最小必要**"原则:
|
||||
|
||||
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机)
|
||||
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机,且以 **AES-256-GCM** 加密存储,密钥由系统 HUKS/TEE 保管、不可导出)
|
||||
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
|
||||
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
|
||||
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒、点击地点导航)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
||||
- **不申请定位权限**:本应用从不读取设备实时位置。「一键导航」所需的坐标由系统地理编码服务按**你填写的地址文本**换算,故 `module.json5` 中不含 `LOCATION` / `APPROXIMATELY_LOCATION`
|
||||
- 地点关键词检索由 HarmonyOS 系统能力**华为地图服务**(Map Kit / Site Kit)提供,仅用于检索,不用于广告、画像或统计分析
|
||||
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
||||
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
|
||||
|
||||
---
|
||||
@@ -326,7 +337,7 @@ hvigorw assembleApp --mode project -p product=default -p buildMode=release
|
||||
| 软件特性 | <https://synccalendar.yangyq.net/sf.html> |
|
||||
| 隐私政策 | <https://synccalendar.yangyq.net/upa.html> |
|
||||
| 用户服务协议 | <https://synccalendar.yangyq.net/usa.html> |
|
||||
| 问题反馈 | <https://synccalendar.yangyq.net/feedback/> |
|
||||
| 问题反馈 | <https://feedlite.yangyq.net/?sw=synccalendar> |
|
||||
|
||||
网站备案:**鲁ICP备17014997号-6A**
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ export class DavAccount {
|
||||
calendarColors: string[] = [];
|
||||
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
|
||||
calendarWritable: string[] = [];
|
||||
/**
|
||||
* ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应:
|
||||
* 'event' 纯日历本(只有 VEVENT)
|
||||
* 'todo' 纯待办本(只有 VTODO)
|
||||
* 'both' 混合本
|
||||
* 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容)
|
||||
*/
|
||||
calendarComps: string[] = [];
|
||||
itemCount: number = 0;
|
||||
lastSyncTime: string = '';
|
||||
}
|
||||
@@ -474,6 +482,9 @@ export class AccountStore {
|
||||
if (parts.length >= 12) {
|
||||
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
|
||||
}
|
||||
if (parts.length >= 13) {
|
||||
acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';');
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -565,6 +576,7 @@ export class AccountStore {
|
||||
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
|
||||
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
|
||||
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : [];
|
||||
a.calendarComps = o['calendarComps'] !== undefined ? o['calendarComps'] as string[] : [];
|
||||
a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
|
||||
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
|
||||
if (a.id === '') {
|
||||
@@ -699,4 +711,45 @@ export class AccountStore {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,20 @@ export class AppSettings {
|
||||
static readonly FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html';
|
||||
/** 使用帮助网址 */
|
||||
static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.html';
|
||||
/**
|
||||
* ⭐ 问题反馈网址。
|
||||
*
|
||||
* 换到 FeedLite(多站点共用一套反馈平台),`?sw=` 指定本项目站点标识。
|
||||
* 旧地址 synccalendar.yangyq.net/feedback/ 是自建 PHP 论坛,
|
||||
* 里面还有 3 条历史反馈(含一条完整 PR 贡献),服务暂不删除。
|
||||
*/
|
||||
static readonly FEEDBACK_URL: string = 'https://feedlite.yangyq.net/?sw=synccalendar';
|
||||
/** 文档标题(内置浏览器标题栏用) */
|
||||
static readonly PRIVACY_TITLE: string = '隐私政策';
|
||||
static readonly AGREEMENT_TITLE: string = '用户服务协议';
|
||||
static readonly FEATURES_TITLE: string = '软件特性';
|
||||
static readonly HELP_TITLE: string = '使用帮助';
|
||||
static readonly FEEDBACK_TITLE: string = '问题反馈';
|
||||
|
||||
/**
|
||||
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
|
||||
|
||||
@@ -25,6 +25,11 @@ export class DisplayEvent {
|
||||
color: string = '#007DFF';
|
||||
completed: boolean = false; // 仅待办使用
|
||||
recurring: boolean = false; // 是否为重复日程展开出的发生
|
||||
/** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同:
|
||||
* recurring 只说"重不重复",这里带的是"多久重复一次"。 */
|
||||
recurRule: string = '';
|
||||
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
|
||||
dtstart: number = 0;
|
||||
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
||||
}
|
||||
|
||||
@@ -341,6 +346,11 @@ export class CalendarDataService {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.color : '#9AA0A6';
|
||||
};
|
||||
// ⭐ 待办按日历本分组显示需要"本名";原来只带 calKey,分组标题无法显示
|
||||
const nameOf = (key: string): string => {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.name : '';
|
||||
};
|
||||
try {
|
||||
const rows: LocalEvent[] = await EventDb.queryTodos(context);
|
||||
for (const e of rows) {
|
||||
@@ -357,8 +367,12 @@ export class CalendarDataService {
|
||||
d.endTime = e.endTime;
|
||||
d.isAllDay = e.isAllDay;
|
||||
d.calKey = e.calKey;
|
||||
d.calName = nameOf(e.calKey);
|
||||
d.color = colorOf(e.calKey);
|
||||
d.completed = e.completed;
|
||||
d.recurRule = e.rrule;
|
||||
d.dtstart = e.dtstart;
|
||||
d.recurring = e.rrule !== '';
|
||||
result.push(d);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,6 +18,15 @@ export class DavColorEntry {
|
||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||||
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
|
||||
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
|
||||
/**
|
||||
* ⭐ COMP_SET 该集合支持的组件类型(来自 supported-calendar-component-set)。
|
||||
* 'event' = 纯日历本(只有 VEVENT)
|
||||
* 'todo' = 纯待办本(只有 VTODO)
|
||||
* 'both' = 混合本(两者都有)
|
||||
* 'unknown' = 服务器未声明该属性(老实现常见)→ 按日历对待,保持兼容
|
||||
*/
|
||||
comps: string = 'unknown';
|
||||
|
||||
}
|
||||
|
||||
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
|
||||
@@ -25,6 +34,8 @@ export class DavCalendarEntry {
|
||||
href: string = ''; // 完整 URL(已补全域名)
|
||||
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
|
||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||||
/** ⭐ COMP_SET 'event' 纯日历 / 'todo' 纯待办 / 'both' 混合 / 'unknown' 服务器未声明 */
|
||||
comps: string = 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +72,8 @@ export class DavClient {
|
||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||
'<d:current-user-privilege-set/>' +
|
||||
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
|
||||
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||||
'</d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
@@ -104,6 +117,7 @@ export class DavClient {
|
||||
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
|
||||
}
|
||||
entry.color = color;
|
||||
entry.comps = DavClient.parseComps(block);
|
||||
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
|
||||
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
|
||||
if (privSet !== '') {
|
||||
@@ -123,6 +137,33 @@ export class DavClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ COMP_SET 解析 supported-calendar-component-set,归一成四类:
|
||||
* event / todo / both / unknown
|
||||
*
|
||||
* 为什么 unknown 要单独存在:很多服务器(含自研实现)压根不返回这个属性。
|
||||
* 这时不能武断判成"不是待办本",否则用户原本能用的日历会被当成待办、
|
||||
* 或者反过来把纯日历标成待办 —— 保守按"日历"对待,不破坏既有行为。
|
||||
*/
|
||||
static parseComps(block: string): string {
|
||||
const seg: string = DavClient.extractTag(block, 'supported-calendar-component-set');
|
||||
if (seg === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
const event: boolean = /name\s*=\s*["']VEVENT["']/i.test(seg);
|
||||
const todo: boolean = /name\s*=\s*["']VTODO["']/i.test(seg);
|
||||
if (event && todo) {
|
||||
return 'both';
|
||||
}
|
||||
if (todo) {
|
||||
return 'todo';
|
||||
}
|
||||
if (event) {
|
||||
return 'event';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
|
||||
*
|
||||
@@ -136,6 +177,7 @@ export class DavClient {
|
||||
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||||
'</d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
const out = new DavCalendarDiscovery();
|
||||
@@ -197,6 +239,9 @@ export class DavClient {
|
||||
entry.href = href.startsWith('http') ? href : origin + href;
|
||||
entry.displayName = name;
|
||||
entry.color = color;
|
||||
entry.comps = DavClient.parseComps(block);
|
||||
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
|
||||
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
|
||||
out.entries.push(entry);
|
||||
}
|
||||
return out;
|
||||
@@ -250,6 +295,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();
|
||||
|
||||
@@ -24,6 +24,10 @@ export class LocalEvent {
|
||||
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
|
||||
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
|
||||
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
|
||||
/** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。
|
||||
* 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**,
|
||||
* 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */
|
||||
dtstart: number = 0;
|
||||
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
|
||||
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
|
||||
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
|
||||
@@ -47,6 +51,7 @@ export class RemoteEvent {
|
||||
isTodo: boolean = false; // VTODO 待办
|
||||
completed: boolean = false; // VTODO STATUS:COMPLETED
|
||||
rrule: string = ''; // 原始 RRULE
|
||||
dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开
|
||||
exdate: string = ''; // 原始 EXDATE(分号分隔)
|
||||
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
|
||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
|
||||
@@ -124,6 +129,11 @@ export class EventDb {
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
try {
|
||||
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
try {
|
||||
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
|
||||
} catch (err) {
|
||||
@@ -181,6 +191,7 @@ export class EventDb {
|
||||
const e = new LocalEvent();
|
||||
e.id = rs.getLong(rs.getColumnIndex('id'));
|
||||
e.uid = rs.getString(rs.getColumnIndex('uid'));
|
||||
e.dtstart = rs.getLong(rs.getColumnIndex('dtstart'));
|
||||
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
|
||||
e.href = rs.getString(rs.getColumnIndex('href'));
|
||||
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
|
||||
@@ -336,6 +347,90 @@ export class EventDb {
|
||||
}
|
||||
|
||||
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
|
||||
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
|
||||
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
|
||||
/** 保存循环规则与基准(设置/取消循环后调用) */
|
||||
static async updateTodoRecurrence(context: common.Context, id: number,
|
||||
rrule: string, dtstart: number, etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const values: relationalStore.ValuesBucket = {
|
||||
'rrule': rrule,
|
||||
'dtstart': dtstart,
|
||||
'recurring': rrule !== '' ? 1 : 0,
|
||||
'etag': etag,
|
||||
'dirty': 0
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(values, predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 循环待办"完成一轮"后更新本地:推后的基准/截止 + 清除完成态。
|
||||
*
|
||||
* ⚠️ 必须同时把 completed 置 0、dirty 清掉:
|
||||
* 否则界面会显示成"已完成",而服务器上其实是"下一轮未完成",两边不一致。
|
||||
*/
|
||||
static async updateTodoAfterRound(context: common.Context, id: number,
|
||||
dtstart: number, dueMs: number, etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const values: relationalStore.ValuesBucket = {
|
||||
'dtstart': dtstart,
|
||||
'start_time': dueMs,
|
||||
'end_time': dueMs,
|
||||
'completed': 0,
|
||||
'etag': etag,
|
||||
'dirty': 0
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(values, predicates);
|
||||
}
|
||||
|
||||
/** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */
|
||||
static async updateTodoFields(context: common.Context, id: number, title: string,
|
||||
desc: string, dueMs: number, etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const values: relationalStore.ValuesBucket = {
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'start_time': dueMs,
|
||||
'etag': etag,
|
||||
'dirty': 0
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(values, predicates);
|
||||
}
|
||||
|
||||
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,562 @@ 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:它是全局块序号,重置会让下一块从 0 重新数,
|
||||
// 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在**服务器原文**上改写 VTODO 的内容字段,返回新的整份 ICS。
|
||||
*
|
||||
* 只改三项,其余(PRIORITY / CATEGORIES / X-* / STATUS / COMPLETED …)原样保留:
|
||||
* - SUMMARY 标题
|
||||
* - DESCRIPTION 备注
|
||||
* - DUE 截止时间(dueMs<=0 → 删除该行)
|
||||
*
|
||||
* 与 rewriteTodoCompletion 相同的两遍处理:
|
||||
* ① 先定位含目标 uid 的 VTODO 块(RFC 5545 不规定字段顺序,不能边扫边改)
|
||||
* ② 再在块内替换/删除目标字段,缺失的在 END:VTODO 前补齐
|
||||
*/
|
||||
static rewriteTodoFields(ics: string, uid: string, title: string,
|
||||
desc: string, dueMs: number): string {
|
||||
const crlf: string = '\r\n';
|
||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
|
||||
const upperOf = (s: string): string => s.toUpperCase();
|
||||
|
||||
// ---- 第 1 遍:定位目标 VTODO 块 ----
|
||||
let targetBegin: number = -1;
|
||||
let targetEnd: number = -1;
|
||||
let curBegin: number = -1;
|
||||
let curHasUid: boolean = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u: string = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) {
|
||||
curBegin = i;
|
||||
curHasUid = false;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) {
|
||||
curHasUid = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||
targetBegin = curBegin;
|
||||
targetEnd = i;
|
||||
}
|
||||
curBegin = -1;
|
||||
curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) {
|
||||
return ics; // 找不到目标,原样返回
|
||||
}
|
||||
|
||||
// ---- 第 2 遍:块内改写 ----
|
||||
const out: string[] = [];
|
||||
let sawSummary: boolean = false;
|
||||
let sawDesc: boolean = false;
|
||||
let sawDue: boolean = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line: string = lines[i];
|
||||
const u: string = upperOf(line);
|
||||
const inTarget: boolean = i > targetBegin && i < targetEnd;
|
||||
if (!inTarget) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('SUMMARY')) {
|
||||
sawSummary = true;
|
||||
if (title !== '') {
|
||||
out.push('SUMMARY:' + IcsUtil.escape(title));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('DESCRIPTION')) {
|
||||
sawDesc = true;
|
||||
if (desc !== '') {
|
||||
out.push('DESCRIPTION:' + IcsUtil.escape(desc));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('DUE')) {
|
||||
sawDue = true;
|
||||
if (dueMs > 0) {
|
||||
out.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
// 缺失字段补齐
|
||||
const extra: string[] = [];
|
||||
if (!sawSummary && title !== '') {
|
||||
extra.push('SUMMARY:' + IcsUtil.escape(title));
|
||||
}
|
||||
if (!sawDesc && desc !== '') {
|
||||
extra.push('DESCRIPTION:' + IcsUtil.escape(desc));
|
||||
}
|
||||
if (!sawDue && dueMs > 0) {
|
||||
extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
||||
}
|
||||
if (extra.length === 0) {
|
||||
const plain: string = out.join(crlf);
|
||||
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||
}
|
||||
|
||||
// 按**结构**定位插入点(不能按行号:上面可能删掉了 DUE 行,行号已不对齐)
|
||||
const result: string[] = [];
|
||||
let vtodoIdx: number = -1;
|
||||
let targetOrdinal: number = -1;
|
||||
// 重新数出目标的块序号
|
||||
{
|
||||
let n: number = -1;
|
||||
let inBlk: boolean = false;
|
||||
let has: boolean = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u: string = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) {
|
||||
n++;
|
||||
inBlk = true;
|
||||
has = false;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) {
|
||||
has = true;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO') && inBlk) {
|
||||
if (has && targetOrdinal < 0) {
|
||||
targetOrdinal = n;
|
||||
}
|
||||
inBlk = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let inserted: boolean = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u: string = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) {
|
||||
vtodoIdx++;
|
||||
result.push(out[i]);
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||
for (const ex of extra) {
|
||||
result.push(ex);
|
||||
}
|
||||
inserted = true;
|
||||
}
|
||||
// ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 0 重新数,
|
||||
// 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。
|
||||
result.push(out[i]);
|
||||
continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
const tail0: string = result.join(crlf);
|
||||
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
|
||||
if (idxVc > 0) {
|
||||
const merged: string = tail0.substring(0, idxVc) + extra.join(crlf) + crlf
|
||||
+ tail0.substring(idxVc);
|
||||
return merged.endsWith(crlf) ? merged : merged + crlf;
|
||||
}
|
||||
for (const ex of extra) {
|
||||
result.push(ex);
|
||||
}
|
||||
}
|
||||
const text2: string = result.join(crlf);
|
||||
return text2.endsWith(crlf) ? text2 : text2 + crlf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 FREQ 把时间往后推一个周期(用于"完成一个循环任务 → 生成下一次")。
|
||||
*
|
||||
* ⚠️ 月份/年份推进要处理"月末溢出":
|
||||
* 1 月 31 日 + 1 个月 在 JS 里会得到 3 月 3 日(2 月没有 31 日)。
|
||||
* RFC 5545 规定这类"不存在的日期"应当**跳过**而非顺延,
|
||||
* 但对家务类待办来说"顺延到 3 号"比"跳过整个月"更符合直觉,
|
||||
* 这里采用**钳制到当月最后一天**(1/31 → 2/28),并注释说明取舍。
|
||||
*
|
||||
* @param freq DAILY / WEEKLY / MONTHLY / YEARLY
|
||||
* @param fromMs 基准时间
|
||||
* @returns 下一周期的时间(同一天内时分秒保持不变)
|
||||
*/
|
||||
/** 从 RRULE 原文取出 FREQ 值(DAILY / WEEKLY / MONTHLY / YEARLY);取不到返回 DAILY */
|
||||
static freqOfRrule(rrule: string): string {
|
||||
const parts: string[] = rrule.split(';');
|
||||
for (const part of parts) {
|
||||
const idx: number = part.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key: string = part.substring(0, idx).trim().toUpperCase();
|
||||
if (key === 'FREQ') {
|
||||
return part.substring(idx + 1).trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
return 'DAILY';
|
||||
}
|
||||
|
||||
static incForFreq(freq: string, fromMs: number): number {
|
||||
const d: Date = new Date(fromMs);
|
||||
const f: string = freq.toUpperCase();
|
||||
if (f === 'DAILY') {
|
||||
return fromMs + 86400000;
|
||||
}
|
||||
if (f === 'WEEKLY') {
|
||||
return fromMs + 7 * 86400000;
|
||||
}
|
||||
if (f === 'MONTHLY') {
|
||||
const y: number = d.getFullYear();
|
||||
const m: number = d.getMonth() + 1;
|
||||
const day: number = d.getDate();
|
||||
const lastDayOfNext: number = new Date(y, m + 1, 0).getDate();
|
||||
return new Date(y, m, Math.min(day, lastDayOfNext),
|
||||
d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
|
||||
}
|
||||
if (f === 'YEARLY') {
|
||||
const y: number = d.getFullYear() + 1;
|
||||
const m: number = d.getMonth();
|
||||
const day: number = d.getDate();
|
||||
// 2/29 → 次年 2/28(闰年才有 29)
|
||||
const lastDay: number = new Date(y, m + 1, 0).getDate();
|
||||
return new Date(y, m, Math.min(day, lastDay),
|
||||
d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
|
||||
}
|
||||
return fromMs + 86400000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在**服务器原文**上写入循环规则(RRULE + DTSTART + DUE)。
|
||||
*
|
||||
* 只在这三处动,其余字段(SUMMARY/DESCRIPTION/PRIORITY/X-* …)原样保留。
|
||||
*
|
||||
* @param rrule 规则原文,如 `FREQ=WEEKLY`;空串 = 取消循环(删除 RRULE/DTSTART,保留 DUE)
|
||||
* @param dtstart 重复基准(毫秒);<=0 时用 dueMs 兜底
|
||||
* @param dueMs 本轮的截止时间(毫秒);<=0 表示无截止
|
||||
*/
|
||||
static rewriteTodoRecurrence(ics: string, uid: string, rrule: string,
|
||||
dtstart: number, dueMs: number): string {
|
||||
const crlf: string = '\r\n';
|
||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
|
||||
const upperOf = (s: string): string => s.toUpperCase();
|
||||
const hasRule: boolean = rrule.trim() !== '';
|
||||
|
||||
// ---- 第 1 遍:定位目标 VTODO 块(RFC 5545 不规定字段顺序,必须两遍)----
|
||||
let targetBegin: number = -1;
|
||||
let targetEnd: number = -1;
|
||||
let curBegin: number = -1;
|
||||
let curHasUid: boolean = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u: string = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) {
|
||||
curBegin = i;
|
||||
curHasUid = false;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) {
|
||||
curHasUid = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||
targetBegin = curBegin;
|
||||
targetEnd = i;
|
||||
}
|
||||
curBegin = -1;
|
||||
curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) {
|
||||
return ics;
|
||||
}
|
||||
|
||||
// ---- 第 2 遍:块内替换/删除 RRULE、DTSTART、DUE ----
|
||||
const out: string[] = [];
|
||||
let sawRule: boolean = false;
|
||||
let sawStart: boolean = false;
|
||||
let sawDue: boolean = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line: string = lines[i];
|
||||
const u: string = upperOf(line);
|
||||
const inTarget: boolean = i > targetBegin && i < targetEnd;
|
||||
if (!inTarget) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('RRULE')) {
|
||||
sawRule = true;
|
||||
if (hasRule) {
|
||||
out.push('RRULE:' + rrule.trim());
|
||||
}
|
||||
continue; // 取消循环时整行丢掉
|
||||
}
|
||||
if (u.startsWith('DTSTART')) {
|
||||
sawStart = true;
|
||||
if (hasRule && dtstart > 0) {
|
||||
out.push('DTSTART:' + IcsUtil.fmtIcsTime(dtstart, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('DUE')) {
|
||||
sawDue = true;
|
||||
if (dueMs > 0) {
|
||||
out.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
// 缺字段补齐
|
||||
const extra: string[] = [];
|
||||
if (hasRule) {
|
||||
if (!sawRule) {
|
||||
extra.push('RRULE:' + rrule.trim());
|
||||
}
|
||||
if (!sawStart && dtstart > 0) {
|
||||
extra.push('DTSTART:' + IcsUtil.fmtIcsTime(dtstart, false));
|
||||
}
|
||||
if (!sawDue && dueMs > 0) {
|
||||
extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
||||
}
|
||||
}
|
||||
if (extra.length === 0) {
|
||||
const plain: string = out.join(crlf);
|
||||
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||
}
|
||||
|
||||
// 按**结构**定位插入点(改动会删行,行号已不对齐)
|
||||
let targetOrdinal: number = -1;
|
||||
{
|
||||
let n: number = -1;
|
||||
let inBlk: boolean = false;
|
||||
let has: boolean = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u: string = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) {
|
||||
n++;
|
||||
inBlk = true;
|
||||
has = false;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) {
|
||||
has = true;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO') && inBlk) {
|
||||
if (has && targetOrdinal < 0) {
|
||||
targetOrdinal = n;
|
||||
}
|
||||
inBlk = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ⭐ RRULE_IDX_FIX vtodoIdx 是**全局块序号**,只在 BEGIN:VTODO 自增。
|
||||
// 之前在这里的 END:VTODO 分支把它重置成 -1,导致每块都从 0 重新数,
|
||||
// 多 VTODO 文件里永远匹配不到 targetOrdinal(第 2 块应为 1)⇒ RRULE 加不上。
|
||||
const result: string[] = [];
|
||||
let vtodoIdx: number = -1;
|
||||
let inserted: boolean = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u: string = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) {
|
||||
vtodoIdx++;
|
||||
result.push(out[i]);
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||
for (const ex of extra) {
|
||||
result.push(ex);
|
||||
}
|
||||
inserted = true;
|
||||
}
|
||||
result.push(out[i]);
|
||||
continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
const tail0: string = result.join(crlf);
|
||||
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
|
||||
if (idxVc > 0) {
|
||||
const merged: string = tail0.substring(0, idxVc) + extra.join(crlf) + crlf
|
||||
+ tail0.substring(idxVc);
|
||||
return merged.endsWith(crlf) ? merged : merged + crlf;
|
||||
}
|
||||
for (const ex of extra) {
|
||||
result.push(ex);
|
||||
}
|
||||
}
|
||||
const text2: string = result.join(crlf);
|
||||
return text2.endsWith(crlf) ? text2 : text2 + crlf;
|
||||
}
|
||||
|
||||
/** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
|
||||
static parseTodos(ics: string): RemoteEvent[] {
|
||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||
@@ -210,7 +766,18 @@ export class IcsUtil {
|
||||
current.location = IcsUtil.unescape(value);
|
||||
} else if (propName === 'RRULE') {
|
||||
current.recurring = true;
|
||||
} else if (propName === 'DUE' || propName === 'DTSTART') {
|
||||
// ⭐ 保存规则原文:原来只置了 recurring 标志,规则本身被丢掉,
|
||||
// 导致「知道它重复,但不知道多久重复一次」。
|
||||
current.rrule = value.trim();
|
||||
} else if (propName === 'DTSTART') {
|
||||
// ⭐ DTSTART 单独存:它是 RRULE 的重复基准,**不是**截止时间。
|
||||
// 原来与 DUE 共用 startTime,谁后出现谁覆盖 ⇒ 循环任务基准错乱。
|
||||
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||
const t = IcsUtil.parseTime(value, isDateOnly);
|
||||
if (t !== null) {
|
||||
current.dtstart = t.time;
|
||||
}
|
||||
} else if (propName === 'DUE') {
|
||||
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||
const t = IcsUtil.parseTime(value, isDateOnly);
|
||||
if (t !== null) {
|
||||
|
||||
@@ -173,6 +173,36 @@ export class SyncEngine {
|
||||
throw err as Error;
|
||||
}
|
||||
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
|
||||
// ⭐ SYNC_TODOS 同步该本里的待办(VTODO)。
|
||||
// 原来整个同步流程只处理 VEVENT,`DavClient.reportTodos` 写了却没人调用,
|
||||
// 导致待办数据从来不会从服务器拉下来 ——
|
||||
// 用户取消勾选本(触发 pruneStaleHrefs 清行)后再勾回来,待办就永久消失了。
|
||||
// 放在这里(VEVENT 处理之前)单独走一遍,不影响原有日程逻辑。
|
||||
try {
|
||||
const todoItems: RemoteItem[] = await DavClient.reportTodos(href, auth);
|
||||
if (todoItems.length > 0) {
|
||||
const todoRemote: RemoteEvent[] = [];
|
||||
for (const ti of todoItems) {
|
||||
try {
|
||||
const raw: string = await DavClient.getRawStrict(ti.href, auth);
|
||||
const parsed: RemoteEvent[] = IcsUtil.parseTodos(raw);
|
||||
for (const pr of parsed) {
|
||||
pr.etag = ti.etag;
|
||||
todoRemote.push(pr);
|
||||
}
|
||||
} catch (e) {
|
||||
LogUtil.write(`待办 GET 失败(跳过) ${ti.href}`);
|
||||
}
|
||||
}
|
||||
if (todoRemote.length > 0) {
|
||||
await EventDb.applyRemote(context, calKey, href, todoRemote, true);
|
||||
LogUtil.write(`日历本[${i}]「${calName}」待办同步:${todoRemote.length} 条`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 待办同步失败不能影响日程同步(很多服务器对 VTODO 查询返回 403/不支持)
|
||||
LogUtil.write(`日历本[${i}]「${calName}」待办同步失败(忽略):${(e as BusinessError).message ?? ''}`);
|
||||
}
|
||||
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
||||
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||
const byUid: Map<string, LocalEvent[]> = new Map();
|
||||
|
||||
@@ -795,10 +795,13 @@ export class SystemCalendarMirror {
|
||||
/**
|
||||
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
|
||||
*
|
||||
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与
|
||||
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。
|
||||
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。
|
||||
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。
|
||||
* 三种情况(0.1.1 起,配合"卸载残留"问题):
|
||||
* ① **还有账号** → 只清"accId 不在当前账号集合里"的 —— 原逻辑,精准不误伤。
|
||||
* ② **账号库为空、且确认是真空**(`isEmptyVaultConfirmed()`)→ **全部清掉**。
|
||||
* 这是卸载重装 / 用户重置账号库后的场景:旧镜像账户的 accId 不可能再匹配上,
|
||||
* 留着只会变成系统日历里永远删不掉的幽灵本,还会继续提醒。
|
||||
* ③ **账号库为空、但拿不准**(读取异常 / 密文解不开)→ **仍然跳过**。
|
||||
* `loadAll()` 会把异常吞掉返回 `[]`,若不区分就会把用户真实在用的镜像全删了。
|
||||
*
|
||||
* @returns 删除的账户数
|
||||
*/
|
||||
@@ -812,8 +815,14 @@ export class SystemCalendarMirror {
|
||||
} catch (err) {
|
||||
return 0; // 读失败一律不动
|
||||
}
|
||||
let wipeAll: boolean = false;
|
||||
if (accounts.length === 0) {
|
||||
return 0; // 安全阀:见注释
|
||||
// 空账号库:只有"确认空白"才敢全清,否则保守跳过(见上方 ③)
|
||||
wipeAll = await AccountStore.isEmptyVaultConfirmed(context);
|
||||
if (!wipeAll) {
|
||||
return 0;
|
||||
}
|
||||
LogUtil.write('兜底清理孤儿镜像:账号库确认为空 → 清理全部镜像账户(卸载/重置后残留)');
|
||||
}
|
||||
const validAccIds: Set<string> = new Set<string>();
|
||||
for (const a of accounts) {
|
||||
@@ -838,11 +847,13 @@ export class SystemCalendarMirror {
|
||||
} else {
|
||||
sep = dash >= 0 ? dash : under;
|
||||
}
|
||||
if (sep <= 0) {
|
||||
continue; // 解析不出 accId → 不动(宁可留,不误删)
|
||||
// 非全清模式:必须能解析出 accId 才能比对,解析不出就跳过(宁可留,不误删)
|
||||
// 全清模式:前缀已命中就是本应用建的,脏账户(解析不出 accId)也要删干净
|
||||
if (!wipeAll && sep <= 0) {
|
||||
continue;
|
||||
}
|
||||
const accId: string = bucket.substring(0, sep);
|
||||
if (validAccIds.has(accId)) {
|
||||
const accId: string = sep > 0 ? bucket.substring(0, sep) : bucket;
|
||||
if (!wipeAll && validAccIds.has(accId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
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(**本地状态不变**,避免界面与服务器不一致)
|
||||
*/
|
||||
/**
|
||||
* 更新待办内容(标题 / 备注 / 截止时间)。
|
||||
*
|
||||
* 同样走 GET 原文 → 改字段 → PUT:
|
||||
* VTODO 上有本地库没保存的字段(PRIORITY / CATEGORIES / X-* 等),
|
||||
* 重建整份会把这些抹掉。
|
||||
*
|
||||
* 只改 SUMMARY / DESCRIPTION / DUE 三项,**不动完成状态**
|
||||
* (用户改的是内容,不该顺带把状态重置了)。
|
||||
*
|
||||
* @param dueMs 0 = 清除截止时间(删除 DUE 行)
|
||||
*/
|
||||
static async updateTodo(context: common.Context, localId: number, title: string,
|
||||
desc: string, dueMs: number): Promise<boolean> {
|
||||
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||
if (row === null || row.kind !== 'todo') {
|
||||
LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
if (row.href === '' || row.remotePath === '') {
|
||||
LogUtil.write(`待办更新失败:缺少服务器路径 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href);
|
||||
if (accResolved === null) {
|
||||
LogUtil.write(`待办更新失败:找不到所属账号`);
|
||||
return false;
|
||||
}
|
||||
const idx: number = accResolved.calendarHrefs.indexOf(row.href);
|
||||
if (idx >= 0 && accResolved.calendarWritable.length > idx
|
||||
&& accResolved.calendarWritable[idx] === '0') {
|
||||
LogUtil.write(`待办更新失败:该日历本只读 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
const auth: string = DavClient.authHeader(accResolved.username, accResolved.password);
|
||||
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
|
||||
: `${row.href}/${row.remotePath}`;
|
||||
try {
|
||||
const original: string = await DavClient.getRawStrict(url, auth);
|
||||
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
|
||||
LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs);
|
||||
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||
await EventDb.updateTodoFields(context, localId, title, desc,
|
||||
dueMs > 0 ? dueMs : 0, newEtag);
|
||||
LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError) {
|
||||
LogUtil.write(`待办更新失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
|
||||
} else {
|
||||
LogUtil.write(`待办更新失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除待办(DELETE 服务器资源 + 本地软删) */
|
||||
static async deleteTodo(context: common.Context, localId: number): Promise<boolean> {
|
||||
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||
if (row === null || row.kind !== 'todo') {
|
||||
LogUtil.write(`待办删除失败:行不存在或不是待办 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
if (row.href === '' || row.remotePath === '') {
|
||||
LogUtil.write(`待办删除失败:缺少服务器路径 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href);
|
||||
if (accResolved === null) {
|
||||
LogUtil.write(`待办删除失败:找不到所属账号`);
|
||||
return false;
|
||||
}
|
||||
const idx: number = accResolved.calendarHrefs.indexOf(row.href);
|
||||
if (idx >= 0 && accResolved.calendarWritable.length > idx
|
||||
&& accResolved.calendarWritable[idx] === '0') {
|
||||
LogUtil.write(`待办删除失败:该日历本只读 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
const auth: string = DavClient.authHeader(accResolved.username, accResolved.password);
|
||||
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
|
||||
: `${row.href}/${row.remotePath}`;
|
||||
try {
|
||||
await DavClient.deleteRemote(url, auth);
|
||||
await EventDb.purge(context, localId);
|
||||
LogUtil.write(`待办删除成功 id=${localId}(标题已脱敏)`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError) {
|
||||
LogUtil.write(`待办删除失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
|
||||
} else {
|
||||
LogUtil.write(`待办删除失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 href 找所属账号(updateTodo/deleteTodo 共用) */
|
||||
private static async findAccount(context: common.Context, href: string): Promise<DavAccount | null> {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
for (const a of accounts) {
|
||||
if (a.calendarHrefs.includes(href)) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置或取消待办的循环规则。
|
||||
*
|
||||
* @param rrule 规则原文(如 `FREQ=WEEKLY`);空串 = 取消循环
|
||||
* @param dtstartMs 重复基准;<=0 时用当前 DUE 兜底
|
||||
* @returns 成功 true
|
||||
*
|
||||
* ⚠️ 写回时**同时**给 DTSTART 和 DUE:
|
||||
* RFC 5545 里 VTODO 的 DTSTART 是 OPTIONAL,但没有它时
|
||||
* 不同客户端对 RRULE 基准的理解不一致(有的按 DUE 算),
|
||||
* 显式给 DTSTART 能消除歧义。
|
||||
*/
|
||||
static async setRecurrence(context: common.Context, localId: number,
|
||||
rrule: string, dtstartMs: number): Promise<boolean> {
|
||||
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||
if (row === null || row.kind !== 'todo') {
|
||||
LogUtil.write(`待办循环设置失败:行不存在或不是待办 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
if (row.href === '' || row.remotePath === '') {
|
||||
LogUtil.write(`待办循环设置失败:缺少服务器路径 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
const acc: DavAccount | null = await TodoService.findAccount(context, row.href);
|
||||
if (acc === null) {
|
||||
LogUtil.write(`待办循环设置失败:找不到所属账号`);
|
||||
return false;
|
||||
}
|
||||
const idx: number = acc.calendarHrefs.indexOf(row.href);
|
||||
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
|
||||
LogUtil.write(`待办循环设置失败:该日历本只读 id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
|
||||
: `${row.href}/${row.remotePath}`;
|
||||
const dueMs: number = row.startTime > 0 ? row.startTime : 0;
|
||||
const base: number = dtstartMs > 0 ? dtstartMs : (dueMs > 0 ? dueMs : Date.now());
|
||||
try {
|
||||
const original: string = await DavClient.getRawStrict(url, auth);
|
||||
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
|
||||
LogUtil.write(`待办循环设置失败:服务器内容不是 VTODO id=${localId}`);
|
||||
return false;
|
||||
}
|
||||
const updated: string = IcsUtil.rewriteTodoRecurrence(
|
||||
original, row.uid, rrule, base, dueMs);
|
||||
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||
await EventDb.updateTodoRecurrence(context, localId, rrule.trim(), base, newEtag);
|
||||
LogUtil.write(`待办循环设置成功 id=${localId} rule=${rrule === '' ? '(取消)' : rrule}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError) {
|
||||
LogUtil.write(`待办循环设置失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
|
||||
} else {
|
||||
LogUtil.write(`待办循环设置失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 循环任务"完成一轮":把 DUE 推进到下一个周期,并保持未完成。
|
||||
*
|
||||
* 与 setCompleted 的区别:
|
||||
* · 非循环 → 走 setCompleted(置 COMPLETED,永久结束)
|
||||
* · 循环 → 走这里(DUE += 一个周期,STATUS 保持 NEEDS-ACTION)
|
||||
*
|
||||
* @returns 推进后的新 DUE(毫秒);失败返回 0
|
||||
*/
|
||||
static async completeRecurringRound(context: common.Context, localId: number): Promise<number> {
|
||||
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||
if (row === null || row.kind !== 'todo' || row.rrule.trim() === '') {
|
||||
return 0;
|
||||
}
|
||||
if (row.href === '' || row.remotePath === '') {
|
||||
return 0;
|
||||
}
|
||||
const acc: DavAccount | null = await TodoService.findAccount(context, row.href);
|
||||
if (acc === null) {
|
||||
return 0;
|
||||
}
|
||||
const idx: number = acc.calendarHrefs.indexOf(row.href);
|
||||
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
|
||||
return 0;
|
||||
}
|
||||
// 从 FREQ 取周期,推下一个基准
|
||||
const freq: string = IcsUtil.freqOfRrule(row.rrule);
|
||||
// 基准优先用 DTSTART(真正的重复基准),没有则用当前 DUE
|
||||
const base: number = row.dtstart > 0 ? row.dtstart : row.startTime;
|
||||
const nextBase: number = IcsUtil.incForFreq(freq, base);
|
||||
// 保留"截止 = 基准 + N"的偏移(原任务若是 09:00 截止,下一次还是 09:00)
|
||||
const offset: number = row.startTime > 0 && row.dtstart > 0 ? row.startTime - row.dtstart : 0;
|
||||
const nextDue: number = nextBase + offset;
|
||||
|
||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
|
||||
: `${row.href}/${row.remotePath}`;
|
||||
try {
|
||||
const original: string = await DavClient.getRawStrict(url, auth);
|
||||
// 先把状态复位成未完成,再把基准与截止推到下一轮
|
||||
let updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, false);
|
||||
updated = IcsUtil.rewriteTodoRecurrence(updated, row.uid, row.rrule, nextBase, nextDue);
|
||||
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||
await EventDb.updateTodoAfterRound(context, localId, nextBase, nextDue, newEtag);
|
||||
LogUtil.write(`循环待办推进到下一轮 id=${localId}(标题已脱敏)`);
|
||||
return nextDue > 0 ? nextDue : nextBase;
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError) {
|
||||
LogUtil.write(`循环待办推进失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
|
||||
} else {
|
||||
LogUtil.write(`循环待办推进失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static async setCompleted(context: common.Context, localId: number, done: boolean): Promise<boolean> {
|
||||
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||
if (row === null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,27 @@ export class EditCalendarItem {
|
||||
href: string;
|
||||
name: string;
|
||||
color: string; // 服务器定义的颜色(calendar-color),可能为空
|
||||
/** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */
|
||||
comps: string;
|
||||
selected: boolean;
|
||||
|
||||
constructor(href: string, name: string, color: string) {
|
||||
constructor(href: string, name: string, color: string, comps: string = 'unknown') {
|
||||
this.href = href;
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.comps = comps;
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
/** 是否待办本(纯待办或混合) */
|
||||
isTodoBook(): boolean {
|
||||
return this.comps === 'todo' || this.comps === 'both';
|
||||
}
|
||||
|
||||
/** 是否日历本(纯日历、混合、或服务器未声明) */
|
||||
isEventBook(): boolean {
|
||||
return this.comps === 'event' || this.comps === 'both' || this.comps === 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +192,7 @@ struct EditAccountPage {
|
||||
return;
|
||||
}
|
||||
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
|
||||
new EditCalendarItem(e.href, e.displayName, e.color));
|
||||
new EditCalendarItem(e.href, e.displayName, e.color, e.comps));
|
||||
if (list.length === 0) {
|
||||
LogUtil.write('编辑账号:未发现任何日历本');
|
||||
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
|
||||
@@ -246,6 +259,7 @@ struct EditAccountPage {
|
||||
target.calendarHrefs = newHrefs;
|
||||
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
|
||||
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
|
||||
target.calendarComps = selectedItems.map((c: EditCalendarItem): string => c.comps);
|
||||
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
|
||||
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
|
||||
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
|
||||
@@ -530,6 +544,22 @@ struct EditCalendarRow {
|
||||
|
||||
build() {
|
||||
Row({ space: 12 }) {
|
||||
// ⭐ SVG_ICON 类型图标:放在颜色圆点**前面**,
|
||||
// 让用户先看清"这本是日历还是待办",再看它是什么颜色。
|
||||
//
|
||||
// 用 SVG 而不是字符:字符图标(▦ / ✓)依赖系统字体字形,
|
||||
// 笔画粗细与视觉重量不统一,跨设备还可能缺字形变成方框。
|
||||
// 这两个 SVG 共用同一套笔触(stroke 2、round 端点、24×24),
|
||||
// 渲染成 16vp 时重量一致。
|
||||
//
|
||||
// 混合本(both):显示待办图标(它确实能存待办),
|
||||
// 但用户看名字/颜色仍能分辨,不必塞两个图标挤在一行。
|
||||
Image(this.item.comps === 'todo' || this.item.comps === 'both'
|
||||
? $r('app.media.ic_todo_book')
|
||||
: $r('app.media.ic_event_book'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.objectFit(ImageFit.Contain)
|
||||
if (this.item.color !== '') {
|
||||
Circle()
|
||||
.width(12)
|
||||
|
||||
@@ -112,6 +112,8 @@ struct EventEditPage {
|
||||
@State sheetMode: string = 'picker';
|
||||
@State pickerIsEnd: boolean = false;
|
||||
@State pickDate: Date = new Date();
|
||||
/** 新建"非今天"日程时的默认开始小时(24 小时制) */
|
||||
private static readonly DEFAULT_START_HOUR: number = 9;
|
||||
@State pickHour: number = 9;
|
||||
@State pickMin: number = 0;
|
||||
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
|
||||
@@ -271,12 +273,23 @@ struct EventEditPage {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 新建:日期取所选日(未选则今天),时间取当前时刻;结束 = 开始 + 1 小时
|
||||
// 新建:日期取所选日(未选则今天)。默认开始时刻分两种情况:
|
||||
// ① 今天 → 取当前时刻("现在就记一笔"最自然;而且今天的 09:00 往往已经过了)
|
||||
// ② 非今天 → 固定 09:00("现在几点"对别的日子毫无参考价值,09:00 才是通用默认)
|
||||
// 结束时间统一 = 开始 + 1 小时。
|
||||
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
|
||||
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
|
||||
new Date(base).getDate()).getTime();
|
||||
const pickedDay = new Date(base);
|
||||
const dayStart = new Date(pickedDay.getFullYear(), pickedDay.getMonth(),
|
||||
pickedDay.getDate()).getTime();
|
||||
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;
|
||||
if (choices.length > 0) {
|
||||
this.chosenKey = choices[0].calKey;
|
||||
@@ -686,7 +699,8 @@ struct EventEditPage {
|
||||
this.endMs = dayStart + 86399999;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,7 @@ struct SettingsPage {
|
||||
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
|
||||
@State mirrorRunning: boolean = false; // 正在执行镜像
|
||||
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
|
||||
@State wipeAllRunning: boolean = false; // 正在清除全部镜像(卸载前清干净)
|
||||
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
|
||||
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
|
||||
@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> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
@@ -1163,6 +1239,7 @@ struct SettingsPage {
|
||||
this.runMirrorNow();
|
||||
})
|
||||
|
||||
|
||||
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.brand_text'))
|
||||
@@ -1206,6 +1283,82 @@ struct SettingsPage {
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.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 后默认展示的视图(月/周/列表)
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
@@ -1544,6 +1697,36 @@ struct SettingsPage {
|
||||
this.openDoc(AppSettings.HELP_TITLE, AppSettings.HELP_URL);
|
||||
})
|
||||
|
||||
// FEEDBACK_CARD 问题反馈(FeedLite 平台,?sw=synccalendar 指定本项目站点)
|
||||
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)
|
||||
Text('›')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
.onClick(() => {
|
||||
this.openDoc(AppSettings.FEEDBACK_TITLE, AppSettings.FEEDBACK_URL);
|
||||
})
|
||||
|
||||
// 隐私政策与用户协议(点击弹层;bindSheet 挂在本卡片上,避免与其它弹层冲突)
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
{ "name": "grid_line", "value": "#14000000" },
|
||||
{ "name": "today_bg", "value": "#14007DFF" },
|
||||
{ "name": "selected_bg", "value": "#007DFF" },
|
||||
{ "name": "chip_off_bg", "value": "#0D000000" }
|
||||
{ "name": "chip_off_bg", "value": "#0D000000" },
|
||||
{ "name": "event_mark", "value": "#D93025" },
|
||||
{ "name": "soft_red", "value": "#F2938A" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- 日历本图标:线性风格,与 todo_book 同一套笔触(stroke 2, round 端点) -->
|
||||
<rect x="3" y="5" width="18" height="16" rx="3" fill="none" stroke="#666666" stroke-width="2"/>
|
||||
<path d="M3 10 H21" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- 两个挂环 -->
|
||||
<path d="M8 3 V7" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M16 3 V7" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- 日期格:用两个小方块暗示"日程格" -->
|
||||
<rect x="7" y="13" width="3.5" height="3.5" rx="1" fill="#666666"/>
|
||||
<rect x="13.5" y="13" width="3.5" height="3.5" rx="1" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 792 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- 待办本图标:与 ic_event_book 同一套笔触,用"清单+勾"表达待办 -->
|
||||
<rect x="3" y="4" width="18" height="17" rx="3" fill="none" stroke="#00B34A" stroke-width="2"/>
|
||||
<!-- 三条清单项 -->
|
||||
<path d="M7 9 H17" fill="none" stroke="#00B34A" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M7 13.5 H14" fill="none" stroke="#00B34A" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M7 18 H12" fill="none" stroke="#00B34A" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- 右下角一个勾,强调"待办"语义 -->
|
||||
<path d="M15 17 L17 19 L21 14.5" fill="none" stroke="#00B34A" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 779 B |
@@ -19,6 +19,8 @@
|
||||
{ "name": "grid_line", "value": "#1FFFFFFF" },
|
||||
{ "name": "today_bg", "value": "#330A84FF" },
|
||||
{ "name": "selected_bg", "value": "#0A84FF" },
|
||||
{ "name": "chip_off_bg", "value": "#1FFFFFFF" }
|
||||
{ "name": "chip_off_bg", "value": "#1FFFFFFF" },
|
||||
{ "name": "event_mark", "value": "#FF6B60" },
|
||||
{ "name": "soft_red", "value": "#C97A72" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// 验证 IcsUtil.rewriteTodoFields(改标题/备注/截止时间)
|
||||
function escape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||
}
|
||||
function fmtIcsTime(ms) {
|
||||
const pad = n => n < 10 ? '0' + n : String(n);
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function rewriteTodoFields(ics, uid, title, desc, dueMs) {
|
||||
const crlf = '\r\n';
|
||||
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
|
||||
const upperOf = s => s.toUpperCase();
|
||||
|
||||
let targetBegin = -1, targetEnd = -1, curBegin = -1, curHasUid = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { curBegin = i; curHasUid = false; continue; }
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) curHasUid = true;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) { targetBegin = curBegin; targetEnd = i; }
|
||||
curBegin = -1; curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) return ics;
|
||||
|
||||
const out = [];
|
||||
let sawSummary = false, sawDesc = false, sawDue = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i], u = upperOf(line);
|
||||
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
|
||||
if (u.startsWith('SUMMARY')) { sawSummary = true; if (title !== '') out.push('SUMMARY:' + escape(title)); continue; }
|
||||
if (u.startsWith('DESCRIPTION')) { sawDesc = true; if (desc !== '') out.push('DESCRIPTION:' + escape(desc)); continue; }
|
||||
if (u.startsWith('DUE')) { sawDue = true; if (dueMs > 0) out.push('DUE:' + fmtIcsTime(dueMs)); continue; }
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const extra = [];
|
||||
if (!sawSummary && title !== '') extra.push('SUMMARY:' + escape(title));
|
||||
if (!sawDesc && desc !== '') extra.push('DESCRIPTION:' + escape(desc));
|
||||
if (!sawDue && dueMs > 0) extra.push('DUE:' + fmtIcsTime(dueMs));
|
||||
if (extra.length === 0) { const p = out.join(crlf); return p.endsWith(crlf) ? p : p + crlf; }
|
||||
|
||||
// 重新数目标块序号
|
||||
let targetOrdinal = -1;
|
||||
{
|
||||
let n = -1, inBlk = false, has = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { n++; inBlk = true; has = false; continue; }
|
||||
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { has = true; continue; }
|
||||
if (u.startsWith('END:VTODO') && inBlk) { if (has && targetOrdinal < 0) targetOrdinal = n; inBlk = false; }
|
||||
}
|
||||
}
|
||||
const result = [];
|
||||
let vtodoIdx = -1, inserted = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (!inserted && vtodoIdx === targetOrdinal) { for (const e of extra) result.push(e); inserted = true; }
|
||||
vtodoIdx = -1; result.push(out[i]); continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
const t0 = result.join(crlf);
|
||||
const iv = t0.lastIndexOf('END:VCALENDAR');
|
||||
if (iv > 0) {
|
||||
const m = t0.substring(0, iv) + extra.join(crlf) + crlf + t0.substring(iv);
|
||||
return m.endsWith(crlf) ? m : m + crlf;
|
||||
}
|
||||
for (const e of extra) result.push(e);
|
||||
}
|
||||
const t2 = result.join(crlf);
|
||||
return t2.endsWith(crlf) ? t2 : t2 + crlf;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) { console.log(' ✅', name); pass++; }
|
||||
else { console.log(' ❌', name, extra || ''); fail++; }
|
||||
}
|
||||
function structureIntact(ics) {
|
||||
const i = ics.indexOf('END:VCALENDAR');
|
||||
if (i < 0) return false;
|
||||
return ics.substring(i + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '') === '';
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 原始:含 PRIORITY / CATEGORIES / X-扩展 —— 这些必须原样保留(防丢数据)
|
||||
const ics1 = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:旧标题\r\nDESCRIPTION:旧备注\r\nDUE:20260105T090000Z\r\nPRIORITY:1\r\nCATEGORIES:家务,生活\r\nX-MY-EXT:hello\r\nSTATUS:NEEDS-ACTION\r\nPERCENT-COMPLETE:0\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
|
||||
const due = new Date(2026, 2, 15, 14, 30, 0).getTime();
|
||||
const r1 = rewriteTodoFields(ics1, 't1', '新标题', '新备注', due);
|
||||
|
||||
ok('标题已更新', r1.includes('SUMMARY:新标题'));
|
||||
ok('旧标题已消失', !r1.includes('SUMMARY:旧标题'));
|
||||
ok('备注已更新', r1.includes('DESCRIPTION:新备注'));
|
||||
ok('截止时间已更新', /DUE:\d{8}T\d{6}/.test(r1) && !r1.includes('DUE:20260105'));
|
||||
ok('⭐ 保留 PRIORITY', r1.includes('PRIORITY:1'));
|
||||
ok('⭐ 保留 CATEGORIES', r1.includes('CATEGORIES:家务,生活') || r1.includes('CATEGORIES:家务\\,生活'));
|
||||
ok('⭐ 保留 X-MY-EXT', r1.includes('X-MY-EXT:hello'));
|
||||
ok('⭐ 保留 STATUS', r1.includes('STATUS:NEEDS-ACTION'));
|
||||
ok('⭐ 结构完整', structureIntact(r1));
|
||||
ok('BEGIN/END VTODO 配对', balanced(r1, 'VTODO'));
|
||||
|
||||
// 清除截止时间
|
||||
const r2 = rewriteTodoFields(ics1, 't1', '旧标题', '旧备注', 0);
|
||||
ok('截止时间可清除', !/DUE:\d{8}/.test(r2));
|
||||
ok('清除后仍保留其他字段', r2.includes('PRIORITY:1'));
|
||||
|
||||
// 特殊字符转义(分号/逗号/换行)
|
||||
const r3 = rewriteTodoFields(ics1, 't1', '标题;含逗号,和换行\n第二行', '', due);
|
||||
ok('⭐ 特殊字符已转义', r3.includes('SUMMARY:标题\\;含逗号\\,和换行\\n第二行'), r3.match(/SUMMARY:[^\r]*/)?.[0]);
|
||||
|
||||
// uid 不匹配 → 原文不动
|
||||
ok('uid 不匹配 → 原文不变', rewriteTodoFields(ics1, 'nope', 'x', 'y', due) === ics1);
|
||||
|
||||
// 缺 SUMMARY/DESCRIPTION 的极简待办 → 补齐
|
||||
const ics4 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t2\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r4 = rewriteTodoFields(ics4, 't2', '补的标题', '补的备注', due);
|
||||
ok('缺字段能补齐', r4.includes('SUMMARY:补的标题') && r4.includes('DESCRIPTION:补的备注') && /DUE:/.test(r4));
|
||||
ok('⭐ 补齐在 VTODO 内', structureIntact(r4) && (() => {
|
||||
const b = r4.substring(r4.indexOf('BEGIN:VTODO'), r4.indexOf('END:VTODO'));
|
||||
return b.includes('SUMMARY:补的标题');
|
||||
})());
|
||||
|
||||
// 多 VTODO 只改目标
|
||||
const ics5 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r5 = rewriteTodoFields(ics5, 'a2', '改后', '', due);
|
||||
ok('多 VTODO:a1 未动', r5.split('UID:a1')[1].split('END:VTODO')[0].includes('SUMMARY:第一个'));
|
||||
ok('多 VTODO:a2 已改', r5.split('UID:a2')[1].split('END:VTODO')[0].includes('SUMMARY:改后'));
|
||||
ok('⭐ 多 VTODO 结构完整', structureIntact(r5));
|
||||
|
||||
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
|
||||
console.log('\n--- 测试1 输出 ---');
|
||||
console.log(r1.replace(/\r\n/g, '\n'));
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,185 @@
|
||||
// 验证待办循环:incForFreq 与 rewriteTodoRecurrence
|
||||
function fmtIcsTime(ms) {
|
||||
const pad = n => n < 10 ? '0' + n : String(n);
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
function freqOfRrule(rrule) {
|
||||
for (const part of rrule.split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i <= 0) continue;
|
||||
if (part.substring(0, i).trim().toUpperCase() === 'FREQ') {
|
||||
return part.substring(i + 1).trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
return 'DAILY';
|
||||
}
|
||||
function incForFreq(freq, fromMs) {
|
||||
const d = new Date(fromMs), f = freq.toUpperCase();
|
||||
if (f === 'DAILY') return fromMs + 86400000;
|
||||
if (f === 'WEEKLY') return fromMs + 7 * 86400000;
|
||||
if (f === 'MONTHLY') {
|
||||
const y = d.getFullYear(), m = d.getMonth() + 1, day = d.getDate();
|
||||
const last = new Date(y, m + 1, 0).getDate();
|
||||
return new Date(y, m, Math.min(day, last), d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
|
||||
}
|
||||
if (f === 'YEARLY') {
|
||||
const y = d.getFullYear() + 1, m = d.getMonth(), day = d.getDate();
|
||||
const last = new Date(y, m + 1, 0).getDate();
|
||||
return new Date(y, m, Math.min(day, last), d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
|
||||
}
|
||||
return fromMs + 86400000;
|
||||
}
|
||||
|
||||
function rewriteTodoRecurrence(ics, uid, rrule, dtstart, dueMs) {
|
||||
const crlf = '\r\n';
|
||||
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
|
||||
const upperOf = s => s.toUpperCase();
|
||||
const hasRule = rrule.trim() !== '';
|
||||
|
||||
let targetBegin = -1, targetEnd = -1, curBegin = -1, curHasUid = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { curBegin = i; curHasUid = false; continue; }
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) curHasUid = true;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) { targetBegin = curBegin; targetEnd = i; }
|
||||
curBegin = -1; curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) return ics;
|
||||
|
||||
const out = [];
|
||||
let sawRule = false, sawStart = false, sawDue = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i], u = upperOf(line);
|
||||
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
|
||||
if (u.startsWith('RRULE')) { sawRule = true; if (hasRule) out.push('RRULE:' + rrule.trim()); continue; }
|
||||
if (u.startsWith('DTSTART')) { sawStart = true; if (hasRule && dtstart > 0) out.push('DTSTART:' + fmtIcsTime(dtstart)); continue; }
|
||||
if (u.startsWith('DUE')) { sawDue = true; if (dueMs > 0) out.push('DUE:' + fmtIcsTime(dueMs)); continue; }
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const extra = [];
|
||||
if (hasRule) {
|
||||
if (!sawRule) extra.push('RRULE:' + rrule.trim());
|
||||
if (!sawStart && dtstart > 0) extra.push('DTSTART:' + fmtIcsTime(dtstart));
|
||||
if (!sawDue && dueMs > 0) extra.push('DUE:' + fmtIcsTime(dueMs));
|
||||
}
|
||||
if (extra.length === 0) { const p = out.join(crlf); return p.endsWith(crlf) ? p : p + crlf; }
|
||||
|
||||
let targetOrdinal = -1;
|
||||
{
|
||||
let n = -1, inBlk = false, has = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { n++; inBlk = true; has = false; continue; }
|
||||
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { has = true; continue; }
|
||||
if (u.startsWith('END:VTODO') && inBlk) { if (has && targetOrdinal < 0) targetOrdinal = n; inBlk = false; }
|
||||
}
|
||||
}
|
||||
const result = [];
|
||||
let vtodoIdx = -1, inserted = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (!inserted && vtodoIdx === targetOrdinal) { for (const e of extra) result.push(e); inserted = true; }
|
||||
result.push(out[i]); continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
const t0 = result.join(crlf);
|
||||
const iv = t0.lastIndexOf('END:VCALENDAR');
|
||||
if (iv > 0) {
|
||||
const m = t0.substring(0, iv) + extra.join(crlf) + crlf + t0.substring(iv);
|
||||
return m.endsWith(crlf) ? m : m + crlf;
|
||||
}
|
||||
for (const e of extra) result.push(e);
|
||||
}
|
||||
const t2 = result.join(crlf);
|
||||
return t2.endsWith(crlf) ? t2 : t2 + crlf;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) { console.log(' ✅', name); pass++; }
|
||||
else { console.log(' ❌', name, extra || ''); fail++; }
|
||||
}
|
||||
function structureIntact(ics) {
|
||||
const i = ics.indexOf('END:VCALENDAR');
|
||||
if (i < 0) return false;
|
||||
return ics.substring(i + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '') === '';
|
||||
}
|
||||
|
||||
// ===== 1. incForFreq 各种边界 =====
|
||||
const d0 = new Date(2026, 0, 31, 9, 0, 0).getTime(); // 2026-01-31 09:00
|
||||
const m1 = new Date(incForFreq('MONTHLY', d0));
|
||||
ok('1/31 + 1月 → 2/28(钳制,不跳到3月)',
|
||||
m1.getMonth() === 1 && m1.getDate() === 28, `${m1.getMonth() + 1}/${m1.getDate()}`);
|
||||
ok(' 时分保持不变(09:00)', m1.getHours() === 9 && m1.getMinutes() === 0);
|
||||
|
||||
const leap = new Date(2024, 1, 29, 9, 0, 0).getTime(); // 2024-02-29
|
||||
const y1 = new Date(incForFreq('YEARLY', leap));
|
||||
ok('2/29 + 1年 → 2/28(次年无29日)', y1.getMonth() === 1 && y1.getDate() === 28,
|
||||
`${y1.getFullYear()}-${y1.getMonth() + 1}-${y1.getDate()}`);
|
||||
|
||||
const w = new Date(incForFreq('WEEKLY', d0));
|
||||
ok('每周 = +7 天', (w.getTime() - d0) === 7 * 86400000);
|
||||
|
||||
const dd = new Date(incForFreq('DAILY', d0));
|
||||
ok('每天 = +1 天', (dd.getTime() - d0) === 86400000);
|
||||
|
||||
// ===== 2. freqOfRrule =====
|
||||
ok('freqOfRrule 取 WEEKLY', freqOfRrule('FREQ=WEEKLY;INTERVAL=2') === 'WEEKLY');
|
||||
ok('freqOfRrule 无序也能取', freqOfRrule('INTERVAL=2;FREQ=DAILY') === 'DAILY');
|
||||
ok('freqOfRrule 空规则回退 DAILY', freqOfRrule('') === 'DAILY');
|
||||
|
||||
// ===== 3. rewriteTodoRecurrence 写规则 =====
|
||||
const ics1 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:浇花\r\nDUE:20260110T090000\r\nSTATUS:NEEDS-ACTION\r\nPRIORITY:5\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const due1 = new Date(2026, 0, 10, 9, 0, 0).getTime();
|
||||
const r1 = rewriteTodoRecurrence(ics1, 't1', 'FREQ=WEEKLY', due1, due1);
|
||||
|
||||
ok('写入 RRULE', r1.includes('RRULE:FREQ=WEEKLY'));
|
||||
ok('⭐ 同时写入 DTSTART(消除客户端歧义)', /DTSTART:\d{8}T\d{6}/.test(r1));
|
||||
ok('DUE 保留', /DUE:\d{8}T\d{6}/.test(r1));
|
||||
ok('⭐ 保留 PRIORITY(不丢字段)', r1.includes('PRIORITY:5'));
|
||||
ok('⭐ 保留 STATUS', r1.includes('STATUS:NEEDS-ACTION'));
|
||||
ok('⭐ 结构完整', structureIntact(r1));
|
||||
|
||||
// ===== 4. 取消循环 =====
|
||||
const ics2 = r1;
|
||||
const r2 = rewriteTodoRecurrence(ics2, 't1', '', 0, due1);
|
||||
ok('取消循环 → RRULE 被删', !r2.includes('RRULE:'));
|
||||
ok('取消循环 → DTSTART 被删', !r2.includes('DTSTART:'));
|
||||
ok('取消循环 → DUE 保留', /DUE:\d{8}/.test(r2));
|
||||
ok('⭐ 取消后结构完整', structureIntact(r2));
|
||||
|
||||
// ===== 5. 推进到下一轮(模拟 completeRecurringRound)=====
|
||||
const base = due1;
|
||||
const nextBase = incForFreq('WEEKLY', base);
|
||||
const r3 = rewriteTodoRecurrence(ics1, 't1', 'FREQ=WEEKLY', nextBase, nextBase);
|
||||
const mDt = r3.match(/DTSTART:(\d{8}T\d{6})/);
|
||||
ok('⭐ 推进后 DTSTART 变成下一周', mDt !== null && mDt[1].startsWith('20260117'),
|
||||
mDt ? mDt[1] : 'no match');
|
||||
ok('推进后 RRULE 不变', r3.includes('RRULE:FREQ=WEEKLY'));
|
||||
|
||||
// ===== 6. 多 VTODO 只改目标 =====
|
||||
const ics6 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nDUE:20260101T090000\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nDUE:20260102T090000\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r6 = rewriteTodoRecurrence(ics6, 'a2', 'FREQ=DAILY', due1, due1);
|
||||
ok('多 VTODO:a1 未加 RRULE', !r6.split('UID:a1')[1].split('END:VTODO')[0].includes('RRULE'));
|
||||
ok('多 VTODO:a2 已加 RRULE', r6.split('UID:a2')[1].split('END:VTODO')[0].includes('RRULE'));
|
||||
ok('⭐ 多 VTODO 结构完整', structureIntact(r6));
|
||||
|
||||
// ===== 7. uid 不匹配 → 原文不动 =====
|
||||
ok('uid 不匹配 → 原文不变', rewriteTodoRecurrence(ics1, 'nope', 'FREQ=DAILY', due1, due1) === ics1);
|
||||
|
||||
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
|
||||
console.log('\n--- 写入循环后的 ICS ---');
|
||||
console.log(r1.replace(/\r\n/g, '\n'));
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,65 @@
|
||||
// 验证 preferredDay 机制消除日号漂移
|
||||
function addMonths(y, m, delta) {
|
||||
let mm = m + delta, yy = y;
|
||||
while (mm < 0) { mm += 12; yy--; }
|
||||
while (mm > 11) { mm -= 12; yy++; }
|
||||
return [yy, mm];
|
||||
}
|
||||
function migrate(preferredDay, y, m, delta) {
|
||||
const nm = addMonths(y, m, delta);
|
||||
const lastDay = new Date(nm[0], nm[1] + 1, 0).getDate();
|
||||
return { ms: new Date(nm[0], nm[1], Math.min(preferredDay, lastDay)).getTime(), y: nm[0], m: nm[1] };
|
||||
}
|
||||
function fmt(ms) {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) { console.log(' ✅', name); pass++; }
|
||||
else { console.log(' ❌', name, extra); fail++; }
|
||||
}
|
||||
|
||||
// 1. 偏好 31 号:1/31 → 2/28 → 3/31(回到 31!)
|
||||
let pref = 31, y = 2026, m = 0, cur = new Date(2026, 0, 31).getTime(), log = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = migrate(pref, y, m, 1);
|
||||
cur = r.ms; y = r.y; m = r.m;
|
||||
log.push(fmt(cur));
|
||||
}
|
||||
console.log(' 偏好31号连续翻月:', log.join(' → '));
|
||||
ok('1/31→2/28→3/31→4/30(月底语义保留)',
|
||||
log[0] === '2026-02-28' && log[1] === '2026-03-31' && log[2] === '2026-04-30',
|
||||
log.join(','));
|
||||
ok('第4次翻到5月回到31', log[3] === '2026-05-31', log[3]);
|
||||
|
||||
// 2. 偏好 15 号:一直稳定
|
||||
pref = 15; y = 2026; m = 0; log = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = migrate(pref, y, m, 1);
|
||||
y = r.y; m = r.m; log.push(fmt(r.ms));
|
||||
}
|
||||
ok('偏好15号稳定', log.every(s => s.endsWith('-15')), log.join(','));
|
||||
|
||||
// 3. 用户点 2/28 后偏好变 28,翻到 3 月应是 28 号
|
||||
pref = 28; y = 2026; m = 1;
|
||||
let r = migrate(pref, y, m, 1);
|
||||
ok('点过28号后翻月为3/28', fmt(r.ms) === '2026-03-28', fmt(r.ms));
|
||||
|
||||
// 4. 闰年偏好 31
|
||||
pref = 31; y = 2028; m = 0;
|
||||
r = migrate(pref, y, m, 1);
|
||||
ok('闰年 1/31 → 2/29', fmt(r.ms) === '2028-02-29', fmt(r.ms));
|
||||
r = migrate(pref, 2028, 1, 1);
|
||||
ok('闰年 2/29 → 3/31', fmt(r.ms) === '2028-03-31', fmt(r.ms));
|
||||
|
||||
// 5. 往前翻同样保留
|
||||
pref = 31; y = 2026; m = 2; // 3月
|
||||
r = migrate(pref, y, m, -1);
|
||||
ok('3/31 上月 → 2/28', fmt(r.ms) === '2026-02-28', fmt(r.ms));
|
||||
r = migrate(pref, 2026, 1, -1);
|
||||
ok('2/28 再上月 → 1/31', fmt(r.ms) === '2026-01-31', fmt(r.ms));
|
||||
|
||||
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user