Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fb5fd120 | ||
|
|
8cc28c6d53 | ||
|
|
ab718fc00f | ||
|
|
2c565747ba | ||
|
|
f08a2a0dea | ||
|
|
f807a5d99f | ||
|
|
8126c058fa | ||
|
|
051257e3be | ||
|
|
daa577e88e | ||
|
|
39337a5530 | ||
|
|
007c112af2 | ||
|
|
aa3187f7d9 | ||
|
|
fe7945d300 | ||
|
|
22d67e8a9d | ||
|
|
8497cfeb3f | ||
|
|
d9db8b476a | ||
|
|
b623a876dd | ||
|
|
413900bbce | ||
|
|
c0b9b44ca2 | ||
|
|
b660a986dc | ||
|
|
8b8b69b287 | ||
|
|
b8d5aa630c | ||
|
|
2bb620a6ed | ||
|
|
ca97ec7fcd | ||
|
|
09e2373965 | ||
|
|
bcaa578ebe | ||
|
|
9a67fce296 | ||
|
|
23b5d4cb9e | ||
|
|
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": {
|
"app": {
|
||||||
"bundleName": "synccalendar.yangyq.net",
|
"bundleName": "synccalendar.yangyq.net",
|
||||||
"vendor": "yangyq",
|
"vendor": "yangyq",
|
||||||
"versionCode": 10000,
|
"versionCode": 10201,
|
||||||
"versionName": "0.1.0",
|
"versionName": "0.2.1",
|
||||||
"buildVersion": "1",
|
"buildVersion": "1",
|
||||||
"icon": "$media:layered_image",
|
"icon": "$media:layered_image",
|
||||||
"label": "$string:app_name"
|
"label": "$string:app_name"
|
||||||
|
|||||||
@@ -2,12 +2,41 @@
|
|||||||
|
|
||||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||||
|
|
||||||
- 应用市场送审版本:**0.0.6** | 仓库当前:**0.0.6**(versionCode 600)
|
- 应用市场送审版本:**0.2.0** | 仓库当前:**0.2.0**(versionCode 10200)
|
||||||
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
|
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://feedlite.yangyq.net/?sw=synccalendar>
|
||||||
- 开源协议:Apache License 2.0
|
- 开源协议:Apache License 2.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📝 最近更新
|
||||||
|
|
||||||
|
### v0.2.0(2026-11)
|
||||||
|
|
||||||
|
**待办(VTODO)从只读升级为完整双向同步**
|
||||||
|
- 新增 `TodoService`:新建 / 编辑 / 打勾完成 / 删除待办,全部推送回服务器
|
||||||
|
- 打过勾会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT
|
||||||
|
- 修改前先 GET 原始 `.ics`,原地改写而不是整份重建 —— 保留服务器上本应用不认识的属性
|
||||||
|
- 日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
|
||||||
|
|
||||||
|
**同步核心修复(3 项高危)**
|
||||||
|
- **删除 404**:`remotePath` 改为保存服务器真实资源文件名。此前一直用
|
||||||
|
`encodeURIComponent(uid) + '.ics'` 去猜,遇到「文件名 ≠ UID」的服务器
|
||||||
|
(如用 `preg_replace` 改名的家书)会 DELETE 到不存在的路径 → 404 → 服务器上删不掉。
|
||||||
|
修复覆盖三条取值链路:REPORT 补拉、etag 未变的复用路径、以及 unchanged 分支的存量回填。
|
||||||
|
- **防复活**:`applyRemote` 增加 `dirtyKeyed` / `dirtyDeletedUids` 白名单。
|
||||||
|
此前只把 dirty 行排除出索引,远端数据查不到就走"新增"分支又插一行,
|
||||||
|
导致同一事件出现两条、或"删了又回来"。
|
||||||
|
- **404/410 物理清库**:删除请求返回 404/410 时视为服务器上已不存在,
|
||||||
|
继续清理本地行而不是直接 return —— 否则用户会看到"怎么删都删不掉"的日程。
|
||||||
|
|
||||||
|
**反馈渠道迁移**
|
||||||
|
- 从自建 PHP 论坛迁至多软件共用的 **FeedLite**,按软件分栏管理
|
||||||
|
|
||||||
|
### v0.1.0(首个送审版本)
|
||||||
|
- CalDAV 全链路同步、月/周/列表视图、重复日程展开、系统日历镜像、桌面卡片、后台上层同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
### CalDAV 同步
|
### CalDAV 同步
|
||||||
@@ -15,7 +44,10 @@
|
|||||||
- 完整协议链路:**PROPFIND** 发现日历本 → **REPORT** 全量拉取 → **ETag** 增量比对 → **PUT / DELETE** 双向推送
|
- 完整协议链路:**PROPFIND** 发现日历本 → **REPORT** 全量拉取 → **ETag** 增量比对 → **PUT / DELETE** 双向推送
|
||||||
- 一个账号可挂载多个日历本,按需勾选同步范围;支持在线编辑账号、重选日历本、删除账号
|
- 一个账号可挂载多个日历本,按需勾选同步范围;支持在线编辑账号、重选日历本、删除账号
|
||||||
- 解析 **VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID**,重复日程的**单次覆盖实例**也能正确还原
|
- 解析 **VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID**,重复日程的**单次覆盖实例**也能正确还原
|
||||||
- 支持 VTODO 待办同步(只读展示,不参与推送)
|
- 完整 **VTODO 待办**支持:新建 / 编辑 / 打勾完成 / 删除,均可双向推送回服务器
|
||||||
|
- 待办与日程分列:日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
|
||||||
|
- 打勾完成会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT 回服务器
|
||||||
|
- 修改时先 GET 原始 `.ics` 再原地改写,保留服务器上本应用不认识的属性
|
||||||
- 「重建日程数据」可强制整表重拉,用于修复提醒丢失等本地数据问题
|
- 「重建日程数据」可强制整表重拉,用于修复提醒丢失等本地数据问题
|
||||||
|
|
||||||
### 日历视图与交互
|
### 日历视图与交互
|
||||||
@@ -32,10 +64,19 @@
|
|||||||
### 日程编辑与提醒
|
### 日程编辑与提醒
|
||||||
|
|
||||||
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
|
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
|
||||||
|
- **地点搜索补全**:在「地点」栏输入关键词即可列出匹配地点(华为 Map Kit / Site Kit),点一下填入完整地址;也可点「地图」进入系统地图界面搜索、拖动图钉选点。**不申请任何定位权限**,手动输入同样可用
|
||||||
|
- **一键导航**:日程详情中点击「导航 ›」,用系统地理编码把地点地址换算为坐标后唤起高德地图;未安装则回退网页版。**不申请定位权限**(`getAddressesFromLocationName` 无需该权限),不会读取设备实时位置
|
||||||
|
- **必填项校验强提醒**:未填标题等必填项时,以 Toast + 输入框标红 + 自动滚回定位三管齐下提示,不会出现"提示在屏幕外看不到"的情况
|
||||||
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
|
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
|
||||||
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
||||||
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
|
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
|
||||||
|
|
||||||
|
### CalDAV 地址自动发现
|
||||||
|
|
||||||
|
- 只填服务器域名也能用:按标准 **`/.well-known/caldav`** 自动发现端点,并在常见路径(Nextcloud、群晖等)中依次尝试
|
||||||
|
- 发现时只认 `resourcetype` 含 `calendar` 的集合,避免把 DAV 根或分享链接误当成日历
|
||||||
|
- 失败时给出**人话错误提示**(如"正确形式通常为 `https://域名/remote.php/dav/calendars/用户名/`"),而不是抛原始状态码
|
||||||
|
|
||||||
### 系统日历镜像(可选增强,默认关闭)
|
### 系统日历镜像(可选增强,默认关闭)
|
||||||
|
|
||||||
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
|
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
|
||||||
@@ -73,7 +114,27 @@
|
|||||||
|
|
||||||
### 设置项一览
|
### 设置项一览
|
||||||
|
|
||||||
系统日历处理方式(仅显示 / 备份到 CalDAV)· 镜像到系统日历 · 选择要镜像的日历本 · 回写开关 · 默认视图 · 日程显示方式 · 自动同步间隔 · 后台自动同步 · 通知权限 · 混合显示系统日历 · 日历本只读 / 静音 · 重建日程数据 · 重置账号库 · 软件特性 · 使用帮助 · 隐私政策与用户协议
|
**系统日历**
|
||||||
|
- 镜像到系统日历(含「选择要镜像的日历本」)
|
||||||
|
- 把系统日历的改动回写
|
||||||
|
- 系统日历中的显示名称(三种命名格式可选)
|
||||||
|
- 混合显示系统日历(关闭后只显示 DAV 账号的日程)
|
||||||
|
- 清除系统日历数据(卸载前清理镜像数据,防止残留孤儿账户)
|
||||||
|
|
||||||
|
**通用**
|
||||||
|
- 默认视图(月 / 周 / 列表)
|
||||||
|
- 日程显示方式(竖向时间轴 / 列表)
|
||||||
|
- 自动同步间隔(1 / 5 / 15 / 30 分钟)
|
||||||
|
- 后台自动同步
|
||||||
|
- 通知权限
|
||||||
|
|
||||||
|
**数据**
|
||||||
|
- 重建日程数据(强制整表重拉)
|
||||||
|
- 重置账号库
|
||||||
|
- 日历本只读 / 静音(逐本设置)
|
||||||
|
|
||||||
|
**关于**
|
||||||
|
- 软件特性 · 使用帮助 · 问题反馈 · 隐私政策与用户协议
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,10 +142,12 @@
|
|||||||
|
|
||||||
我们遵循"**数据自持、最小必要**"原则:
|
我们遵循"**数据自持、最小必要**"原则:
|
||||||
|
|
||||||
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机)
|
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机,且以 **AES-256-GCM** 加密存储,密钥由系统 HUKS/TEE 保管、不可导出)
|
||||||
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
|
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
|
||||||
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
|
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
|
||||||
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒、点击地点导航)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
- **不申请定位权限**:本应用从不读取设备实时位置。「一键导航」所需的坐标由系统地理编码服务按**你填写的地址文本**换算,故 `module.json5` 中不含 `LOCATION` / `APPROXIMATELY_LOCATION`
|
||||||
|
- 地点关键词检索由 HarmonyOS 系统能力**华为地图服务**(Map Kit / Site Kit)提供,仅用于检索,不用于广告、画像或统计分析
|
||||||
|
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
||||||
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
|
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -97,7 +160,84 @@
|
|||||||
- **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
|
- **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
|
||||||
- **农历**:`LunarUtil`,1901–2099 压缩表推算
|
- **农历**:`LunarUtil`,1901–2099 压缩表推算
|
||||||
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
|
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
|
||||||
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER / LOCATION(仅地点导航时)
|
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER
|
||||||
|
- ⚠️ **LOCATION 已于 0.1.0 移除**:`geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,地理编码不需要定位权限;应用也从不调用 `getCurrentLocation`。
|
||||||
|
隐私政策/隐私标签里**不得**再声称读取位置。
|
||||||
|
|
||||||
|
## 🔐 权限清单与用途
|
||||||
|
|
||||||
|
| 权限 | 类型 | 用途 | 何时申请 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ohos.permission.INTERNET` | 系统授权 | 访问你填写的 CalDAV 服务器 | 安装即授予 |
|
||||||
|
| `ohos.permission.READ_CALENDAR` | user_grant | 读取本应用写入系统日历的日程 | 打开「混合显示」时 |
|
||||||
|
| `ohos.permission.READ_WHOLE_CALENDAR` | **受限(需 ACL)** | 读取系统日历中**非本应用创建**的日程,实现混合展示 | 同上 |
|
||||||
|
| `ohos.permission.WRITE_CALENDAR` | user_grant | 把 CalDAV 日历本镜像到系统日历 | 开启「镜像到系统日历」时 |
|
||||||
|
| `ohos.permission.PUBLISH_AGENT_REMINDER` | 系统授权 | 发送日程提醒通知 | 保存带提醒的日程时 |
|
||||||
|
|
||||||
|
> 所有 user_grant 权限都**只在用户主动操作对应功能时**才弹窗,启动 / 首启 / 同意隐私政策时一律不申请。
|
||||||
|
> 拒绝后功能默认关闭,可随时在系统设置或本应用设置页重新开启。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 自行编译并上架:权限要怎么处理
|
||||||
|
|
||||||
|
> 如果你 fork 本项目、修改后想上架华为应用市场,**仅修改 `module.json5` 是不够的**。
|
||||||
|
> 下面这些权限需要额外动作,否则审核会被驳回、或安装后权限静默失效。
|
||||||
|
|
||||||
|
### 一、必须自己去申请的(本项目无法代劳)
|
||||||
|
|
||||||
|
以下权限绑定在**华为开发者账号**上,与本仓库代码无关。你必须用自己的账号重新走一遍:
|
||||||
|
|
||||||
|
| 权限 | 需要做什么 | 审核周期 | 被拒风险 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `READ_WHOLE_CALENDAR` | 在 **AGC → 我的应用 → 权限管理** 提交 **ACL 申请**,说明为什么必须读取非本应用创建的日程 | 数个工作日 ~ 2 周 | 中。用途说明不充分会被驳回 |
|
||||||
|
| `PUBLISH_AGENT_REMINDER` | 属受限开放权限,需在 AGC 申请 | 数个工作日 | 低 |
|
||||||
|
|
||||||
|
**本项目已获批的 ACL 只对 `bundleName = synccalendar.yangyq.net` 生效。**
|
||||||
|
一旦你改了 `bundleName`(几乎必然要改,否则会与本项目冲突),ACL 立即失效,必须用新包名重新申请。
|
||||||
|
|
||||||
|
### 二、ACL 是怎么落地的
|
||||||
|
|
||||||
|
ACL **不写在 `module.json5` 里**,而是由 AGC 在审核通过后签发进 **签名 Profile(`.p7b`)**。
|
||||||
|
|
||||||
|
想确认手上这份 Profile 有没有带 ACL,可以这样查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 解出 profile 明文(p7b 本质是 PKCS#7 包裹的 JSON)
|
||||||
|
openssl smime -verify -inform DER -in your.release.p7b -noverify -out profile.json
|
||||||
|
|
||||||
|
# 查看 allowed-acls
|
||||||
|
python3 -c "import json;d=json.load(open('profile.json'));print(d.get('acls'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
正常应输出类似:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"allowed-acls": ["ohos.permission.READ_WHOLE_CALENDAR"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**若 `acls` 为空或缺失,说明该 Profile 未获批 ACL** —— 此时即使 `module.json5` 声明了权限,
|
||||||
|
装到普通用户手机上也会被系统拒绝,功能静默失效。
|
||||||
|
|
||||||
|
### 三、不想申请 ACL 怎么办
|
||||||
|
|
||||||
|
如果你不想走 ACL 流程(例如只是自用、或嫌审核慢),可以把 `READ_WHOLE_CALENDAR` 从
|
||||||
|
`module.json5` 中删掉,代价是:
|
||||||
|
|
||||||
|
- 「混合显示系统日历」只能看到**本应用自己写入**的日历(`READ_CALENDAR` 足够)
|
||||||
|
- 看不到华为账号自带日历、其他 App 写入的日历
|
||||||
|
|
||||||
|
功能仍然可用,只是视图里少了其他来源的日程。这是一种被社区实践过的合规绕行方案。
|
||||||
|
|
||||||
|
### 四、其他与审核相关的注意点
|
||||||
|
|
||||||
|
- **应用图标**:AGC 要求分层图标的后景图(`background.png`)为 **100% 不透明、无圆角**(规范 7.1.1.4.3)。
|
||||||
|
本项目 `AppScope/resources/base/media/background.png` 已满足;若你要换图,务必重新校验 alpha 通道。
|
||||||
|
另外 `app_icon.png` 目前含透明像素,某些场景(如应用内展示)不受影响,但如要提交商店素材建议先转为不透明。
|
||||||
|
- **Demo / 免账号体验**:审核员通常没有你的 CalDAV 账号,无法测试核心功能,这是常见的驳回原因。
|
||||||
|
建议提供一个「一键载入演示数据」入口,或在上架备注中提供测试账号。
|
||||||
|
- **隐私政策一致性**:隐私政策里声明的权限、数据流向必须与 `module.json5` 完全一致。
|
||||||
|
本项目已移除 LOCATION,隐私政策中**不得**再出现"读取位置"之类表述。
|
||||||
|
|
||||||
### 目录结构
|
### 目录结构
|
||||||
|
|
||||||
@@ -326,7 +466,7 @@ hvigorw assembleApp --mode project -p product=default -p buildMode=release
|
|||||||
| 软件特性 | <https://synccalendar.yangyq.net/sf.html> |
|
| 软件特性 | <https://synccalendar.yangyq.net/sf.html> |
|
||||||
| 隐私政策 | <https://synccalendar.yangyq.net/upa.html> |
|
| 隐私政策 | <https://synccalendar.yangyq.net/upa.html> |
|
||||||
| 用户服务协议 | <https://synccalendar.yangyq.net/usa.html> |
|
| 用户服务协议 | <https://synccalendar.yangyq.net/usa.html> |
|
||||||
| 问题反馈 | <https://synccalendar.yangyq.net/feedback/> |
|
| 问题反馈 | <https://feedlite.yangyq.net/?sw=synccalendar> |
|
||||||
|
|
||||||
网站备案:**鲁ICP备17014997号-6A**
|
网站备案:**鲁ICP备17014997号-6A**
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ export class DavAccount {
|
|||||||
calendarColors: string[] = [];
|
calendarColors: string[] = [];
|
||||||
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
|
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
|
||||||
calendarWritable: string[] = [];
|
calendarWritable: string[] = [];
|
||||||
|
/**
|
||||||
|
* ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应:
|
||||||
|
* 'event' 纯日历本(只有 VEVENT)
|
||||||
|
* 'todo' 纯待办本(只有 VTODO)
|
||||||
|
* 'both' 混合本
|
||||||
|
* 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容)
|
||||||
|
*/
|
||||||
|
calendarComps: string[] = [];
|
||||||
itemCount: number = 0;
|
itemCount: number = 0;
|
||||||
lastSyncTime: string = '';
|
lastSyncTime: string = '';
|
||||||
}
|
}
|
||||||
@@ -69,6 +77,14 @@ export class CalSource {
|
|||||||
source: string = 'dav'; // 'dav' | 'system' | 'local'
|
source: string = 'dav'; // 'dav' | 'system' | 'local'
|
||||||
visible: boolean = true;
|
visible: boolean = true;
|
||||||
writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑
|
writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑
|
||||||
|
/**
|
||||||
|
* 本支持哪些组件(来自服务器 COMP_SET 声明):
|
||||||
|
* 'event' 纯日历本(新建日程可选,新建待办不可选)
|
||||||
|
* 'todo' 纯待办本(反过来)
|
||||||
|
* 'both' 两者都可
|
||||||
|
* 'unknown' 服务器未声明 → 两边都按可用处理(旧数据兼容)
|
||||||
|
*/
|
||||||
|
comps: string = 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 与 UI 无关的调色板 */
|
/** 与 UI 无关的调色板 */
|
||||||
@@ -474,6 +490,9 @@ export class AccountStore {
|
|||||||
if (parts.length >= 12) {
|
if (parts.length >= 12) {
|
||||||
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
|
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
|
||||||
}
|
}
|
||||||
|
if (parts.length >= 13) {
|
||||||
|
acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';');
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +584,7 @@ export class AccountStore {
|
|||||||
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
|
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
|
||||||
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
|
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
|
||||||
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] 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.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
|
||||||
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
|
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
|
||||||
if (a.id === '') {
|
if (a.id === '') {
|
||||||
@@ -699,4 +719,45 @@ export class AccountStore {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。
|
||||||
|
*
|
||||||
|
* 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`,
|
||||||
|
* 于是调用方无法从返回值区分两种情况:
|
||||||
|
* ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**);
|
||||||
|
* ② 密文存在但解不开,或读取过程抛错(临时故障)。
|
||||||
|
*
|
||||||
|
* 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理,
|
||||||
|
* 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。
|
||||||
|
*
|
||||||
|
* 判定口径(三者同时成立才算"确认空白"):
|
||||||
|
* - 偏好里**没有**账号密文(`data_b64` 为空);
|
||||||
|
* - 旧版明文存储也没有账号;
|
||||||
|
* - 读取过程没有抛异常。
|
||||||
|
*
|
||||||
|
* @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过
|
||||||
|
*/
|
||||||
|
static async isEmptyVaultConfirmed(context: common.Context): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences = await Vault.prefs(context);
|
||||||
|
const data: string = await store.get(VAULT_DATA, '') as string;
|
||||||
|
if (data !== '') {
|
||||||
|
// 有密文:只有能解出"0 个账号"才算确认空白;解不开一律视为拿不准
|
||||||
|
const plain: string | null = await Vault.decrypt(context, data);
|
||||||
|
if (plain === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const arr: Array<Record<string, Object>> = JSON.parse(plain) as Array<Record<string, Object>>;
|
||||||
|
return arr.length === 0;
|
||||||
|
}
|
||||||
|
// 无密文:再看旧版明文存储是否还有残留账号
|
||||||
|
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
|
||||||
|
return legacy === null || legacy.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
console.error(`[AccountStore] isEmptyVaultConfirmed 判定失败(按"拿不准"处理): ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,20 @@ export class AppSettings {
|
|||||||
static readonly FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html';
|
static readonly FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html';
|
||||||
/** 使用帮助网址 */
|
/** 使用帮助网址 */
|
||||||
static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.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 PRIVACY_TITLE: string = '隐私政策';
|
||||||
static readonly AGREEMENT_TITLE: string = '用户服务协议';
|
static readonly AGREEMENT_TITLE: string = '用户服务协议';
|
||||||
static readonly FEATURES_TITLE: string = '软件特性';
|
static readonly FEATURES_TITLE: string = '软件特性';
|
||||||
static readonly HELP_TITLE: string = '使用帮助';
|
static readonly HELP_TITLE: string = '使用帮助';
|
||||||
|
static readonly FEEDBACK_TITLE: string = '问题反馈';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
|
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { IcsUtil } from './IcsUtil';
|
|||||||
/** 界面展示用事件 */
|
/** 界面展示用事件 */
|
||||||
export class DisplayEvent {
|
export class DisplayEvent {
|
||||||
id: number = 0; // 本地库 id 或系统日历事件 id
|
id: number = 0; // 本地库 id 或系统日历事件 id
|
||||||
|
uid: string = ''; // 服务器 UID(待办父子关系用它匹配 parentUid)
|
||||||
isSystem: boolean = false;
|
isSystem: boolean = false;
|
||||||
title: string = '';
|
title: string = '';
|
||||||
description: string = '';
|
description: string = '';
|
||||||
@@ -25,6 +26,21 @@ export class DisplayEvent {
|
|||||||
color: string = '#007DFF';
|
color: string = '#007DFF';
|
||||||
completed: boolean = false; // 仅待办使用
|
completed: boolean = false; // 仅待办使用
|
||||||
recurring: boolean = false; // 是否为重复日程展开出的发生
|
recurring: boolean = false; // 是否为重复日程展开出的发生
|
||||||
|
/** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同:
|
||||||
|
* recurring 只说"重不重复",这里带的是"多久重复一次"。 */
|
||||||
|
recurRule: string = '';
|
||||||
|
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
|
||||||
|
dtstart: number = 0;
|
||||||
|
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
|
||||||
|
priority: number = 0;
|
||||||
|
/** 分类标签,逗号分隔(对应 CATEGORIES) */
|
||||||
|
categories: string = '';
|
||||||
|
/** 提醒提前分钟数(多选),空 = 不提醒 */
|
||||||
|
reminders: number[] = [];
|
||||||
|
/** 父任务 UID(空 = 顶层任务),对应 RELATED-TO */
|
||||||
|
parentUid: string = '';
|
||||||
|
/** 附件 JSON 数组字符串(含 base64),空 = 无附件 */
|
||||||
|
attachments: string = '';
|
||||||
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +128,9 @@ export class CalendarDataService {
|
|||||||
const detected: boolean = acc.calendarWritable.length > i
|
const detected: boolean = acc.calendarWritable.length > i
|
||||||
? acc.calendarWritable[i] !== '0' : true;
|
? acc.calendarWritable[i] !== '0' : true;
|
||||||
s.writable = detected && !manualKeys.includes(s.calKey);
|
s.writable = detected && !manualKeys.includes(s.calKey);
|
||||||
|
// 透传本支持的类型(新建待办/日程时据此过滤,避免把待办写进纯日历本)
|
||||||
|
s.comps = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
|
||||||
|
? acc.calendarComps[i] : 'unknown';
|
||||||
sources.push(s);
|
sources.push(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,6 +360,11 @@ export class CalendarDataService {
|
|||||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||||
return found !== undefined ? found.color : '#9AA0A6';
|
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 {
|
try {
|
||||||
const rows: LocalEvent[] = await EventDb.queryTodos(context);
|
const rows: LocalEvent[] = await EventDb.queryTodos(context);
|
||||||
for (const e of rows) {
|
for (const e of rows) {
|
||||||
@@ -349,6 +373,7 @@ export class CalendarDataService {
|
|||||||
}
|
}
|
||||||
const d = new DisplayEvent();
|
const d = new DisplayEvent();
|
||||||
d.id = e.id;
|
d.id = e.id;
|
||||||
|
d.uid = e.uid;
|
||||||
d.isSystem = false;
|
d.isSystem = false;
|
||||||
d.title = e.title;
|
d.title = e.title;
|
||||||
d.description = e.description;
|
d.description = e.description;
|
||||||
@@ -357,8 +382,17 @@ export class CalendarDataService {
|
|||||||
d.endTime = e.endTime;
|
d.endTime = e.endTime;
|
||||||
d.isAllDay = e.isAllDay;
|
d.isAllDay = e.isAllDay;
|
||||||
d.calKey = e.calKey;
|
d.calKey = e.calKey;
|
||||||
|
d.calName = nameOf(e.calKey);
|
||||||
d.color = colorOf(e.calKey);
|
d.color = colorOf(e.calKey);
|
||||||
d.completed = e.completed;
|
d.completed = e.completed;
|
||||||
|
d.priority = e.priority;
|
||||||
|
d.categories = e.categories;
|
||||||
|
d.reminders = e.reminders;
|
||||||
|
d.parentUid = e.parentUid;
|
||||||
|
d.attachments = e.attachments;
|
||||||
|
d.recurRule = e.rrule;
|
||||||
|
d.dtstart = e.dtstart;
|
||||||
|
d.recurring = e.rrule !== '';
|
||||||
result.push(d);
|
result.push(d);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ export class CardAllDay {
|
|||||||
isAllDay: boolean = true;
|
isAllDay: boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 待办卡片单条(4x4 / 6x4 列表用,带 localId 以支持卡片内直接打勾) */
|
||||||
|
export class TodoCardItem {
|
||||||
|
/** 本地库主键 —— 卡片"打勾完成"时靠它定位要改哪一行 */
|
||||||
|
id: number = 0;
|
||||||
|
title: string = '';
|
||||||
|
/** 截止时间文字:'今天 18:00' / '明天' / '无日期' */
|
||||||
|
dueText: string = '';
|
||||||
|
/**
|
||||||
|
* 副行文字:截止时间 · 日历本(与 App 内 todoSubtitle 同款呈现)。
|
||||||
|
* 在数据侧拼好,卡片侧直接显示,避免各卡片各拼一遍导致不一致。
|
||||||
|
*/
|
||||||
|
subText: string = '';
|
||||||
|
color: string = '#007DFF';
|
||||||
|
calName: string = '';
|
||||||
|
/** 是否已完成(已完成的不出现在待办卡片列表里,此字段留作扩展) */
|
||||||
|
completed: boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
/** 卡片整体数据 */
|
/** 卡片整体数据 */
|
||||||
export class CardData {
|
export class CardData {
|
||||||
eventsJson: string = '[]';
|
eventsJson: string = '[]';
|
||||||
@@ -62,6 +80,15 @@ export class CardData {
|
|||||||
isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线)
|
isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线)
|
||||||
selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天)
|
selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天)
|
||||||
dayCount: number = 0; // 当前显示日的日程条数(含全天)
|
dayCount: number = 0; // 当前显示日的日程条数(含全天)
|
||||||
|
// ===== 待办卡片专用字段(isTodoCard=true 时才有效)=====
|
||||||
|
/** 待办卡片列表:TodoCardItem[] JSON(按截止时间升序,未完成在前) */
|
||||||
|
todoListJson: string = '[]';
|
||||||
|
/** 未完成待办总数(2x2 / 2x4 卡片大数字) */
|
||||||
|
todoCount: number = 0;
|
||||||
|
/** 今日到期待办数(卡片副标题用) */
|
||||||
|
todoTodayCount: number = 0;
|
||||||
|
/** 是否待办卡片(卡片侧据此切换渲染分支) */
|
||||||
|
isTodoCard: boolean = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CardDataService {
|
export class CardDataService {
|
||||||
@@ -127,25 +154,57 @@ export class CardDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** App 内同步完成后调用:刷新所有已添加的卡片 */
|
/** App 内同步完成后调用:刷新所有已添加的卡片 */
|
||||||
static async pushToAllForms(context: common.Context): Promise<void> {
|
/**
|
||||||
|
* 把待办数据合并进日程数据对象。
|
||||||
|
*
|
||||||
|
* ⭐ 抽出来的原因: EntryFormAbility.pushData 与 CardDataService.pushToAllForms
|
||||||
|
* 是两条独立的推送路径,之前只有前者做了合并,后者漏了 ⇒
|
||||||
|
* 待办卡片永远显示 0 条(todoCount 停在默认值)。
|
||||||
|
* 两处共用同一段逻辑,避免再出现"改一处忘一处"。
|
||||||
|
*/
|
||||||
|
private static mergeTodoInto(ev: CardData, td: CardData): void {
|
||||||
|
ev.todoListJson = td.todoListJson;
|
||||||
|
ev.todoCount = td.todoCount;
|
||||||
|
ev.todoTodayCount = td.todoTodayCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param excludeTodoId 乐观更新用:临时把该待办从**所有卡片**的列表与计数中排除
|
||||||
|
* (只影响卡片显示,不动数据库)。0 表示不排除。
|
||||||
|
*/
|
||||||
|
static async pushToAllForms(context: common.Context, excludeTodoId: number = 0): Promise<void> {
|
||||||
// 先恢复持久化的 formId(App 重启后内存注册表为空)
|
// 先恢复持久化的 formId(App 重启后内存注册表为空)
|
||||||
await CardDataService.loadPersistedFormIds(context);
|
await CardDataService.loadPersistedFormIds(context);
|
||||||
if (CardDataService.formIds.length === 0) {
|
if (CardDataService.formIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// ⭐ 待办数据与 dayOffset 无关(待办不翻页),只构建一次
|
||||||
|
let todoData: CardData | null = null;
|
||||||
|
try {
|
||||||
|
todoData = await CardDataService.buildTodoCardData(context, excludeTodoId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`待办卡片数据构建失败: ${(err as BusinessError).message}`);
|
||||||
|
}
|
||||||
// 各卡片可能翻到了不同日期 → 按偏移分组构建,避免把用户翻走的日期拽回今天
|
// 各卡片可能翻到了不同日期 → 按偏移分组构建,避免把用户翻走的日期拽回今天
|
||||||
const cache: Map<number, formBindingData.FormBindingData> =
|
const cache: Map<number, formBindingData.FormBindingData> =
|
||||||
new Map<number, formBindingData.FormBindingData>();
|
new Map<number, formBindingData.FormBindingData>();
|
||||||
|
const fallbackData: CardData = await CardDataService.buildCardData(context, 0);
|
||||||
|
if (todoData !== null) {
|
||||||
|
CardDataService.mergeTodoInto(fallbackData, todoData);
|
||||||
|
}
|
||||||
const fallback: formBindingData.FormBindingData =
|
const fallback: formBindingData.FormBindingData =
|
||||||
formBindingData.createFormBindingData(await CardDataService.buildCardData(context, 0));
|
formBindingData.createFormBindingData(fallbackData);
|
||||||
cache.set(0, fallback);
|
cache.set(0, fallback);
|
||||||
const stale: string[] = [];
|
const stale: string[] = [];
|
||||||
for (const formId of CardDataService.formIds) {
|
for (const formId of CardDataService.formIds) {
|
||||||
const off: number = CardDataService.getDayOffset(formId);
|
const off: number = CardDataService.getDayOffset(formId);
|
||||||
let binding: formBindingData.FormBindingData | undefined = cache.get(off);
|
let binding: formBindingData.FormBindingData | undefined = cache.get(off);
|
||||||
if (binding === undefined) {
|
if (binding === undefined) {
|
||||||
binding = formBindingData.createFormBindingData(
|
const d: CardData = await CardDataService.buildCardData(context, off);
|
||||||
await CardDataService.buildCardData(context, off));
|
if (todoData !== null) {
|
||||||
|
CardDataService.mergeTodoInto(d, todoData);
|
||||||
|
}
|
||||||
|
binding = formBindingData.createFormBindingData(d);
|
||||||
cache.set(off, binding);
|
cache.set(off, binding);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -305,14 +364,16 @@ export class CardDataService {
|
|||||||
// 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中)
|
// 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中)
|
||||||
data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length;
|
data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length;
|
||||||
|
|
||||||
// ===== 2x4 卡片右侧:**当前时间之后最近的 2 条**日程 =====
|
// ===== 2x4 卡片右侧:**当前时间之后最近的若干条**日程(卡片可滚动,最多 15 条)=====
|
||||||
// 规则:只取"还没开始"的有时间日程(startTime >= now),
|
// 规则:只取"还没开始"的有时间日程(startTime >= now),
|
||||||
// **排除全天 / 跨天**,按开始时间升序取前 2 条。
|
// **排除全天 / 跨天**,按开始时间升序取前 2 条。
|
||||||
// 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。
|
// 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。
|
||||||
const upcomingNext: DisplayEvent[] = upcoming
|
const upcomingNext: DisplayEvent[] = upcoming
|
||||||
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs)
|
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs)
|
||||||
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
|
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
|
||||||
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 2);
|
// ⭐ 上限 15 条:卡片可滚动,不必再为了"放得下"而截断到 2 条。
|
||||||
|
// 15 是经验值 —— 再多则用户更该进 App 看,而不是在卡片里翻。
|
||||||
|
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 15);
|
||||||
const ongoingItems: CardItem[] = [];
|
const ongoingItems: CardItem[] = [];
|
||||||
for (const e of ongoingPick) {
|
for (const e of ongoingPick) {
|
||||||
const dayLong: boolean = isDayLong(e);
|
const dayLong: boolean = isDayLong(e);
|
||||||
@@ -381,4 +442,109 @@ export class CardDataService {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片列表最多输出多少条待办。
|
||||||
|
* ⭐ 统一为 15:所有待办卡片都已改为可滚动(List + 虚拟化),
|
||||||
|
* 不必再为了"一屏放得下"而按卡片尺寸给不同上限。
|
||||||
|
*/
|
||||||
|
private static readonly MAX_TODO_ITEMS: number = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建**待办卡片**数据。
|
||||||
|
*
|
||||||
|
* 与 buildCardData 的区别:不查日程,只查 VTODO;输出 todoListJson + 统计数字。
|
||||||
|
* 复用同一个 CardData 类,靠 isTodoCard 标记,卡片侧据此走不同渲染分支
|
||||||
|
*(这样 FormExtension 的推送逻辑不用区分两种卡片,一套 binding 推所有卡片即可)。
|
||||||
|
*/
|
||||||
|
/** @param excludeId 乐观更新用:临时把该 localId 从列表与计数中排除(不动数据库) */
|
||||||
|
static async buildTodoCardData(context: common.Context, excludeId: number = 0): Promise<CardData> {
|
||||||
|
const data = new CardData();
|
||||||
|
data.isTodoCard = true;
|
||||||
|
try {
|
||||||
|
LogUtil.init(context);
|
||||||
|
const now = new Date();
|
||||||
|
const weekCn: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||||
|
const fullWeekCn: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||||
|
data.dateText = `今天 ${now.getMonth() + 1}月${now.getDate()}日 ${weekCn[now.getDay()]}`;
|
||||||
|
data.lunarText = LunarUtil.lunarDayText(now.getTime());
|
||||||
|
data.dateMd = `${now.getMonth() + 1}月${now.getDate()}日`;
|
||||||
|
data.weekday = fullWeekCn[now.getDay()];
|
||||||
|
data.dayOffset = 0;
|
||||||
|
data.isToday = true;
|
||||||
|
data.selectedDate = CardDataService.startOfDay(now.getTime());
|
||||||
|
|
||||||
|
const sources = await CalendarDataService.loadSources(context);
|
||||||
|
const todos: DisplayEvent[] = await CalendarDataService.loadTodos(context, sources);
|
||||||
|
|
||||||
|
// 只保留未完成;excludeId>0 时额外剔除该条(乐观更新,仅影响卡片显示)
|
||||||
|
const undone: DisplayEvent[] = todos.filter((e: DisplayEvent): boolean =>
|
||||||
|
!e.completed && (excludeId <= 0 || e.id !== excludeId));
|
||||||
|
const todayStart: number = CardDataService.startOfDay(now.getTime());
|
||||||
|
const todayEnd: number = todayStart + 86400000;
|
||||||
|
|
||||||
|
// 排序:有截止时间在前按升序;无截止时间在后(新建的在前)
|
||||||
|
const withDue: DisplayEvent[] = undone
|
||||||
|
.filter((e: DisplayEvent): boolean => e.endTime > 0)
|
||||||
|
.sort((a: DisplayEvent, b: DisplayEvent): number => a.endTime - b.endTime);
|
||||||
|
const noDue: DisplayEvent[] = undone
|
||||||
|
.filter((e: DisplayEvent): boolean => e.endTime <= 0)
|
||||||
|
.sort((a: DisplayEvent, b: DisplayEvent): number => b.id - a.id);
|
||||||
|
|
||||||
|
const orderedList: DisplayEvent[] = withDue.concat(noDue);
|
||||||
|
const p2 = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||||
|
|
||||||
|
const items: TodoCardItem[] = [];
|
||||||
|
for (const e of orderedList) {
|
||||||
|
if (items.length >= CardDataService.MAX_TODO_ITEMS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const it = new TodoCardItem();
|
||||||
|
it.id = e.id;
|
||||||
|
it.title = e.title === '' ? '(无标题)' : e.title;
|
||||||
|
it.color = e.color;
|
||||||
|
it.calName = e.calName;
|
||||||
|
it.completed = e.completed;
|
||||||
|
// 截止时间文字:今天 / 明天 / 昨天 / 逾期 / 具体日期 / 无日期
|
||||||
|
if (e.endTime <= 0) {
|
||||||
|
it.dueText = '无日期';
|
||||||
|
} else {
|
||||||
|
const due = new Date(e.endTime);
|
||||||
|
const dueDay: number = CardDataService.startOfDay(e.endTime);
|
||||||
|
const diffDays: number = Math.round((dueDay - todayStart) / 86400000);
|
||||||
|
const hm: string = `${p2(due.getHours())}:${p2(due.getMinutes())}`;
|
||||||
|
// 是否为"当天 0 点整" —— 只有日期的待办,不显示 00:00
|
||||||
|
const dateOnly: boolean = due.getHours() === 0 && due.getMinutes() === 0;
|
||||||
|
const timePart: string = dateOnly ? '' : ' ' + hm;
|
||||||
|
if (diffDays === 0) {
|
||||||
|
it.dueText = '今天' + timePart;
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
it.dueText = '明天' + timePart;
|
||||||
|
} else if (diffDays === -1) {
|
||||||
|
it.dueText = '昨天' + timePart;
|
||||||
|
} else if (diffDays < -1) {
|
||||||
|
it.dueText = `逾期 ${Math.abs(diffDays)} 天`;
|
||||||
|
} else if (diffDays > 1 && diffDays <= 7) {
|
||||||
|
it.dueText = `${due.getMonth() + 1}月${due.getDate()}日${timePart}`;
|
||||||
|
} else {
|
||||||
|
it.dueText = `${due.getMonth() + 1}/${due.getDate()}${timePart}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 副行:截止时间 · 日历本(本名为空时只显示截止时间)
|
||||||
|
it.subText = it.calName !== '' ? `${it.dueText} · ${it.calName}` : it.dueText;
|
||||||
|
items.push(it);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.todoListJson = JSON.stringify(items);
|
||||||
|
data.todoCount = undone.length;
|
||||||
|
data.todoTodayCount = withDue.filter((e: DisplayEvent): boolean =>
|
||||||
|
e.endTime >= todayStart && e.endTime < todayEnd).length;
|
||||||
|
// 今日到期条数也塞进 todayCount,让 2x2/2x4 复用同一套大数字排版
|
||||||
|
data.todayCount = data.todoCount;
|
||||||
|
LogUtil.write(`待办卡片数据刷新:未完成 ${undone.length} 条,今日到期 ${data.todoTodayCount} 条,列表输出 ${items.length} 条`);
|
||||||
|
} catch (err) {
|
||||||
|
LogUtil.write(`待办卡片数据刷新失败: ${JSON.stringify(err)}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export class DavColorEntry {
|
|||||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||||||
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
|
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
|
||||||
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
|
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(已补全域名)
|
href: string = ''; // 完整 URL(已补全域名)
|
||||||
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
|
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
|
||||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
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>' +
|
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||||
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||||
'<d:current-user-privilege-set/>' +
|
'<d:current-user-privilege-set/>' +
|
||||||
|
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
|
||||||
|
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||||||
'</d:prop></d:propfind>';
|
'</d:prop></d:propfind>';
|
||||||
const httpRequest = http.createHttp();
|
const httpRequest = http.createHttp();
|
||||||
try {
|
try {
|
||||||
@@ -104,6 +117,7 @@ export class DavClient {
|
|||||||
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
|
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
|
||||||
}
|
}
|
||||||
entry.color = color;
|
entry.color = color;
|
||||||
|
entry.comps = DavClient.parseComps(block);
|
||||||
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
|
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
|
||||||
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
|
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
|
||||||
if (privSet !== '') {
|
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)。
|
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
|
||||||
*
|
*
|
||||||
@@ -136,6 +177,7 @@ export class DavClient {
|
|||||||
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
||||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||||
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
'<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>';
|
'</d:prop></d:propfind>';
|
||||||
const httpRequest = http.createHttp();
|
const httpRequest = http.createHttp();
|
||||||
const out = new DavCalendarDiscovery();
|
const out = new DavCalendarDiscovery();
|
||||||
@@ -197,6 +239,9 @@ export class DavClient {
|
|||||||
entry.href = href.startsWith('http') ? href : origin + href;
|
entry.href = href.startsWith('http') ? href : origin + href;
|
||||||
entry.displayName = name;
|
entry.displayName = name;
|
||||||
entry.color = color;
|
entry.color = color;
|
||||||
|
entry.comps = DavClient.parseComps(block);
|
||||||
|
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
|
||||||
|
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
|
||||||
out.entries.push(entry);
|
out.entries.push(entry);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -250,6 +295,37 @@ export class DavClient {
|
|||||||
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 拉取单个资源原文,**失败抛错**(与 getRaw 相反)。
|
||||||
|
*
|
||||||
|
* 为什么需要严格版:待办写回要"先 GET 原文、改字段、再 PUT"。
|
||||||
|
* 若 GET 失败却拿到一个 `"HTTP 404"` 之类的字符串并当 ICS 内容 PUT 回去,
|
||||||
|
* 会把服务器上那条待办**覆盖成一句错误信息**(数据破坏)。
|
||||||
|
* 原 getRaw 保留原行为不动(诊断用,已有调用方)。
|
||||||
|
*/
|
||||||
|
static async getRawStrict(url: string, auth: string): Promise<string> {
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
try {
|
||||||
|
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||||
|
method: http.RequestMethod.GET,
|
||||||
|
header: {
|
||||||
|
'Authorization': auth,
|
||||||
|
'Content-Type': 'text/calendar; charset=utf-8',
|
||||||
|
'User-Agent': 'SyncCalendar/1.0'
|
||||||
|
},
|
||||||
|
connectTimeout: 10000,
|
||||||
|
readTimeout: 20000,
|
||||||
|
expectDataType: http.HttpDataType.STRING
|
||||||
|
});
|
||||||
|
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||||
|
throw new DavHttpError(resp.responseCode, `拉取待办原文失败,服务器返回 ${resp.responseCode}`);
|
||||||
|
}
|
||||||
|
return resp.result as string;
|
||||||
|
} finally {
|
||||||
|
httpRequest.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
||||||
static async getRaw(url: string, auth: string): Promise<string> {
|
static async getRaw(url: string, auth: string): Promise<string> {
|
||||||
const httpRequest = http.createHttp();
|
const httpRequest = http.createHttp();
|
||||||
@@ -389,6 +465,52 @@ export class DavClient {
|
|||||||
return DavClient.reportComponents(href, auth, 'VTODO', '');
|
return DavClient.reportComponents(href, auth, 'VTODO', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PROPFIND Depth:1 列出某集合下所有资源的文件名(诊断用,不做组件过滤) */
|
||||||
|
static async listNames(href: string, auth: string): Promise<string[]> {
|
||||||
|
const body: string = '<?xml version="1.0" encoding="utf-8"?>' +
|
||||||
|
'<d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>';
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
try {
|
||||||
|
const resp: http.HttpResponse = await httpRequest.request(href, {
|
||||||
|
method: 'PROPFIND' as http.RequestMethod,
|
||||||
|
header: {
|
||||||
|
'Authorization': auth,
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'Depth': '1',
|
||||||
|
'User-Agent': 'SyncCalendar/1.0'
|
||||||
|
},
|
||||||
|
extraData: body,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
readTimeout: 30000
|
||||||
|
});
|
||||||
|
const xml: string = resp.result as string;
|
||||||
|
LogUtil.write(`PROPFIND诊断 状态=${resp.responseCode} 响应体=${xml.length}字符`);
|
||||||
|
const out: string[] = [];
|
||||||
|
const re = /<[\w-]*:?href>([^<]+)<\/[\w-]*:?href>/gi;
|
||||||
|
let m: RegExpExecArray | null = re.exec(xml);
|
||||||
|
while (m !== null) {
|
||||||
|
const h: string = m[1];
|
||||||
|
if (h.indexOf('.ics') >= 0) {
|
||||||
|
out.push(decodeURIComponent(h.substring(h.lastIndexOf('/') + 1)));
|
||||||
|
}
|
||||||
|
m = re.exec(xml);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
httpRequest.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不带 comp-filter 的全量 calendar-query(诊断用) */
|
||||||
|
static async reportAll(href: string, auth: string): Promise<RemoteItem[]> {
|
||||||
|
const body: string = '<?xml version="1.0" encoding="utf-8"?>' +
|
||||||
|
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' +
|
||||||
|
'<d:prop><d:getetag/><c:calendar-data/></d:prop>' +
|
||||||
|
'<c:filter><c:comp-filter name="VCALENDAR"/></c:filter>' +
|
||||||
|
'</c:calendar-query>';
|
||||||
|
return DavClient.reportRaw(href, auth, body, 'ALL');
|
||||||
|
}
|
||||||
|
|
||||||
/** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */
|
/** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */
|
||||||
private static async reportComponents(href: string, auth: string,
|
private static async reportComponents(href: string, auth: string,
|
||||||
compName: string, timeRange: string): Promise<RemoteItem[]> {
|
compName: string, timeRange: string): Promise<RemoteItem[]> {
|
||||||
@@ -399,6 +521,12 @@ export class DavClient {
|
|||||||
`<c:comp-filter name="${compName}">` +
|
`<c:comp-filter name="${compName}">` +
|
||||||
timeRange +
|
timeRange +
|
||||||
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
|
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
|
||||||
|
return DavClient.reportRaw(href, auth, body, compName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 REPORT 执行器:传完整 body,返回解析后的资源列表 */
|
||||||
|
static async reportRaw(href: string, auth: string, body: string,
|
||||||
|
compName: string): Promise<RemoteItem[]> {
|
||||||
const httpRequest = http.createHttp();
|
const httpRequest = http.createHttp();
|
||||||
try {
|
try {
|
||||||
const resp: http.HttpResponse = await httpRequest.request(href, {
|
const resp: http.HttpResponse = await httpRequest.request(href, {
|
||||||
@@ -425,6 +553,8 @@ export class DavClient {
|
|||||||
}
|
}
|
||||||
const xml: string = resp.result as string;
|
const xml: string = resp.result as string;
|
||||||
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
|
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
|
||||||
|
LogUtil.write(`REPORT-诊断(${compName}) 前200字符: ${xml.substring(0, 200)}`);
|
||||||
|
LogUtil.write(`REPORT-诊断(${compName}) 含VTODO=${xml.indexOf('BEGIN:VTODO') >= 0} 含VEVENT=${xml.indexOf('BEGIN:VEVENT') >= 0} 响应数=${(xml.match(/<[\w-]*:?response[\s>]/gi) || []).length}`);
|
||||||
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
||||||
const origin: string = originMatch !== null ? originMatch[0] : '';
|
const origin: string = originMatch !== null ? originMatch[0] : '';
|
||||||
const items: RemoteItem[] = [];
|
const items: RemoteItem[] = [];
|
||||||
@@ -449,6 +579,13 @@ export class DavClient {
|
|||||||
item.ics = block.substring(start, end + 'END:VCALENDAR'.length);
|
item.ics = block.substring(start, end + 'END:VCALENDAR'.length);
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
|
LogUtil.write(`诊断 blocks=${blocks.length} items=${items.length} xml长=${xml.length}`);
|
||||||
|
for (let bi = 0; bi < blocks.length; bi++) {
|
||||||
|
const bb: string = blocks[bi];
|
||||||
|
if (!/<[\w-]*:?response[\s>]/i.test(bb)) { continue; }
|
||||||
|
const h: string = DavClient.extractTag(bb, "href");
|
||||||
|
LogUtil.write(`诊断 block${bi} len=${bb.length} href=[${h.substring(0,50)}] VCAL=${bb.indexOf("BEGIN:VCALENDAR")>=0} VTODO=${bb.indexOf("BEGIN:VTODO")>=0}`);
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
} finally {
|
} finally {
|
||||||
httpRequest.destroy();
|
httpRequest.destroy();
|
||||||
|
|||||||
@@ -24,13 +24,30 @@ export class LocalEvent {
|
|||||||
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
|
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
|
||||||
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
|
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
|
||||||
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
|
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
|
||||||
|
/** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。
|
||||||
|
* 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**,
|
||||||
|
* 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */
|
||||||
|
dtstart: number = 0;
|
||||||
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
|
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
|
||||||
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
|
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
|
||||||
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
|
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
|
||||||
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
|
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
|
||||||
recurrenceId: number = 0;
|
recurrenceId: number = 0;
|
||||||
|
/**
|
||||||
|
* 优先级,用 **iCal 原生值**:1=最高 … 9=最低,0=未设置。
|
||||||
|
* 直接存原生值,写回时零转换,也不会与服务器上其他客户端写下的值语义不一致。
|
||||||
|
*/
|
||||||
|
priority: number = 0;
|
||||||
|
/** 分类标签,逗号分隔(对应 iCal CATEGORIES,多值用逗号连接) */
|
||||||
|
categories: string = '';
|
||||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
|
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
|
||||||
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
|
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
|
||||||
|
/** 父任务 UID(空 = 顶层任务)。对应 VTODO 的 RELATED-TO:RELTYPE=PARENT。
|
||||||
|
* 用 UID 而非本地 id:父子关系是服务器资源属性,跨设备同步时 UID 才稳定。 */
|
||||||
|
parentUid: string = '';
|
||||||
|
/** 附件 JSON 数组字符串:[{name,mime,size,base64}],空 = 无附件。
|
||||||
|
* base64 直接存库,同步时拼 ATTACH 行零转换。 */
|
||||||
|
attachments: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 远端事件条目(REPORT 解析结果) */
|
/** 远端事件条目(REPORT 解析结果) */
|
||||||
@@ -40,6 +57,10 @@ export class RemoteEvent {
|
|||||||
title: string = '';
|
title: string = '';
|
||||||
description: string = '';
|
description: string = '';
|
||||||
location: string = '';
|
location: string = '';
|
||||||
|
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
|
||||||
|
priority: number = 0;
|
||||||
|
/** 分类标签,逗号分隔(对应 CATEGORIES) */
|
||||||
|
categories: string = '';
|
||||||
startTime: number = 0;
|
startTime: number = 0;
|
||||||
endTime: number = 0;
|
endTime: number = 0;
|
||||||
isAllDay: boolean = false;
|
isAllDay: boolean = false;
|
||||||
@@ -47,10 +68,24 @@ export class RemoteEvent {
|
|||||||
isTodo: boolean = false; // VTODO 待办
|
isTodo: boolean = false; // VTODO 待办
|
||||||
completed: boolean = false; // VTODO STATUS:COMPLETED
|
completed: boolean = false; // VTODO STATUS:COMPLETED
|
||||||
rrule: string = ''; // 原始 RRULE
|
rrule: string = ''; // 原始 RRULE
|
||||||
|
dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开
|
||||||
exdate: string = ''; // 原始 EXDATE(分号分隔)
|
exdate: string = ''; // 原始 EXDATE(分号分隔)
|
||||||
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
|
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
|
||||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
|
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
|
||||||
reminders: number[] = []; // 全部提醒提前分钟数(多选)
|
reminders: number[] = []; // 全部提醒提前分钟数(多选)
|
||||||
|
/** 父任务 UID(空 = 顶层任务)。 */
|
||||||
|
parentUid: string = '';
|
||||||
|
/** 附件 JSON 数组字符串(含 base64),空 = 无附件。 */
|
||||||
|
attachments: string = '';
|
||||||
|
/**
|
||||||
|
* ⭐ REMOTE_PATH 服务器上的真实资源文件名(REPORT 返回的 href 末段,已含 URL 编码)。
|
||||||
|
*
|
||||||
|
* 为什么不能用 uid 拼:服务器不保证「资源文件名 = ICS 里的 UID」。
|
||||||
|
* 家书 CalDav.php 用 `preg_replace('/[^A-Za-z0-9._-]/', '_', $uid)` 生成文件名,
|
||||||
|
* UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics`。
|
||||||
|
* 猜错 ⇒ DELETE/PUT 打到不存在的路径 ⇒ 404,服务器上删不掉、或写出重复资源。
|
||||||
|
*/
|
||||||
|
remotePath: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EventDb {
|
export class EventDb {
|
||||||
@@ -124,6 +159,31 @@ export class EventDb {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 列已存在
|
// 列已存在
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await store.executeSql("ALTER TABLE events ADD COLUMN parent_uid TEXT DEFAULT ''");
|
||||||
|
} catch (err) {
|
||||||
|
// 列已存在
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await store.executeSql("ALTER TABLE events ADD COLUMN attachments TEXT DEFAULT ''");
|
||||||
|
} catch (err) {
|
||||||
|
// 列已存在
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await store.executeSql('ALTER TABLE events ADD COLUMN priority INTEGER DEFAULT 0');
|
||||||
|
} catch (err) {
|
||||||
|
// 列已存在
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await store.executeSql("ALTER TABLE events ADD COLUMN categories TEXT DEFAULT ''");
|
||||||
|
} catch (err) {
|
||||||
|
// 列已存在
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
|
||||||
|
} catch (err) {
|
||||||
|
// 列已存在
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
|
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -181,12 +241,16 @@ export class EventDb {
|
|||||||
const e = new LocalEvent();
|
const e = new LocalEvent();
|
||||||
e.id = rs.getLong(rs.getColumnIndex('id'));
|
e.id = rs.getLong(rs.getColumnIndex('id'));
|
||||||
e.uid = rs.getString(rs.getColumnIndex('uid'));
|
e.uid = rs.getString(rs.getColumnIndex('uid'));
|
||||||
|
e.dtstart = rs.getLong(rs.getColumnIndex('dtstart'));
|
||||||
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
|
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
|
||||||
e.href = rs.getString(rs.getColumnIndex('href'));
|
e.href = rs.getString(rs.getColumnIndex('href'));
|
||||||
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
|
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
|
||||||
e.title = rs.getString(rs.getColumnIndex('title'));
|
e.title = rs.getString(rs.getColumnIndex('title'));
|
||||||
e.description = rs.getString(rs.getColumnIndex('description'));
|
e.description = rs.getString(rs.getColumnIndex('description'));
|
||||||
e.location = rs.getString(rs.getColumnIndex('location'));
|
e.location = rs.getString(rs.getColumnIndex('location'));
|
||||||
|
e.priority = rs.getLong(rs.getColumnIndex('priority'));
|
||||||
|
const cats: string = rs.getString(rs.getColumnIndex('categories'));
|
||||||
|
e.categories = cats === undefined || cats === null ? '' : cats;
|
||||||
e.startTime = rs.getLong(rs.getColumnIndex('start_time'));
|
e.startTime = rs.getLong(rs.getColumnIndex('start_time'));
|
||||||
e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
|
e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
|
||||||
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
|
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
|
||||||
@@ -202,6 +266,10 @@ export class EventDb {
|
|||||||
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
|
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
|
||||||
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
|
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
|
||||||
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
|
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
|
||||||
|
const pUid: string = rs.getString(rs.getColumnIndex('parent_uid'));
|
||||||
|
e.parentUid = (pUid === undefined || pUid === null) ? '' : pUid;
|
||||||
|
const att: string = rs.getString(rs.getColumnIndex('attachments'));
|
||||||
|
e.attachments = (att === undefined || att === null) ? '' : att;
|
||||||
const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
|
const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
|
||||||
if (remStr !== '') {
|
if (remStr !== '') {
|
||||||
e.reminders = remStr.split(',').map((s: string): number => Number(s))
|
e.reminders = remStr.split(',').map((s: string): number => Number(s))
|
||||||
@@ -234,7 +302,11 @@ export class EventDb {
|
|||||||
'exdate': e.exdate,
|
'exdate': e.exdate,
|
||||||
'recurrence_id': e.recurrenceId,
|
'recurrence_id': e.recurrenceId,
|
||||||
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
|
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
|
||||||
'reminders': e.reminders.map((n: number): string => String(n)).join(',')
|
'reminders': e.reminders.map((n: number): string => String(n)).join(','),
|
||||||
|
'priority': e.priority,
|
||||||
|
'categories': e.categories,
|
||||||
|
'parent_uid': e.parentUid,
|
||||||
|
'attachments': e.attachments
|
||||||
};
|
};
|
||||||
return bucket;
|
return bucket;
|
||||||
}
|
}
|
||||||
@@ -336,6 +408,90 @@ export class EventDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
|
/** 全部未删除的待办(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[]> {
|
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
|
||||||
const store = await EventDb.getDb(context);
|
const store = await EventDb.getDb(context);
|
||||||
const predicates = new relationalStore.RdbPredicates('events');
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
@@ -377,6 +533,72 @@ export class EventDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */
|
/** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */
|
||||||
|
/**
|
||||||
|
* 查"未来窗口内需要提醒的未完成待办"(#38)。
|
||||||
|
*
|
||||||
|
* 与 queryRemindable(日程)的区别:
|
||||||
|
* - `kind='todo'` 而不是 'event'
|
||||||
|
* - **必须 `completed=0`** —— 已完成的待办不该再响
|
||||||
|
* - 待办的提醒基准同样是 start_time(loadTodos 把 DUE 存进 startTime)
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 单独更新待办的子任务/附件属性(updateTodoFields 只管标题/备注/截止)。
|
||||||
|
* @param touchParent true=写 parent_uid(空串=清空),false=不动
|
||||||
|
* @param touchAttach true=写 attachments(空串=清空),false=不动
|
||||||
|
*/
|
||||||
|
static async setTodoHierarchy(context: common.Context, id: number, parentUid: string,
|
||||||
|
attachments: string, touchParent: boolean, touchAttach: boolean): Promise<void> {
|
||||||
|
if (!touchParent && !touchAttach) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const bucket: relationalStore.ValuesBucket = {};
|
||||||
|
if (touchParent) {
|
||||||
|
(bucket as Record<string, string>)['parent_uid'] = parentUid;
|
||||||
|
}
|
||||||
|
if (touchAttach) {
|
||||||
|
(bucket as Record<string, string>)['attachments'] = attachments;
|
||||||
|
}
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('id', id);
|
||||||
|
await store.update(bucket, predicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单独更新待办的提醒列表(updateTodoFields 只管标题/备注/截止三字段) */
|
||||||
|
static async setTodoReminders(context: common.Context, id: number,
|
||||||
|
reminders: number[]): Promise<void> {
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const bucket: relationalStore.ValuesBucket = {
|
||||||
|
'reminder': reminders.length > 0 ? reminders[0] : 0,
|
||||||
|
'reminders': reminders.map((n: number): string => String(n)).join(',')
|
||||||
|
};
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('id', id);
|
||||||
|
await store.update(bucket, predicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async queryRemindableTodos(context: common.Context, from: number,
|
||||||
|
to: number): Promise<LocalEvent[]> {
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('deleted', 0).and().equalTo('kind', 'todo')
|
||||||
|
.and().equalTo('completed', 0)
|
||||||
|
.and().greaterThan('reminder', 0)
|
||||||
|
.and().greaterThanOrEqualTo('start_time', from)
|
||||||
|
.and().lessThanOrEqualTo('start_time', to)
|
||||||
|
.orderByAsc('start_time').limitAs(50);
|
||||||
|
const rs = await store.query(predicates);
|
||||||
|
const list: LocalEvent[] = [];
|
||||||
|
try {
|
||||||
|
while (rs.goToNextRow()) {
|
||||||
|
list.push(EventDb.fromRow(rs));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rs.close();
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
static async queryRemindable(context: common.Context, from: number, to: number): Promise<LocalEvent[]> {
|
static async queryRemindable(context: common.Context, from: number, to: number): Promise<LocalEvent[]> {
|
||||||
const store = await EventDb.getDb(context);
|
const store = await EventDb.getDb(context);
|
||||||
const predicates = new relationalStore.RdbPredicates('events');
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
@@ -429,14 +651,29 @@ export class EventDb {
|
|||||||
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
|
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
|
||||||
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
|
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
|
||||||
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
|
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
|
||||||
// 语义与 `existing.find(x => !x.dirty && x.uid===r.uid && x.startTime===r.startTime
|
// dirty 行不入 `keyed`、同键取**最先**出现的那行。
|
||||||
// && x.recurrenceId===r.recurrenceId)` 完全一致:dirty 行不入索引、同键取**最先**出现的那行。
|
// ⭐ 但仅排除出 keyed不够(见下方 REVIVE_GUARD):还需单独索引 dirty 行并主动拦截,
|
||||||
|
// 否则远端数据查不到 key 会走新增分支,反而插出重复行。
|
||||||
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
||||||
|
// ⭐ REVIVE_GUARD dirty 行**单独**建索引,而不是只从 keyed 里排除。
|
||||||
|
// 只排除不够:远端数据 keyed 查不到 → 走"新增"分支 → 又插一行,
|
||||||
|
// 本地就出现两行(用户改的 + 远端旧版),表现为"改完又变回去/出现两条"。
|
||||||
|
const dirtyKeyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
||||||
|
// ⭐ 本地已标记删除、还没推给服务器的 uid 集合。
|
||||||
|
// 按 uid 而非 key:删重复系列时本地只留主事件标 deleted,
|
||||||
|
// 但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住。
|
||||||
|
const dirtyDeletedUids: Set<string> = new Set<string>();
|
||||||
for (const e of existing) {
|
for (const e of existing) {
|
||||||
|
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
|
||||||
|
if (e.dirty && e.deleted) {
|
||||||
|
dirtyDeletedUids.add(e.uid);
|
||||||
|
}
|
||||||
if (e.dirty) {
|
if (e.dirty) {
|
||||||
|
if (!dirtyKeyed.has(k)) {
|
||||||
|
dirtyKeyed.set(k, e);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
|
|
||||||
if (!keyed.has(k)) {
|
if (!keyed.has(k)) {
|
||||||
keyed.set(k, e);
|
keyed.set(k, e);
|
||||||
}
|
}
|
||||||
@@ -453,6 +690,14 @@ export class EventDb {
|
|||||||
continue; // 同一资源内的重复条目 → 只保留一条
|
continue; // 同一资源内的重复条目 → 只保留一条
|
||||||
}
|
}
|
||||||
seenKeys.add(key);
|
seenKeys.add(key);
|
||||||
|
// ⭐ REVIVE_GUARD 本地已删待推送 → 远端旧版本一律忽略,防止死灰复燃
|
||||||
|
if (dirtyDeletedUids.has(r.uid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ⭐ 本地有未推送的修改 → 保留本地版本,绝不能让远端旧版覆盖或重复插入
|
||||||
|
if (dirtyKeyed.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const found: LocalEvent | undefined = keyed.get(key);
|
const found: LocalEvent | undefined = keyed.get(key);
|
||||||
if (found === undefined) {
|
if (found === undefined) {
|
||||||
// 新增
|
// 新增
|
||||||
@@ -460,7 +705,8 @@ export class EventDb {
|
|||||||
e.uid = r.uid;
|
e.uid = r.uid;
|
||||||
e.calKey = calKey;
|
e.calKey = calKey;
|
||||||
e.href = href;
|
e.href = href;
|
||||||
e.remotePath = encodeURIComponent(r.uid) + '.ics';
|
// ⭐ REMOTE_PATH 优先用服务器真实文件名;为空时才退回按 uid 猜(本地新建等场景)
|
||||||
|
e.remotePath = r.remotePath !== '' ? r.remotePath : (encodeURIComponent(r.uid) + '.ics');
|
||||||
e.title = r.title;
|
e.title = r.title;
|
||||||
e.description = r.description;
|
e.description = r.description;
|
||||||
e.location = r.location;
|
e.location = r.location;
|
||||||
@@ -478,17 +724,27 @@ export class EventDb {
|
|||||||
e.recurrenceId = r.recurrenceId;
|
e.recurrenceId = r.recurrenceId;
|
||||||
e.reminder = r.reminder;
|
e.reminder = r.reminder;
|
||||||
e.reminders = r.reminders;
|
e.reminders = r.reminders;
|
||||||
|
e.priority = r.priority;
|
||||||
|
e.categories = r.categories;
|
||||||
|
e.parentUid = r.parentUid;
|
||||||
|
e.attachments = r.attachments;
|
||||||
await store.insert('events', EventDb.toBucket(e));
|
await store.insert('events', EventDb.toBucket(e));
|
||||||
added++;
|
added++;
|
||||||
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|
||||||
|| found.reminder !== r.reminder || found.recurring !== r.recurring
|
|| found.reminder !== r.reminder || found.recurring !== r.recurring
|
||||||
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
|
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
|
||||||
|
|| found.priority !== r.priority || found.categories !== r.categories
|
||||||
|
|| found.parentUid !== r.parentUid || found.attachments !== r.attachments
|
||||||
|| found.href !== href) {
|
|| found.href !== href) {
|
||||||
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
|
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
|
||||||
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
|
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
|
||||||
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
|
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
|
||||||
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
|
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
|
||||||
found.href = href;
|
found.href = href;
|
||||||
|
// 服务器上资源被改名时同步更新,否则后续 DELETE/PUT 会打到旧路径
|
||||||
|
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
|
||||||
|
found.remotePath = r.remotePath;
|
||||||
|
}
|
||||||
found.title = r.title;
|
found.title = r.title;
|
||||||
found.description = r.description;
|
found.description = r.description;
|
||||||
found.location = r.location;
|
found.location = r.location;
|
||||||
@@ -506,19 +762,34 @@ export class EventDb {
|
|||||||
found.recurrenceId = r.recurrenceId;
|
found.recurrenceId = r.recurrenceId;
|
||||||
found.reminder = r.reminder;
|
found.reminder = r.reminder;
|
||||||
found.reminders = r.reminders;
|
found.reminders = r.reminders;
|
||||||
|
found.priority = r.priority;
|
||||||
|
found.categories = r.categories;
|
||||||
|
found.parentUid = r.parentUid;
|
||||||
|
found.attachments = r.attachments;
|
||||||
const up = new relationalStore.RdbPredicates('events');
|
const up = new relationalStore.RdbPredicates('events');
|
||||||
up.equalTo('id', found.id);
|
up.equalTo('id', found.id);
|
||||||
await store.update(EventDb.toBucket(found), up);
|
await store.update(EventDb.toBucket(found), up);
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
unchanged++;
|
unchanged++;
|
||||||
// etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中,
|
// ⭐ UNCHANGED_REMOTE_PATH etag 未变是**常态路径**(REPORT 只给 etag,
|
||||||
// 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释)
|
// 未变就直接复用本地行、不 GET)。实测 742 行里 99% 走这里。
|
||||||
|
// 若只在"更新分支"回填 remotePath,对存量数据等于无效 ——
|
||||||
|
// 历史行里存的是旧的 uid 猜测值,而 DELETE/PUT 正是用它拼 URL,
|
||||||
|
// 拼错就 404(服务器上删不掉)。
|
||||||
|
// 所以这里必须一并校正 href 与 remotePath。
|
||||||
|
const hb: relationalStore.ValuesBucket = {};
|
||||||
if (found.href !== href) {
|
if (found.href !== href) {
|
||||||
found.href = href;
|
found.href = href;
|
||||||
|
hb['href'] = href;
|
||||||
|
}
|
||||||
|
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
|
||||||
|
found.remotePath = r.remotePath;
|
||||||
|
hb['remote_path'] = r.remotePath;
|
||||||
|
}
|
||||||
|
if (Object.keys(hb).length > 0) {
|
||||||
const hp = new relationalStore.RdbPredicates('events');
|
const hp = new relationalStore.RdbPredicates('events');
|
||||||
hp.equalTo('id', found.id);
|
hp.equalTo('id', found.id);
|
||||||
const hb: relationalStore.ValuesBucket = { 'href': href };
|
|
||||||
await store.update(hb, hp);
|
await store.update(hb, hp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,22 @@
|
|||||||
// iCalendar 解析与生成
|
// iCalendar 解析与生成
|
||||||
import { LocalEvent, RemoteEvent } from './EventDb';
|
import { LocalEvent, RemoteEvent } from './EventDb';
|
||||||
|
|
||||||
|
/** 解析 ATTACH 行得到的单个附件元数据(Index.ets 弹层也会用到,故 export) */
|
||||||
|
export class AttachMeta {
|
||||||
|
name: string = '';
|
||||||
|
mime: string = '';
|
||||||
|
base64: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
/** 解析出的远端日程(内部中间结构) */
|
/** 解析出的远端日程(内部中间结构) */
|
||||||
class ParsedEvent {
|
class ParsedEvent {
|
||||||
uid: string = '';
|
uid: string = '';
|
||||||
title: string = '';
|
title: string = '';
|
||||||
description: string = '';
|
description: string = '';
|
||||||
|
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
|
||||||
|
priority: number = 0;
|
||||||
|
/** 分类标签,逗号分隔(对应 CATEGORIES) */
|
||||||
|
categories: string = '';
|
||||||
location: string = '';
|
location: string = '';
|
||||||
startTime: number = 0;
|
startTime: number = 0;
|
||||||
endTime: number = 0;
|
endTime: number = 0;
|
||||||
@@ -170,21 +181,669 @@ export class IcsUtil {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 在**服务器原文**上改写某个 VTODO 的完成状态,返回新的整份 ICS。
|
||||||
|
*
|
||||||
|
* 为什么不从本地字段重建:VTODO 上还有 SUMMARY / DESCRIPTION / DUE / PRIORITY /
|
||||||
|
* CATEGORIES / X-* 等大量字段,本地库只保存了其中少数几个。
|
||||||
|
* 若按本地字段重新拼一份 VTODO 再 PUT,等于**把没保存的字段全部删掉**(丢用户数据)。
|
||||||
|
* 因此这里只做"在原文上精确改那几行",其余内容原样保留。
|
||||||
|
*
|
||||||
|
* 改动范围(按 RFC 5545,三者要一起维护,否则各客户端显示不一致):
|
||||||
|
* - `STATUS:COMPLETED` ⇄ `STATUS:NEEDS-ACTION`
|
||||||
|
* - `COMPLETED:<UTC时间>`(完成时写入,取消完成时删除)
|
||||||
|
* - `PERCENT-COMPLETE:100` ⇄ `PERCENT-COMPLETE:0`
|
||||||
|
*
|
||||||
|
* @param ics 服务器上的原始 .ics 全文
|
||||||
|
* @param uid 目标待办的 UID(一个资源里理论上只有一个 VTODO,但仍按 uid 定位更稳)
|
||||||
|
* @param done true=标记完成;false=取消完成
|
||||||
|
* @returns 改写后的完整 ICS(调用方直接 PUT)
|
||||||
|
*/
|
||||||
|
static rewriteTodoCompletion(ics: string, uid: string, done: boolean): string {
|
||||||
|
const crlf: string = '\r\n';
|
||||||
|
// 折行还原(RFC 5545 允许续行,续行以空格/Tab 开头)
|
||||||
|
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||||
|
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
|
||||||
|
const upperOf = (s: string): string => s.toUpperCase();
|
||||||
|
const nowUtc: string = IcsUtil.fmtUtcNow();
|
||||||
|
|
||||||
|
// ---------- 第 1 遍:找出"含目标 uid 的 VTODO"的 [begin, end] 行号区间 ----------
|
||||||
|
// 必须两遍:RFC 5545 不规定字段顺序,UID 可能出现在 STATUS 之后。
|
||||||
|
// 单遍实现会漏改(实测风险:STATUS 在前时 matched 仍为 false → 用户点了没反应)。
|
||||||
|
let targetBegin: number = -1;
|
||||||
|
let targetEnd: number = -1;
|
||||||
|
let targetOrdinal: number = -1; // 目标 VTODO 是第几个块(0 基),供插入段按结构定位
|
||||||
|
let curBegin: number = -1;
|
||||||
|
let curHasUid: boolean = false;
|
||||||
|
let blockIdx: number = -1;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const u: string = upperOf(lines[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
blockIdx++;
|
||||||
|
curBegin = i;
|
||||||
|
curHasUid = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||||
|
if (lines[i].substring(4).trim() === uid) {
|
||||||
|
curHasUid = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO')) {
|
||||||
|
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||||
|
targetBegin = curBegin;
|
||||||
|
targetEnd = i;
|
||||||
|
targetOrdinal = blockIdx;
|
||||||
|
}
|
||||||
|
curBegin = -1;
|
||||||
|
curHasUid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetBegin < 0) {
|
||||||
|
// 找不到目标 uid:原样返回(调用方会 PUT 回去,内容未变,等于无操作)
|
||||||
|
return ics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 第 2 遍:在区间内改写状态字段 ----------
|
||||||
|
const out: string[] = [];
|
||||||
|
let sawStatus: boolean = false;
|
||||||
|
let sawPercent: boolean = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line: string = lines[i];
|
||||||
|
const u: string = upperOf(line);
|
||||||
|
const inTarget: boolean = i > targetBegin && i < targetEnd;
|
||||||
|
|
||||||
|
if (!inTarget) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('STATUS:')) {
|
||||||
|
sawStatus = true;
|
||||||
|
out.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('COMPLETED')) {
|
||||||
|
continue; // 丢弃旧的,完成时下面统一补
|
||||||
|
}
|
||||||
|
if (u.startsWith('PERCENT-COMPLETE')) {
|
||||||
|
sawPercent = true;
|
||||||
|
out.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缺字段则在目标 VTODO 的 END 之前补齐(有的客户端缺 STATUS 就不显示完成态)
|
||||||
|
const extra: string[] = [];
|
||||||
|
if (!sawStatus) {
|
||||||
|
extra.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
extra.push('COMPLETED:' + nowUtc);
|
||||||
|
}
|
||||||
|
if (!sawPercent) {
|
||||||
|
extra.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
|
||||||
|
}
|
||||||
|
if (extra.length === 0) {
|
||||||
|
const plain: string = out.join(crlf);
|
||||||
|
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ 插入点必须按"结构"定位,不能按行号:
|
||||||
|
// 改动可能删掉了旧的 COMPLETED 行,out 与第一遍的 lines 行号已不对齐,
|
||||||
|
// 用行号定位会把字段写到 END:VTODO 之外(实测会落到 END:VCALENDAR 之后,
|
||||||
|
// 结构非法,部分服务端直接 400)。
|
||||||
|
// 这里重新数 VTODO 块,找到"第 targetOrdinal 个"块的 END 行插进去。
|
||||||
|
const result: string[] = [];
|
||||||
|
let vtodoIdx: number = -1; // 当前处于第几个 VTODO 块内(-1=不在块内)
|
||||||
|
let inserted: boolean = false;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const u: string = upperOf(out[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
vtodoIdx++;
|
||||||
|
result.push(out[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO')) {
|
||||||
|
// 走到目标块的 END 之前先补字段
|
||||||
|
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||||
|
for (const ex of extra) {
|
||||||
|
result.push(ex);
|
||||||
|
}
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
// ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 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, priority: number = -1, categories: string = '',
|
||||||
|
location: string = '', reminders: number[] = [], parentUid: string = '',
|
||||||
|
attachments: string = '', touchParent: boolean = false,
|
||||||
|
touchAttach: boolean = false): string {
|
||||||
|
// priority === -1 表示"本次不改优先级";0 表示"清空优先级"。
|
||||||
|
// 用 -1 而不是 0 做哨兵,因为 0 本身是合法语义(无优先级)。
|
||||||
|
const touchPriority: boolean = priority >= 0;
|
||||||
|
const touchCategories: boolean = categories !== '';
|
||||||
|
const touchLocation: boolean = location !== '';
|
||||||
|
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;
|
||||||
|
let inAlarm: boolean = false;
|
||||||
|
let sawPriority: boolean = false;
|
||||||
|
let sawCategories: boolean = false;
|
||||||
|
let sawLocation: 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;
|
||||||
|
}
|
||||||
|
// 提醒:整段丢弃旧 VALARM,末尾按新列表重建(比逐行改 TRIGGER 简单且不会漏 ACTION/DESCRIPTION)
|
||||||
|
if (reminders.length > 0 && (u.startsWith('BEGIN:VALARM') || inAlarm)) {
|
||||||
|
if (u.startsWith('BEGIN:VALARM')) { inAlarm = true; }
|
||||||
|
if (u.startsWith('END:VALARM')) { inAlarm = false; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 父任务:touched 时才改写,否则原样保留(不误删别人写的 RELATED-TO)
|
||||||
|
if (u.startsWith('RELATED-TO')) {
|
||||||
|
if (touchParent) {
|
||||||
|
if (parentUid !== '') {
|
||||||
|
out.push(IcsUtil.parentLine(parentUid));
|
||||||
|
}
|
||||||
|
// parentUid 空 → 删除该行
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 附件:touched 时整段重建(删所有旧 ATTACH,末尾统一写新的)
|
||||||
|
if (u.startsWith('ATTACH')) {
|
||||||
|
if (touchAttach) {
|
||||||
|
continue; // 丢弃旧 ATTACH,末尾统一写
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('PRIORITY') && touchPriority) {
|
||||||
|
sawPriority = true;
|
||||||
|
if (priority >= 1 && priority <= 9) {
|
||||||
|
out.push('PRIORITY:' + priority);
|
||||||
|
}
|
||||||
|
// priority===0 → 清空,不 push(等于删除该行)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('CATEGORIES') && touchCategories) {
|
||||||
|
sawCategories = true;
|
||||||
|
out.push('CATEGORIES:' + IcsUtil.escape(categories));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('LOCATION') && touchLocation) {
|
||||||
|
sawLocation = true;
|
||||||
|
out.push('LOCATION:' + IcsUtil.escape(location));
|
||||||
|
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 (!sawPriority && touchPriority && priority >= 1 && priority <= 9) {
|
||||||
|
extra.push('PRIORITY:' + priority);
|
||||||
|
}
|
||||||
|
if (!sawCategories && touchCategories) {
|
||||||
|
extra.push('CATEGORIES:' + IcsUtil.escape(categories));
|
||||||
|
}
|
||||||
|
if (!sawLocation && touchLocation) {
|
||||||
|
extra.push('LOCATION:' + IcsUtil.escape(location));
|
||||||
|
}
|
||||||
|
if (touchParent && parentUid !== '') {
|
||||||
|
extra.push(IcsUtil.parentLine(parentUid));
|
||||||
|
}
|
||||||
|
if (touchAttach) {
|
||||||
|
const metas: AttachMeta[] = IcsUtil.jsonToAttachMeta(attachments);
|
||||||
|
for (const m of metas) {
|
||||||
|
extra.push(IcsUtil.attachLine(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 提醒整体重建(上面已把旧 VALARM 整段跳过)
|
||||||
|
if (reminders.length > 0) {
|
||||||
|
const alarms: string = IcsUtil.valarms(reminders);
|
||||||
|
if (alarms !== '') {
|
||||||
|
// 去掉末尾的 CRLF(extra 每项会由 join(crlf) 拼接,带尾部换行会多一个空行)
|
||||||
|
let a: string = alarms;
|
||||||
|
while (a.length >= 2 && a.charAt(a.length - 2) === '\r' && a.charAt(a.length - 1) === '\n') {
|
||||||
|
a = a.substring(0, a.length - 2);
|
||||||
|
}
|
||||||
|
extra.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 待办列表(待办为只读展示,不参与推送) */
|
/** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
|
||||||
static parseTodos(ics: string): RemoteEvent[] {
|
static parseTodos(ics: string): RemoteEvent[] {
|
||||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||||
const lines: string[] = unfolded.split(/\r?\n/);
|
const lines: string[] = unfolded.split(/\r?\n/);
|
||||||
const parsed: RemoteEvent[] = [];
|
const parsed: RemoteEvent[] = [];
|
||||||
let current: RemoteEvent | null = null;
|
let current: RemoteEvent | null = null;
|
||||||
|
let curAttaches: AttachMeta[] = [];
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const upper: string = line.toUpperCase();
|
const upper: string = line.toUpperCase();
|
||||||
if (upper.startsWith('BEGIN:VTODO')) {
|
if (upper.startsWith('BEGIN:VTODO')) {
|
||||||
current = new RemoteEvent();
|
current = new RemoteEvent();
|
||||||
current.isTodo = true;
|
current.isTodo = true;
|
||||||
|
curAttaches = [];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (upper.startsWith('END:VTODO')) {
|
if (upper.startsWith('END:VTODO')) {
|
||||||
if (current !== null && current.uid !== '') {
|
if (current !== null && current.uid !== '') {
|
||||||
|
// 附件:把收集到的 AttachMeta 序列化成 JSON 存进 RemoteEvent
|
||||||
|
if (curAttaches.length > 0) {
|
||||||
|
current.attachments = IcsUtil.attachMetaToJson(curAttaches);
|
||||||
|
}
|
||||||
parsed.push(current);
|
parsed.push(current);
|
||||||
}
|
}
|
||||||
current = null;
|
current = null;
|
||||||
@@ -208,9 +867,28 @@ export class IcsUtil {
|
|||||||
current.description = IcsUtil.unescape(value);
|
current.description = IcsUtil.unescape(value);
|
||||||
} else if (propName === 'LOCATION') {
|
} else if (propName === 'LOCATION') {
|
||||||
current.location = IcsUtil.unescape(value);
|
current.location = IcsUtil.unescape(value);
|
||||||
|
} else if (propName === 'PRIORITY') {
|
||||||
|
// iCal 原生值:1=最高 … 9=最低,0/缺失=未设置。直接存,不二次映射。
|
||||||
|
const pv: number = parseInt(value, 10);
|
||||||
|
current.priority = isNaN(pv) ? 0 : pv;
|
||||||
|
} else if (propName === 'CATEGORIES') {
|
||||||
|
// 多标签用逗号分隔;iCal 允许多个 CATEGORIES 行,这里取最后一行即可
|
||||||
|
// (本应用写回时只写一行,读别人的多行时最后一行足够用)
|
||||||
|
current.categories = IcsUtil.unescape(value);
|
||||||
} else if (propName === 'RRULE') {
|
} else if (propName === 'RRULE') {
|
||||||
current.recurring = true;
|
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 isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||||
const t = IcsUtil.parseTime(value, isDateOnly);
|
const t = IcsUtil.parseTime(value, isDateOnly);
|
||||||
if (t !== null) {
|
if (t !== null) {
|
||||||
@@ -223,6 +901,20 @@ export class IcsUtil {
|
|||||||
if (value.toUpperCase() === 'COMPLETED') {
|
if (value.toUpperCase() === 'COMPLETED') {
|
||||||
current.completed = true;
|
current.completed = true;
|
||||||
}
|
}
|
||||||
|
} else if (propName === 'RELATED-TO') {
|
||||||
|
// 父任务 UID。RELTYPE 参数默认 PARENT(本应用的语义);
|
||||||
|
// 其他客户端可能不带参数,值就是父 UID,直接取。
|
||||||
|
current.parentUid = value;
|
||||||
|
} else if (propName === 'ATTACH') {
|
||||||
|
// 只收集内联 base64;URI 形式(ATTACH:http://...)暂不落库(服务器不支持托管,
|
||||||
|
// 且外部链接意义不大)。别人客户端写入的 URI 附件这里跳过、不报错。
|
||||||
|
if (propPart.toUpperCase().includes('ENCODING=BASE64')) {
|
||||||
|
const meta = new AttachMeta();
|
||||||
|
meta.base64 = value;
|
||||||
|
meta.mime = IcsUtil.paramValue(propPart, 'FMTTYPE');
|
||||||
|
meta.name = IcsUtil.paramValue(propPart, 'X-FILENAME');
|
||||||
|
curAttaches.push(meta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
@@ -248,6 +940,161 @@ export class IcsUtil {
|
|||||||
'END:VCALENDAR\r\n';
|
'END:VCALENDAR\r\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从零构造一份 VTODO 资源(新建待办用)。
|
||||||
|
*
|
||||||
|
* @param uid 待办全局唯一标识
|
||||||
|
* @param title 标题
|
||||||
|
* @param desc 备注(可为空,空则不写 DESCRIPTION)
|
||||||
|
* @param dueMs 截止时间毫秒(0 = 无截止时间,不写 DUE)
|
||||||
|
* @param dueIsDateOnly 截止时间是否"只有日期无时刻"(true 写 `DUE;VALUE=DATE`)
|
||||||
|
*/
|
||||||
|
static buildTodo(uid: string, title: string, desc: string, dueMs: number,
|
||||||
|
dueIsDateOnly: boolean, priority: number = 0, categories: string = '',
|
||||||
|
location: string = '', reminders: number[] = [], parentUid: string = '',
|
||||||
|
attachments: string = ''): string {
|
||||||
|
const now: string = IcsUtil.fmtUtcNow();
|
||||||
|
let out: string = 'BEGIN:VCALENDAR\r\n' +
|
||||||
|
'VERSION:2.0\r\n' +
|
||||||
|
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
|
||||||
|
'CALSCALE:GREGORIAN\r\n' +
|
||||||
|
'BEGIN:VTODO\r\n' +
|
||||||
|
`UID:${uid}\r\n` +
|
||||||
|
`DTSTAMP:${now}\r\n` +
|
||||||
|
`SUMMARY:${IcsUtil.escape(title)}\r\n`;
|
||||||
|
if (desc !== '') {
|
||||||
|
out += `DESCRIPTION:${IcsUtil.escape(desc)}\r\n`;
|
||||||
|
}
|
||||||
|
if (dueMs > 0) {
|
||||||
|
if (dueIsDateOnly) {
|
||||||
|
out += `DUE;VALUE=DATE:${IcsUtil.fmtIcsTime(dueMs, true)}\r\n`;
|
||||||
|
} else {
|
||||||
|
out += `DUE:${IcsUtil.fmtIcsTime(dueMs, false)}\r\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED
|
||||||
|
out += 'STATUS:NEEDS-ACTION\r\n';
|
||||||
|
out += IcsUtil.todoExtras(priority, categories, location);
|
||||||
|
if (parentUid !== '') {
|
||||||
|
out += IcsUtil.parentLine(parentUid) + '\r\n';
|
||||||
|
}
|
||||||
|
// 附件:内联 base64,可多条
|
||||||
|
const metas: AttachMeta[] = IcsUtil.jsonToAttachMeta(attachments);
|
||||||
|
for (const m of metas) {
|
||||||
|
out += IcsUtil.attachLine(m) + '\r\n';
|
||||||
|
}
|
||||||
|
// VALARM:VTODO 的 TRIGGER 相对 DUE(RFC 5545 §3.8.6.3),
|
||||||
|
// 所以复用现有 valarm() 生成的 -PT5M 就是"到期前 5 分钟"
|
||||||
|
out += IcsUtil.valarmsPublic(reminders);
|
||||||
|
out += 'END:VTODO\r\n';
|
||||||
|
out += 'END:VCALENDAR\r\n';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 VTODO 的扩展属性行(PRIORITY / CATEGORIES / LOCATION)。
|
||||||
|
*
|
||||||
|
* 抽成方法的原因:新建(buildTodo)与改写(rewriteTodoFields)都要写这三行,
|
||||||
|
* 逻辑必须一致,否则新建的和改过的会生成不同结构。
|
||||||
|
*
|
||||||
|
* @param priority iCal 原生值 1(最高)…9(最低);0 或非法值 = 不写
|
||||||
|
* @param categories 逗号分隔的标签;空 = 不写
|
||||||
|
* @param location 地点文本;空 = 不写
|
||||||
|
*/
|
||||||
|
/** 从属性串里取参数值(如 propPart = "ATTACH;FMTTYPE=text/plain;X-FILENAME=报告.pdf")。 */
|
||||||
|
private static paramValue(propPart: string, paramName: string): string {
|
||||||
|
const parts: string[] = propPart.split(';');
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const seg: string = parts[i];
|
||||||
|
const eq: number = seg.indexOf('=');
|
||||||
|
if (eq > 0 && seg.substring(0, eq).toUpperCase() === paramName.toUpperCase()) {
|
||||||
|
return seg.substring(eq + 1).replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AttachMeta[] → JSON 字符串(存库用)。 */
|
||||||
|
static attachMetaToJson(list: AttachMeta[]): string {
|
||||||
|
if (list.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const arr: string[] = [];
|
||||||
|
for (const a of list) {
|
||||||
|
arr.push(JSON.stringify({ name: a.name, mime: a.mime, base64: a.base64 }));
|
||||||
|
}
|
||||||
|
return '[' + arr.join(',') + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 字符串 → AttachMeta[](解析库值用)。 */
|
||||||
|
static jsonToAttachMeta(json: string): AttachMeta[] {
|
||||||
|
if (json === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(json) as Record<string, string>[];
|
||||||
|
const out: AttachMeta[] = [];
|
||||||
|
for (const o of arr) {
|
||||||
|
const m = new AttachMeta();
|
||||||
|
m.name = o.name ?? '';
|
||||||
|
m.mime = o.mime ?? 'application/octet-stream';
|
||||||
|
m.base64 = o.base64 ?? '';
|
||||||
|
if (m.base64 !== '') {
|
||||||
|
out.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成一条内联 base64 的 ATTACH 行(已折行)。 */
|
||||||
|
private static attachLine(meta: AttachMeta): string {
|
||||||
|
let head: string = 'ATTACH';
|
||||||
|
if (meta.mime !== '') {
|
||||||
|
head += `;FMTTYPE=${meta.mime}`;
|
||||||
|
}
|
||||||
|
head += ';ENCODING=BASE64;VALUE=BINARY';
|
||||||
|
if (meta.name !== '') {
|
||||||
|
// 逗号/分号属性值里要转义;文件名通常是中文或普通字符,稳妥起见转义
|
||||||
|
head += `;X-FILENAME=${meta.name}`;
|
||||||
|
}
|
||||||
|
return IcsUtil.foldLine(head + ':' + meta.base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 RELATED-TO 行(父任务)。 */
|
||||||
|
private static parentLine(parentUid: string): string {
|
||||||
|
return `RELATED-TO;RELTYPE=PARENT:${parentUid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 5545 折行:本工程**有意不折**,直通返回。
|
||||||
|
*
|
||||||
|
* 为什么不做折行:
|
||||||
|
* 1. 现有 SUMMARY/DESCRIPTION/LOCATION/CATEGORIES 一贯整行输出,未做折行;
|
||||||
|
* 2. 目标服务器(群晖)实测 3792 字符整行 ATTACH 也能 PUT→201、GET 原样保留;
|
||||||
|
* 3. 折行是 RFC 5545 的 SHOULD 而非 MUST;一致性优先;
|
||||||
|
* 4. 折行若按 75 字符硬切,会把 UTF-8 中文名(如「报告.pdf」)拦腰切断成乱码。
|
||||||
|
*/
|
||||||
|
static foldLine(line: string): string {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
static todoExtras(priority: number, categories: string, location: string): string {
|
||||||
|
let out: string = '';
|
||||||
|
if (priority >= 1 && priority <= 9) {
|
||||||
|
out += `PRIORITY:${priority}\r\n`;
|
||||||
|
}
|
||||||
|
if (categories !== '') {
|
||||||
|
out += `CATEGORIES:${IcsUtil.escape(categories)}\r\n`;
|
||||||
|
}
|
||||||
|
if (location !== '') {
|
||||||
|
out += `LOCATION:${IcsUtil.escape(location)}\r\n`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。
|
* ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。
|
||||||
*
|
*
|
||||||
@@ -395,6 +1242,11 @@ export class IcsUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
|
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
|
||||||
|
/** 供待办侧复用的公开包装(VTODO 的 VALARM 与 VEVENT 格式相同) */
|
||||||
|
static valarmsPublic(minutes: number[]): string {
|
||||||
|
return IcsUtil.valarms(minutes);
|
||||||
|
}
|
||||||
|
|
||||||
private static valarms(minutes: number[]): string {
|
private static valarms(minutes: number[]): string {
|
||||||
let out: string = '';
|
let out: string = '';
|
||||||
for (const m of minutes) {
|
for (const m of minutes) {
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// entry/src/main/ets/common/PlaceParser.ets
|
||||||
|
// 从剪贴板文本/链接中解析出地点:支持高德分享链接(含坐标)与微信复制的地址文本(无坐标)。
|
||||||
|
//
|
||||||
|
// 支持的四种链接形态(均实测过):
|
||||||
|
// ① poi/detail ?__p=<poiid>,<lat>,<lng>,<name>,<address> —— 坐标在 query
|
||||||
|
// ② share/index /share/index/__q=<lat>,<lng>,<name> —— ⚠️ 坐标在 PATH 不在 query
|
||||||
|
// ③ place /place/<poiid>?lng=<lng>&lat=<lat> —— 显式命名
|
||||||
|
// ④ surl 短链 surl.amap.com/<code> —— 需跟随 302 取 Location
|
||||||
|
//
|
||||||
|
// ⚠️ 坐标系与顺序(最容易搞错的地方):
|
||||||
|
// - 高德所有形态都是「纬度在前、经度在后」,而国内地图复制坐标常是「经度在前」。
|
||||||
|
// 搞反会定位到几百公里外。本解析器统一用合法范围校验纠偏:
|
||||||
|
// 纬度绝对值必然 ≤90,若两值都在 90 内则按国旗惯例「纬在前」处理。
|
||||||
|
// - 微信/高德输出的都是 GCJ-02(火星坐标),与 Site Kit 一致,**不需要坐标转换**。
|
||||||
|
// 只有照片 EXIF 的 WGS-84 才需要转,本解析器不处理该场景。
|
||||||
|
import { http } from '@kit.NetworkKit';
|
||||||
|
import { LogUtil } from './LogUtil';
|
||||||
|
|
||||||
|
/** 解析结果 */
|
||||||
|
export class ParsedPlace {
|
||||||
|
/** 地点名称(可能为空) */
|
||||||
|
name: string = '';
|
||||||
|
/** 详细地址(可能为空) */
|
||||||
|
address: string = '';
|
||||||
|
/** 纬度(0 表示无坐标) */
|
||||||
|
lat: number = 0;
|
||||||
|
/** 经度(0 表示无坐标) */
|
||||||
|
lng: number = 0;
|
||||||
|
/** 是否有有效坐标 */
|
||||||
|
hasCoord: boolean = false;
|
||||||
|
/** 命中方式,便于排查:amap-p / amap-q / amap-place / amap-surl / wechat-text / bare-address */
|
||||||
|
source: string = '';
|
||||||
|
|
||||||
|
/** 用于填进地点栏的文本:有名称用「名称」,否则用地址 */
|
||||||
|
get primaryText(): string {
|
||||||
|
return this.name !== '' ? this.name : this.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaceParser {
|
||||||
|
/** 短链展开超时(毫秒)。网络不好时不值得让用户干等 */
|
||||||
|
private static readonly SHORTLINK_TIMEOUT: number = 5000;
|
||||||
|
/** 最多跟随几次跳转,防止死循环 */
|
||||||
|
private static readonly MAX_REDIRECT: number = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析入口:给一段文本,判断是链接还是地址文本,返回地点信息。
|
||||||
|
* 无法识别时返回 null(调用方应保持原有输入,不阻塞用户)。
|
||||||
|
*/
|
||||||
|
static async parse(text: string, context?: object): Promise<ParsedPlace | null> {
|
||||||
|
const t: string = (text === undefined || text === null) ? '' : text.trim();
|
||||||
|
if (t === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 先尝试按链接解析(含短链展开,需要网络)
|
||||||
|
const fromUrl: ParsedPlace | null = await PlaceParser.parseUrl(t);
|
||||||
|
if (fromUrl !== null) {
|
||||||
|
return fromUrl;
|
||||||
|
}
|
||||||
|
// 再按纯文本地址解析(无需网络)
|
||||||
|
return PlaceParser.parseText(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 链接解析 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裁剪 URL 尾部:只裁"多出来"的右括号与中文标点,保留 URL 内部的合法括号。
|
||||||
|
*
|
||||||
|
* 为什么需要:高德分享链接里的 POI 名称常含未编码的半角括号,
|
||||||
|
* 如 `...青岛菜(黄岛珠江路店)%2C武夷山路...`
|
||||||
|
* 若正则直接排除 ')',就会在第一个 ')' 处截断 → 后面的地址段全丢。
|
||||||
|
* 实测该链接被截到 221/456 字符,__p 从 5 段变成 4 段。
|
||||||
|
*
|
||||||
|
* 做法:从左往右数括号,depth 变负说明这个 ')' 没有对应 '(',
|
||||||
|
* 它不属于 URL → 从这里截断。
|
||||||
|
*/
|
||||||
|
private static trimUrlTail(raw: string): string {
|
||||||
|
let depth: number = 0;
|
||||||
|
let cut: number = raw.length;
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const ch: string = raw.charAt(i);
|
||||||
|
if (ch === '(') {
|
||||||
|
depth++;
|
||||||
|
} else if (ch === ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth < 0) {
|
||||||
|
// 多出来的右括号 → URL 到此为止
|
||||||
|
cut = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let out: string = raw.substring(0, cut);
|
||||||
|
// 再裁掉可能粘在末尾的中文标点(用户手动加的全角句号/逗号等)
|
||||||
|
while (out.length > 0) {
|
||||||
|
const last: string = out.charAt(out.length - 1);
|
||||||
|
if (last === '。' || last === ',' || last === '、' || last === ';' ||
|
||||||
|
last === '!' || last === '?') {
|
||||||
|
out = out.substring(0, out.length - 1);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async parseUrl(text: string): Promise<ParsedPlace | null> {
|
||||||
|
// 从可能夹带其他文字的段落里抽出第一个 http(s) 链接
|
||||||
|
const urlMatch: RegExpExecArray | null = /https?:\/\/[^\s"'<>()【】]+/i.exec(text);
|
||||||
|
if (urlMatch === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url: string = PlaceParser.trimUrlTail(urlMatch[0]);
|
||||||
|
// ① poi/detail: __p=<id>,<lat>,<lng>,<name>,<address...>
|
||||||
|
const p: ParsedPlace | null = PlaceParser.parseAmapP(url);
|
||||||
|
if (p !== null) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
// ② share/index: __q=<lat>,<lng>,<name>(在 path 里)
|
||||||
|
const q: ParsedPlace | null = PlaceParser.parseAmapQ(url);
|
||||||
|
if (q !== null) {
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
// ③ place: ?lng=&lat=
|
||||||
|
const pl: ParsedPlace | null = PlaceParser.parseAmapPlace(url);
|
||||||
|
if (pl !== null) {
|
||||||
|
return pl;
|
||||||
|
}
|
||||||
|
// ④ 短链:跟随 302 取 Location 头里的 q 参数
|
||||||
|
if (/surl\.amap\.com/i.test(url)) {
|
||||||
|
return await PlaceParser.expandShortLink(url);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ① `__p=<poiid>,<lat>,<lng>,<name>,<address>`
|
||||||
|
*
|
||||||
|
* ⚠️ 地址段可能自身含逗号(如「XX路1号,2楼」),所以:
|
||||||
|
* 只取前 4 段,第 5 段及之后**全部拼回**地址,避免 split 切错。
|
||||||
|
*/
|
||||||
|
private static parseAmapP(url: string): ParsedPlace | null {
|
||||||
|
const m: RegExpExecArray | null = /[?&]__p=([^&]+)/i.exec(url);
|
||||||
|
if (m === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let raw: string = '';
|
||||||
|
try {
|
||||||
|
raw = decodeURIComponent(m[1]);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segs: string[] = raw.split(',');
|
||||||
|
if (segs.length < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lat: number = parseFloat(segs[1]);
|
||||||
|
const lng: number = parseFloat(segs[2]);
|
||||||
|
const out = new ParsedPlace();
|
||||||
|
out.source = 'amap-p';
|
||||||
|
out.name = segs.length > 3 ? segs[3].trim() : '';
|
||||||
|
// 地址 = 第 4 段之后全部拼回(含逗号的情况)
|
||||||
|
if (segs.length > 4) {
|
||||||
|
out.address = segs.slice(4).join(',').trim();
|
||||||
|
}
|
||||||
|
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
|
||||||
|
if (fixed[0] !== 0 || fixed[1] !== 0) {
|
||||||
|
out.lat = fixed[0];
|
||||||
|
out.lng = fixed[1];
|
||||||
|
out.hasCoord = true;
|
||||||
|
}
|
||||||
|
return out.primaryText !== '' ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ② `share/index/__q=<lat>,<lng>,<name>`
|
||||||
|
*
|
||||||
|
* ⚠️ 这是唯一坐标藏在 **path** 而不是 query 里的形态。
|
||||||
|
* 只解析 query 会漏掉它,必须先看 path。
|
||||||
|
*/
|
||||||
|
private static parseAmapQ(url: string): ParsedPlace | null {
|
||||||
|
const m: RegExpExecArray | null = /__q=([^&]+)/i.exec(url);
|
||||||
|
if (m === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let raw: string = '';
|
||||||
|
try {
|
||||||
|
raw = decodeURIComponent(m[1]);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segs: string[] = raw.split(',');
|
||||||
|
if (segs.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lat: number = parseFloat(segs[0]);
|
||||||
|
const lng: number = parseFloat(segs[1]);
|
||||||
|
const out = new ParsedPlace();
|
||||||
|
out.source = 'amap-q';
|
||||||
|
if (segs.length > 2) {
|
||||||
|
out.name = segs.slice(2).join(',').trim();
|
||||||
|
}
|
||||||
|
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
|
||||||
|
if (fixed[0] !== 0 || fixed[1] !== 0) {
|
||||||
|
out.lat = fixed[0];
|
||||||
|
out.lng = fixed[1];
|
||||||
|
out.hasCoord = true;
|
||||||
|
}
|
||||||
|
return out.primaryText !== '' ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ③ `/place/<poiid>?lng=<lng>&lat=<lat>` —— 显式命名,不存在顺序问题 */
|
||||||
|
private static parseAmapPlace(url: string): ParsedPlace | null {
|
||||||
|
const mlng: RegExpExecArray | null = /[?&]lng=([-\d.]+)/i.exec(url);
|
||||||
|
const mlat: RegExpExecArray | null = /[?&]lat=([-\d.]+)/i.exec(url);
|
||||||
|
if (mlng === null || mlat === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lng: number = parseFloat(mlng[1]);
|
||||||
|
const lat: number = parseFloat(mlat[1]);
|
||||||
|
const out = new ParsedPlace();
|
||||||
|
out.source = 'amap-place';
|
||||||
|
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
|
||||||
|
if (fixed[0] === 0 && fixed[1] === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
out.lat = fixed[0];
|
||||||
|
out.lng = fixed[1];
|
||||||
|
out.hasCoord = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ④ 短链展开:surl.amap.com/xxx
|
||||||
|
*
|
||||||
|
* 实测第一次 302 的 Location 就带完整信息:
|
||||||
|
* https://wb.amap.com/?q=32.56132,108.50452,紫阳县明树茶厂
|
||||||
|
* 所以不需要跟到底,拿到第一个 Location 即可,省流量也省时间。
|
||||||
|
*/
|
||||||
|
private static async expandShortLink(url: string): Promise<ParsedPlace | null> {
|
||||||
|
let cur: string = url;
|
||||||
|
for (let i = 0; i < PlaceParser.MAX_REDIRECT; i++) {
|
||||||
|
const loc: string = await PlaceParser.headLocation(cur);
|
||||||
|
if (loc === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Location 里可能直接带 q=<lat>,<lng>,<name>
|
||||||
|
const m: RegExpExecArray | null = /[?&]q=([^&]+)/i.exec(loc);
|
||||||
|
if (m !== null) {
|
||||||
|
let raw: string = '';
|
||||||
|
try {
|
||||||
|
raw = decodeURIComponent(m[1]);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segs: string[] = raw.split(',');
|
||||||
|
if (segs.length >= 2) {
|
||||||
|
const lat: number = parseFloat(segs[0]);
|
||||||
|
const lng: number = parseFloat(segs[1]);
|
||||||
|
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
|
||||||
|
if (fixed[0] !== 0 || fixed[1] !== 0) {
|
||||||
|
const out = new ParsedPlace();
|
||||||
|
out.source = 'amap-surl';
|
||||||
|
out.lat = fixed[0];
|
||||||
|
out.lng = fixed[1];
|
||||||
|
out.hasCoord = true;
|
||||||
|
if (segs.length > 2) {
|
||||||
|
out.name = segs.slice(2).join(',').trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 还没到目标,继续跟
|
||||||
|
if (!/^https?:/i.test(loc)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cur = loc;
|
||||||
|
// 中途可能变成 m.amap.com/share/index/__q=... 形态
|
||||||
|
const q: ParsedPlace | null = PlaceParser.parseAmapQ(cur);
|
||||||
|
if (q !== null) {
|
||||||
|
q.source = 'amap-surl';
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发 HEAD 请求取 Location 头(不下载响应体)。失败返回空串 */
|
||||||
|
private static async headLocation(url: string): Promise<string> {
|
||||||
|
const req = http.createHttp();
|
||||||
|
try {
|
||||||
|
const resp: http.HttpResponse = await req.request(url, {
|
||||||
|
method: http.RequestMethod.GET,
|
||||||
|
header: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36' },
|
||||||
|
connectTimeout: PlaceParser.SHORTLINK_TIMEOUT,
|
||||||
|
readTimeout: PlaceParser.SHORTLINK_TIMEOUT,
|
||||||
|
// 关键:不自动跟随跳转,手动读 Location,避免下载整个落地页
|
||||||
|
usingProtocol: http.HttpProtocol.HTTP1_1
|
||||||
|
});
|
||||||
|
const headers = resp.header as Record<string, string>;
|
||||||
|
if (headers === undefined || headers === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const loc: string = headers['Location'] ?? headers['location'] ?? '';
|
||||||
|
return typeof loc === 'string' ? loc : '';
|
||||||
|
} catch (err) {
|
||||||
|
LogUtil.write(`短链展开失败: ${url}`);
|
||||||
|
return '';
|
||||||
|
} finally {
|
||||||
|
req.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 坐标纠偏 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校正经纬度顺序。
|
||||||
|
*
|
||||||
|
* 规则(依据实测与地理常识):
|
||||||
|
* - 纬度绝对值必然 ≤ 90,经度可以到 180
|
||||||
|
* - 若第一个值绝对值 > 90 → 第一个其实是经度,交换
|
||||||
|
* - 若两个都在 90 内 → 按高德惯例「纬在前」,不交换
|
||||||
|
* - 任一为 0 或非数字 → 视为无坐标(返回 [0,0])
|
||||||
|
*
|
||||||
|
* @returns [lat, lng];无效时返回 [0, 0]
|
||||||
|
*/
|
||||||
|
private static fixLatLng(a: number, b: number): number[] {
|
||||||
|
const invalid: number[] = [0, 0];
|
||||||
|
if (isNaN(a) || isNaN(b) || (a === 0 && b === 0)) {
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
let lat: number = a;
|
||||||
|
let lng: number = b;
|
||||||
|
if (Math.abs(a) > 90 && Math.abs(b) <= 90) {
|
||||||
|
// 第一个是经度,交换
|
||||||
|
lat = b;
|
||||||
|
lng = a;
|
||||||
|
}
|
||||||
|
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) {
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
return [lat, lng];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 纯文本地址解析 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析微信「复制地址」出来的纯文本。
|
||||||
|
*
|
||||||
|
* 实测格式:`<名称>(<分店名>) <空格> <省市区+门牌>`
|
||||||
|
* 例:招商银行(青岛经济技术开发区支行) 山东省青岛市黄岛区珠江路1-3楼1018号一楼
|
||||||
|
*
|
||||||
|
* 兼容性考虑:
|
||||||
|
* - 分隔符可能是空格、全角空格、换行
|
||||||
|
* - 括号可能是半角 () 或全角()
|
||||||
|
* - 可能只有一个名称没有地址
|
||||||
|
* - 可能只有地址没有名称
|
||||||
|
*
|
||||||
|
* 策略:**名称优先**。因为微信拼的地址可能有问题(实测「珠江路1-3楼1018号」
|
||||||
|
* 把楼层塞进了门牌号,检索命中率低),而名称检索几乎必中。
|
||||||
|
*/
|
||||||
|
private static parseText(text: string): ParsedPlace | null {
|
||||||
|
// 去掉 URL 残留与多余空白
|
||||||
|
let t: string = text.replace(/\s+/g, ' ').trim();
|
||||||
|
if (t === '' || t.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const out = new ParsedPlace();
|
||||||
|
out.source = 'wechat-text';
|
||||||
|
|
||||||
|
// 情况一:名称(分店) 空格 地址 —— 括号后跟空格是最可靠的切分点
|
||||||
|
const m1: RegExpExecArray | null = /^(.+?[))])\s*(.+)$/.exec(t);
|
||||||
|
if (m1 !== null) {
|
||||||
|
out.name = m1[1].trim();
|
||||||
|
out.address = m1[2].trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// 情况二:名称 + 空格 + 行政区开头(省/市/区/县/路/街/号…)
|
||||||
|
const m2: RegExpExecArray | null = /^(.+?)\s+((?:中国)?[省市区县].+)$/.exec(t);
|
||||||
|
if (m2 !== null) {
|
||||||
|
out.name = m2[1].trim();
|
||||||
|
out.address = m2[2].trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// 情况三:只有名称,或只有地址
|
||||||
|
if (/^(?:中国)?[省市区县]/.test(t) || /[路街号弄巷栋楼室]/.test(t)) {
|
||||||
|
out.address = t;
|
||||||
|
} else {
|
||||||
|
out.name = t;
|
||||||
|
}
|
||||||
|
return out.primaryText !== '' ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工具 ====================
|
||||||
|
|
||||||
|
/** 反向地理编码后的展示文本:名称 | 地址 */
|
||||||
|
static mergeForLocationText(name: string, address: string): string {
|
||||||
|
if (name !== '' && address !== '' && name !== address) {
|
||||||
|
return `${name}|${address}`;
|
||||||
|
}
|
||||||
|
return name !== '' ? name : address;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,8 +161,13 @@ export class ReminderService {
|
|||||||
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
|
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
|
||||||
}
|
}
|
||||||
const now: number = Date.now();
|
const now: number = Date.now();
|
||||||
const rows: LocalEvent[] =
|
// ⭐ #38 日程 + 待办一起排提醒。两类分别查(过滤条件不同:
|
||||||
|
// 待办要求 completed=0,日程无此概念),合并后统一处理。
|
||||||
|
const eventRows: LocalEvent[] =
|
||||||
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
||||||
|
const todoRows: LocalEvent[] =
|
||||||
|
await EventDb.queryRemindableTodos(context, now, now + ReminderService.WINDOW);
|
||||||
|
const rows: LocalEvent[] = eventRows.concat(todoRows);
|
||||||
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
|
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
|
||||||
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
||||||
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
|
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
|
||||||
@@ -193,6 +198,8 @@ export class ReminderService {
|
|||||||
}
|
}
|
||||||
const d = new Date(remindAt);
|
const d = new Date(remindAt);
|
||||||
const calName: string = await ReminderService.calName(context, e.calKey);
|
const calName: string = await ReminderService.calName(context, e.calKey);
|
||||||
|
// #38 文案按类型区分:待办是"到期",日程是"开始"
|
||||||
|
const isTodo: boolean = e.kind === 'todo';
|
||||||
const request: reminderAgentManager.ReminderRequestCalendar = {
|
const request: reminderAgentManager.ReminderRequestCalendar = {
|
||||||
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
|
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
|
||||||
dateTime: {
|
dateTime: {
|
||||||
@@ -203,10 +210,14 @@ export class ReminderService {
|
|||||||
minute: d.getMinutes(),
|
minute: d.getMinutes(),
|
||||||
second: 0
|
second: 0
|
||||||
},
|
},
|
||||||
title: '日程提醒',
|
title: isTodo ? '待办提醒' : '日程提醒',
|
||||||
content: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
|
content: isTodo
|
||||||
|
? `「${calName}」中的待办「${e.title}」将在 ${rem} 分钟后到期`
|
||||||
|
: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
|
||||||
|
// ⚠️ 通知 id 必须唯一:日程与待办的 id 来自同一张表(自增主键),
|
||||||
|
// 天然不冲突,所以这里不用额外加偏移,否则会与 rem 的乘加混淆。
|
||||||
notificationId: (e.id * 10 + rem) % 100000,
|
notificationId: (e.id * 10 + rem) % 100000,
|
||||||
expiredContent: '该日程已开始',
|
expiredContent: isTodo ? '该待办已到期' : '该日程已开始',
|
||||||
ringDuration: 5,
|
ringDuration: 5,
|
||||||
snoozeTimes: 0,
|
snoozeTimes: 0,
|
||||||
timeInterval: 0
|
timeInterval: 0
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ export class SyncEngine {
|
|||||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
|
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
|
||||||
const t1: number = Date.now();
|
const t1: number = Date.now();
|
||||||
|
// ⭐ 纯待办本(COMP_SET 只有 VTODO)不跑 event 通道。
|
||||||
|
// 实测群晖:对纯待办本做 event 的 REPORT 会返回文件列表,
|
||||||
|
// 里面其实是 VTODO 文件,被 parseEvents 解析成 0 条日程却仍落库
|
||||||
|
// → 本地库出现 kind='event' 记录,而所在本是待办本。
|
||||||
|
// 后果:这条"假日程"在待办列表看不到(列表只查 kind='todo'),
|
||||||
|
// 但点它编辑会走日程通道,报"需要联网 + 日历本需可写"。
|
||||||
|
const thisComps: string = i < acc.calendarComps.length ? acc.calendarComps[i] : '';
|
||||||
|
if (thisComps === 'todo') {
|
||||||
|
LogUtil.write(`日历本[${i}]「${calName}」是纯待办本(COMP_SET=todo),跳过 event 通道`);
|
||||||
|
} else {
|
||||||
let items: RemoteItem[] = [];
|
let items: RemoteItem[] = [];
|
||||||
try {
|
try {
|
||||||
items = await DavClient.reportEtags(href, auth);
|
items = await DavClient.reportEtags(href, auth);
|
||||||
@@ -173,6 +183,36 @@ export class SyncEngine {
|
|||||||
throw err as Error;
|
throw err as Error;
|
||||||
}
|
}
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
|
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)
|
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
||||||
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||||
const byUid: Map<string, LocalEvent[]> = new Map();
|
const byUid: Map<string, LocalEvent[]> = new Map();
|
||||||
@@ -214,8 +254,9 @@ export class SyncEngine {
|
|||||||
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
|
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
|
||||||
&& group[0].etag === it.etag) {
|
&& group[0].etag === it.etag) {
|
||||||
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
||||||
|
const rp: string = it.href.substring(it.href.lastIndexOf('/') + 1);
|
||||||
for (const row of group) {
|
for (const row of group) {
|
||||||
remote.push(SyncEngine.rowToRemote(row, it.etag));
|
remote.push(SyncEngine.rowToRemote(row, it.etag, rp));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -234,6 +275,12 @@ export class SyncEngine {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
||||||
|
// ⭐ REMOTE_PATH 服务器真实资源文件名(href 末段,已含 URL 编码)。
|
||||||
|
// 绝不能用 `encodeURIComponent(uid) + '.ics'` 去猜:
|
||||||
|
// 家书 CalDav.php 用 preg_replace('/[^A-Za-z0-9._-]/','_',uid) 生成文件名,
|
||||||
|
// UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics`。
|
||||||
|
// 猜错 ⇒ DELETE/PUT 打到不存在的路径 ⇒ 404,服务器上删不掉、或写出重复资源。
|
||||||
|
const fileName: string = it.href.substring(it.href.lastIndexOf('/') + 1);
|
||||||
const parsed: RemoteEvent[] = IcsUtil.parse(raw);
|
const parsed: RemoteEvent[] = IcsUtil.parse(raw);
|
||||||
const out: RemoteEvent[] = [];
|
const out: RemoteEvent[] = [];
|
||||||
for (const r of parsed) {
|
for (const r of parsed) {
|
||||||
@@ -243,6 +290,7 @@ export class SyncEngine {
|
|||||||
if (r.uid === '') {
|
if (r.uid === '') {
|
||||||
r.uid = resUid;
|
r.uid = resUid;
|
||||||
}
|
}
|
||||||
|
r.remotePath = fileName;
|
||||||
r.etag = it.etag;
|
r.etag = it.etag;
|
||||||
out.push(r);
|
out.push(r);
|
||||||
}
|
}
|
||||||
@@ -269,6 +317,8 @@ export class SyncEngine {
|
|||||||
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
|
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
|
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
|
||||||
changed += remote.length;
|
changed += remote.length;
|
||||||
|
} // end else(非纯待办本才跑 event 通道)
|
||||||
|
|
||||||
// 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步)
|
// 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步)
|
||||||
try {
|
try {
|
||||||
const todoItems = await DavClient.reportTodos(href, auth);
|
const todoItems = await DavClient.reportTodos(href, auth);
|
||||||
@@ -476,10 +526,18 @@ export class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 本地行 → 远端事件(etag 未变的资源复用本地完整数据,含 reminder) */
|
/** 本地行 → 远端事件(etag 未变的资源复用本地完整数据,含 reminder) */
|
||||||
private static rowToRemote(e: LocalEvent, etag: string): RemoteEvent {
|
/**
|
||||||
|
* ⭐ REMOTE_PATH 第三个参数是当前 REPORT 返回的真实资源文件名(href 末段)。
|
||||||
|
*
|
||||||
|
* 为什么必须从外面传进来:本地行的 remotePath 可能是历史数据用 uid 猜出来的,
|
||||||
|
* 也可能是空的(本地新建后还没同步成功)。而 REPORT 的 href 反映的是
|
||||||
|
* **此刻服务器上的真实文件名**,比本地任何值都可靠。
|
||||||
|
*/
|
||||||
|
private static rowToRemote(e: LocalEvent, etag: string, remotePath: string): RemoteEvent {
|
||||||
const r = new RemoteEvent();
|
const r = new RemoteEvent();
|
||||||
r.uid = e.uid;
|
r.uid = e.uid;
|
||||||
r.etag = etag;
|
r.etag = etag;
|
||||||
|
r.remotePath = remotePath !== '' ? remotePath : e.remotePath;
|
||||||
r.title = e.title;
|
r.title = e.title;
|
||||||
r.description = e.description;
|
r.description = e.description;
|
||||||
r.location = e.location;
|
r.location = e.location;
|
||||||
@@ -534,7 +592,27 @@ export class SyncEngine {
|
|||||||
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
||||||
try {
|
try {
|
||||||
if (e.deleted) {
|
if (e.deleted) {
|
||||||
|
// ⭐ DELETE_404_PURGE 单条路径:deleteRemote 内部已把 404 视作成功,
|
||||||
|
// 这里再显式兜一层 410(Gone),并保证无论哪种"远端已无"都清本地。
|
||||||
|
let gone: boolean = false;
|
||||||
|
try {
|
||||||
await DavClient.deleteRemote(url, auth);
|
await DavClient.deleteRemote(url, auth);
|
||||||
|
gone = true;
|
||||||
|
} catch (delErr) {
|
||||||
|
if (delErr instanceof DavHttpError) {
|
||||||
|
const st: number = (delErr as DavHttpError).status;
|
||||||
|
if (st === 401) {
|
||||||
|
throw delErr as Error;
|
||||||
|
}
|
||||||
|
if (st === 404 || st === 410) {
|
||||||
|
gone = true;
|
||||||
|
LogUtil.write(`单条资源在服务器上已不存在(${st}),直接清理本地:uid=${e.uid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!gone) {
|
||||||
|
throw delErr as Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
await EventDb.purge(context, e.id);
|
await EventDb.purge(context, e.id);
|
||||||
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
|
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -586,15 +664,30 @@ export class SyncEngine {
|
|||||||
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
|
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
|
||||||
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
|
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
|
||||||
if (master.deleted) {
|
if (master.deleted) {
|
||||||
|
// ⭐ DELETE_404_PURGE 404/410 = 服务器上本就没有这个资源。
|
||||||
|
// 必须当成"删除成功"继续往下清本地,不能 return ——
|
||||||
|
// 否则本地行永远留着,用户看到"删不掉的日程",且每次同步都重复失败。
|
||||||
|
// 典型诱因:remotePath 用 uid 猜错(服务器真实文件名不同)⇒ DELETE 打到不存在的路径。
|
||||||
|
let remoteGone: boolean = false;
|
||||||
try {
|
try {
|
||||||
await DavClient.deleteRemote(url, auth);
|
await DavClient.deleteRemote(url, auth);
|
||||||
|
remoteGone = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
|
if (err instanceof DavHttpError) {
|
||||||
|
const st: number = (err as DavHttpError).status;
|
||||||
|
if (st === 401) {
|
||||||
throw err as Error;
|
throw err as Error;
|
||||||
}
|
}
|
||||||
LogUtil.write(`推送删除重复系列失败:uid=${uid} → ${(err as Error).message}`);
|
if (st === 404 || st === 410) {
|
||||||
|
remoteGone = true;
|
||||||
|
LogUtil.write(`重复系列资源在服务器上已不存在(${st}),直接清理本地:uid=${uid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!remoteGone) {
|
||||||
|
LogUtil.write(`推送删除重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await EventDb.purgeByUid(context, calKey, uid);
|
await EventDb.purgeByUid(context, calKey, uid);
|
||||||
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);
|
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -795,10 +795,13 @@ export class SystemCalendarMirror {
|
|||||||
/**
|
/**
|
||||||
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
|
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
|
||||||
*
|
*
|
||||||
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与
|
* 三种情况(0.1.1 起,配合"卸载残留"问题):
|
||||||
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。
|
* ① **还有账号** → 只清"accId 不在当前账号集合里"的 —— 原逻辑,精准不误伤。
|
||||||
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。
|
* ② **账号库为空、且确认是真空**(`isEmptyVaultConfirmed()`)→ **全部清掉**。
|
||||||
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。
|
* 这是卸载重装 / 用户重置账号库后的场景:旧镜像账户的 accId 不可能再匹配上,
|
||||||
|
* 留着只会变成系统日历里永远删不掉的幽灵本,还会继续提醒。
|
||||||
|
* ③ **账号库为空、但拿不准**(读取异常 / 密文解不开)→ **仍然跳过**。
|
||||||
|
* `loadAll()` 会把异常吞掉返回 `[]`,若不区分就会把用户真实在用的镜像全删了。
|
||||||
*
|
*
|
||||||
* @returns 删除的账户数
|
* @returns 删除的账户数
|
||||||
*/
|
*/
|
||||||
@@ -812,8 +815,14 @@ export class SystemCalendarMirror {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return 0; // 读失败一律不动
|
return 0; // 读失败一律不动
|
||||||
}
|
}
|
||||||
|
let wipeAll: boolean = false;
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
return 0; // 安全阀:见注释
|
// 空账号库:只有"确认空白"才敢全清,否则保守跳过(见上方 ③)
|
||||||
|
wipeAll = await AccountStore.isEmptyVaultConfirmed(context);
|
||||||
|
if (!wipeAll) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
LogUtil.write('兜底清理孤儿镜像:账号库确认为空 → 清理全部镜像账户(卸载/重置后残留)');
|
||||||
}
|
}
|
||||||
const validAccIds: Set<string> = new Set<string>();
|
const validAccIds: Set<string> = new Set<string>();
|
||||||
for (const a of accounts) {
|
for (const a of accounts) {
|
||||||
@@ -838,11 +847,13 @@ export class SystemCalendarMirror {
|
|||||||
} else {
|
} else {
|
||||||
sep = dash >= 0 ? dash : under;
|
sep = dash >= 0 ? dash : under;
|
||||||
}
|
}
|
||||||
if (sep <= 0) {
|
// 非全清模式:必须能解析出 accId 才能比对,解析不出就跳过(宁可留,不误删)
|
||||||
continue; // 解析不出 accId → 不动(宁可留,不误删)
|
// 全清模式:前缀已命中就是本应用建的,脏账户(解析不出 accId)也要删干净
|
||||||
|
if (!wipeAll && sep <= 0) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const accId: string = bucket.substring(0, sep);
|
const accId: string = sep > 0 ? bucket.substring(0, sep) : bucket;
|
||||||
if (validAccIds.has(accId)) {
|
if (!wipeAll && validAccIds.has(accId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export class DayTimeline {
|
|||||||
allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前)
|
allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前)
|
||||||
blocks: TimelineBlock[] = []; // 时间轴色块
|
blocks: TimelineBlock[] = []; // 时间轴色块
|
||||||
hasTimed: boolean = false; // 当天是否有有时间日程
|
hasTimed: boolean = false; // 当天是否有有时间日程
|
||||||
|
/** 当天截止、尚未完成的待办(画在时间轴顶部、全天区之上,带勾选框) */
|
||||||
|
todos: DisplayEvent[] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */
|
/** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
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, priority: number = -1, categories: string = '',
|
||||||
|
location: string = '', reminders: number[] = [], parentUid: string = '',
|
||||||
|
attachments: string = '', touchParent: boolean = false,
|
||||||
|
touchAttach: boolean = false): Promise<boolean> {
|
||||||
|
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||||
|
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,
|
||||||
|
priority, categories, location, reminders, parentUid, attachments,
|
||||||
|
touchParent, touchAttach);
|
||||||
|
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||||
|
await EventDb.updateTodoFields(context, localId, title, desc,
|
||||||
|
dueMs > 0 ? dueMs : 0, newEtag);
|
||||||
|
// 提醒单独落库(updateTodoFields 只处理三个字段)
|
||||||
|
await EventDb.setTodoReminders(context, localId, reminders);
|
||||||
|
// 子任务与附件:只有 touch 时才更新本地库(否则保留原值,避免覆盖)
|
||||||
|
if (touchParent || touchAttach) {
|
||||||
|
await EventDb.setTodoHierarchy(context, localId, parentUid, attachments,
|
||||||
|
touchParent, touchAttach);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建待办:生成 VTODO → PUT 到服务器 → 落本地库。
|
||||||
|
*
|
||||||
|
* @param href 目标日历本的集合 URL
|
||||||
|
* @param title 标题(调用方保证非空)
|
||||||
|
* @param desc 备注(可为空串)
|
||||||
|
* @param dueMs 截止时间毫秒(0 = 无截止)
|
||||||
|
* @param dueIsDateOnly 截止时间是否只到日(true 写 DUE;VALUE=DATE)
|
||||||
|
* @returns 成功返回新行 id(>0),失败返回 0
|
||||||
|
*/
|
||||||
|
static async createTodo(context: common.Context, href: string, title: string,
|
||||||
|
desc: string, dueMs: number, dueIsDateOnly: boolean, priority: number = 0,
|
||||||
|
categories: string = '', location: string = '',
|
||||||
|
reminders: number[] = [], parentUid: string = '',
|
||||||
|
attachments: string = ''): Promise<number> {
|
||||||
|
const t: string = title.trim();
|
||||||
|
if (t === '' || href === '') {
|
||||||
|
LogUtil.write(`待办新建失败:标题或日历本为空`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 1) 找到该 href 所属账号
|
||||||
|
const acc: DavAccount | null = await TodoService.findAccount(context, href);
|
||||||
|
if (acc === null) {
|
||||||
|
LogUtil.write(`待办新建失败:找不到所属账号 href=${href}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 2) 只读日历本提前拦下(否则必然 403)
|
||||||
|
const idx: number = acc.calendarHrefs.indexOf(href);
|
||||||
|
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
|
||||||
|
LogUtil.write(`待办新建失败:该日历本只读 href=${href}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 生成 uid 与资源文件名
|
||||||
|
// 格式与事件侧一致:todo-<毫秒时间戳>-<随机6位>,足够避免撞车
|
||||||
|
const stamp: number = Date.now();
|
||||||
|
const rnd: string = Math.random().toString(36).substring(2, 8);
|
||||||
|
const uid: string = `todo-${stamp}-${rnd}`;
|
||||||
|
const fileName: string = `${uid}.ics`;
|
||||||
|
const url: string = href.endsWith('/') ? href + fileName : `${href}/${fileName}`;
|
||||||
|
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4) 先推服务器:成功才落库,避免产生"本地有、服务器没有"的孤儿
|
||||||
|
const ics: string = IcsUtil.buildTodo(uid, t, desc, dueMs, dueIsDateOnly,
|
||||||
|
priority, categories, location, reminders, parentUid, attachments);
|
||||||
|
const etag: string = await DavClient.putEvent(url, auth, ics);
|
||||||
|
|
||||||
|
// 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送)
|
||||||
|
const e: LocalEvent = new LocalEvent();
|
||||||
|
e.uid = uid;
|
||||||
|
e.calKey = `${acc.id}_${idx}`;
|
||||||
|
e.href = href;
|
||||||
|
e.remotePath = fileName;
|
||||||
|
e.etag = etag;
|
||||||
|
e.kind = 'todo';
|
||||||
|
e.title = t;
|
||||||
|
e.description = desc;
|
||||||
|
e.startTime = dueMs > 0 ? dueMs : 0; // 待办的"开始"即截止(与 loadTodos 口径一致)
|
||||||
|
e.endTime = dueMs > 0 ? dueMs : 0;
|
||||||
|
e.isAllDay = dueIsDateOnly;
|
||||||
|
e.completed = false;
|
||||||
|
e.priority = priority;
|
||||||
|
e.categories = categories;
|
||||||
|
e.location = location;
|
||||||
|
e.reminders = reminders;
|
||||||
|
e.reminder = reminders.length > 0 ? reminders[0] : 0;
|
||||||
|
e.parentUid = parentUid;
|
||||||
|
e.attachments = attachments;
|
||||||
|
e.dirty = false; // 已经推成功了,无需再推
|
||||||
|
e.deleted = false;
|
||||||
|
const newId: number = await EventDb.insertLocal(context, e);
|
||||||
|
// insertLocal 强制置 dirty=true,这里立刻清掉(内容已在服务器上了)
|
||||||
|
await EventDb.clearDirty(context, newId, etag);
|
||||||
|
LogUtil.write(`待办新建成功 id=${newId}(标题已脱敏)→ ${fileName}`);
|
||||||
|
return newId;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DavHttpError) {
|
||||||
|
LogUtil.write(`待办新建失败 HTTP ${(err as DavHttpError).status}(标题已脱敏)`);
|
||||||
|
} else {
|
||||||
|
LogUtil.write(`待办新建失败 ${(err as BusinessError).message ?? ''}`);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除待办(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 级联完成:把 parentUid 指向某父任务的所有**未完成**子任务全部置完成。
|
||||||
|
*
|
||||||
|
* 用于父任务勾选完成时。只完成未完成的(已完成的跳过,幂等)。
|
||||||
|
* 逐条调用 setCompleted(它本身幂等 + 会 GET/PUT 服务器 + 更新本地)。
|
||||||
|
*/
|
||||||
|
static async completeChildren(context: common.Context, parentUid: string): Promise<number> {
|
||||||
|
if (parentUid === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const rows: LocalEvent[] = await EventDb.queryTodos(context);
|
||||||
|
let done: number = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.parentUid === parentUid && !r.completed) {
|
||||||
|
const ok: boolean = await TodoService.setCompleted(context, r.id, true);
|
||||||
|
if (ok) {
|
||||||
|
done++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (done > 0) {
|
||||||
|
LogUtil.write(`级联完成 ${done} 个子任务(父 ${parentUid})`);
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async setCompleted(context: common.Context, localId: number, done: boolean): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,10 +42,12 @@ export default class EntryAbility extends UIAbility {
|
|||||||
private handleWidgetParam(want: Want): void {
|
private handleWidgetParam(want: Want): void {
|
||||||
const params = want.parameters;
|
const params = want.parameters;
|
||||||
if (params !== undefined) {
|
if (params !== undefined) {
|
||||||
|
// ⭐ 白名单:日程卡片发 'add',待办卡片发 'addTodo'(新建待办)/ 'openTodo'(进待办页)。
|
||||||
|
// 之前只认 'add',待办卡片点 + 毫无反应 —— 参数被静默丢弃。
|
||||||
const raw = params['widgetAction'];
|
const raw = params['widgetAction'];
|
||||||
if (typeof raw === 'string' && raw === 'add') {
|
if (typeof raw === 'string' && (raw === 'add' || raw === 'addTodo' || raw === 'openTodo')) {
|
||||||
AppStorage.setOrCreate<string>('widgetAction', 'add');
|
AppStorage.setOrCreate<string>('widgetAction', raw);
|
||||||
LogUtil.write('卡片添加入口:已设置 widgetAction=add');
|
LogUtil.write(`卡片入口:已设置 widgetAction=${raw}`);
|
||||||
}
|
}
|
||||||
// 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期)
|
// 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期)
|
||||||
const od = params['openDate'];
|
const od = params['openDate'];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
|
|||||||
import process from '@ohos.process';
|
import process from '@ohos.process';
|
||||||
import { CardDataService, CardData } from '../common/CardDataService';
|
import { CardDataService, CardData } from '../common/CardDataService';
|
||||||
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||||
|
import { TodoService } from '../common/TodoService';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
|
||||||
export default class EntryFormAbility extends FormExtensionAbility {
|
export default class EntryFormAbility extends FormExtensionAbility {
|
||||||
@@ -18,15 +19,31 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组装更新数据并推送到指定卡片 */
|
/**
|
||||||
|
* 组装更新数据并推送到指定卡片。
|
||||||
|
*
|
||||||
|
* ⭐ 一次同时构建「日程」与「待办」两份数据,合并进同一个 binding。
|
||||||
|
* 日程卡片只读 eventsJson 等字段,待办卡片只读 todoListJson 等字段,
|
||||||
|
* 彼此不会串味。这样无需维护 formId → 卡片类型的映射(那需要持久化,
|
||||||
|
* 且系统重启后容易丢失),一套逻辑服务全部 8 张卡片。
|
||||||
|
*/
|
||||||
private pushData(formId: string, dayOffset: number = 0): void {
|
private pushData(formId: string, dayOffset: number = 0): void {
|
||||||
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
|
Promise.all([
|
||||||
|
CardDataService.buildCardData(this.context, dayOffset),
|
||||||
|
CardDataService.buildTodoCardData(this.context),
|
||||||
|
]).then((results: CardData[]): void => {
|
||||||
|
const ev: CardData = results[0];
|
||||||
|
const td: CardData = results[1];
|
||||||
|
// 待办字段并入日程数据对象(两者都是 CardData,字段不冲突)
|
||||||
|
ev.todoListJson = td.todoListJson;
|
||||||
|
ev.todoCount = td.todoCount;
|
||||||
|
ev.todoTodayCount = td.todoTodayCount;
|
||||||
const binding: formBindingData.FormBindingData =
|
const binding: formBindingData.FormBindingData =
|
||||||
formBindingData.createFormBindingData(data);
|
formBindingData.createFormBindingData(ev);
|
||||||
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
|
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
|
||||||
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
|
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
|
||||||
});
|
});
|
||||||
LogUtil.write(`卡片 ${formId} 数据已推送`);
|
LogUtil.write(`卡片 ${formId} 数据已推送(日程+待办)`);
|
||||||
}).catch((err: BusinessError): void => {
|
}).catch((err: BusinessError): void => {
|
||||||
LogUtil.write(`卡片数据构建失败: ${err.message}`);
|
LogUtil.write(`卡片数据构建失败: ${err.message}`);
|
||||||
});
|
});
|
||||||
@@ -84,12 +101,35 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
|||||||
CardDataService.clearDayOffset(formId);
|
CardDataService.clearDayOffset(formId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 卡片上「‹ / › / 回到今天」按钮触发:message 里带 pageAction=prev|next|today */
|
/**
|
||||||
|
* 卡片事件:既处理日程翻页,也处理待办打勾。
|
||||||
|
*
|
||||||
|
* ⚠️ 不用 JSON.parse 解析 message —— 不同系统版本下格式可能是
|
||||||
|
* `{params:{...}}` 也可能是直接 `{...}`,格式差异会让 JSON 解析拿到 undefined。
|
||||||
|
* 统一用**子串匹配**,稳且不依赖具体结构。
|
||||||
|
*/
|
||||||
onFormEvent(formId: string, message: string): void {
|
onFormEvent(formId: string, message: string): void {
|
||||||
LogUtil.init(this.context);
|
LogUtil.init(this.context);
|
||||||
|
|
||||||
|
// ===== 待办打勾:message 里带 todoCheck=<localId> =====
|
||||||
|
const checkIdx: number = message.indexOf('todoCheck');
|
||||||
|
if (checkIdx >= 0) {
|
||||||
|
const m = /todoCheck\D{0,8}(\d+)/.exec(message);
|
||||||
|
const localId: number = m !== null ? parseInt(m[1], 10) : 0;
|
||||||
|
LogUtil.write(`卡片打勾待办: formId=${formId} localId=${localId} msg=${message}`);
|
||||||
|
if (localId > 0) {
|
||||||
|
// onFormEvent 是同步回调,这里异步执行但必须兜住 reject,
|
||||||
|
// 否则未捕获的 Promise 会让进程异常(FormExtension 生命周期只有 10 秒)
|
||||||
|
this.completeTodoFromCard(formId, localId).catch((err: BusinessError): void => {
|
||||||
|
LogUtil.write('卡片打勾未捕获异常: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 日程翻页 =====
|
||||||
const cur: number = CardDataService.getDayOffset(formId);
|
const cur: number = CardDataService.getDayOffset(formId);
|
||||||
let next: number = cur;
|
let next: number = cur;
|
||||||
// 不用 JSON 解析:不同版本 message 的格式可能是 {params:{...}} 或直接 {...},直接子串匹配更稳
|
|
||||||
if (message.indexOf('prev') >= 0) {
|
if (message.indexOf('prev') >= 0) {
|
||||||
next = cur - 1;
|
next = cur - 1;
|
||||||
} else if (message.indexOf('next') >= 0) {
|
} else if (message.indexOf('next') >= 0) {
|
||||||
@@ -101,4 +141,63 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
|||||||
LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`);
|
LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`);
|
||||||
this.pushData(formId, next);
|
this.pushData(formId, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在卡片上完成一条待办。
|
||||||
|
*
|
||||||
|
* 时序设计(关键):
|
||||||
|
* ① **先推一次数据让该条从卡片消失** —— 用户点勾必须立刻有视觉反馈。
|
||||||
|
* 但此时库里还没改成 completed,所以这里不等它完成,
|
||||||
|
* 而是先推送"排除该 localId"的临时数据(见 pushDataExcludingTodo)。
|
||||||
|
* ② 再真正调 TodoService.setCompleted(内部 GET 原 ics → 改写 → PUT)。
|
||||||
|
* ③ 成功 → 推真实数据(该条已 completed,本来就不在列表里,效果一致);
|
||||||
|
* 失败 → 也推真实数据(该条会重新出现,用户能看出没成功,可重试或进 App 处理)。
|
||||||
|
*
|
||||||
|
* 为什么不 await 完再推:
|
||||||
|
* FormExtensionAbility 生命周期回调只有 10 秒窗口,
|
||||||
|
* GET+PUT 两步网络在慢网下可能 10 秒以上,卡片会先"卡住不动"再消失,体验很差。
|
||||||
|
*/
|
||||||
|
private async completeTodoFromCard(formId: string, localId: number): Promise<void> {
|
||||||
|
// ⭐ 关键:推给**所有**卡片,不只 formId 这一张。
|
||||||
|
// 原来只推 formId ⇒ 用户在 A 卡片打勾后,B 卡片仍显示那条待办,
|
||||||
|
// 必须在 B 上再点一次才刷新(用户实测反馈)。
|
||||||
|
// 每张卡片是独立实例、各持一份数据,必须逐个 updateForm。
|
||||||
|
|
||||||
|
// ① 乐观移除:所有卡片立即不含该条(先给视觉反馈,不等网络)
|
||||||
|
await CardDataService.pushToAllForms(this.context, localId);
|
||||||
|
// ② 真正落库 + 推送服务器(GET 原 ics → 改写 → PUT)
|
||||||
|
let ok: boolean = false;
|
||||||
|
try {
|
||||||
|
ok = await TodoService.setCompleted(this.context, localId, true);
|
||||||
|
LogUtil.write('卡片打勾结果: localId=' + localId + ' ' + (ok ? '成功' : '失败'));
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write('卡片打勾异常: localId=' + localId + ' ' + e.message);
|
||||||
|
}
|
||||||
|
// ③ 无论成败都再推一次真实数据:
|
||||||
|
// 成功 → 该条已 completed,本就不在未完成列表里,各卡片保持一致
|
||||||
|
// 失败 → 该条重新出现,用户能看出没成功,可重试
|
||||||
|
await CardDataService.pushToAllForms(this.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推送"排除某条待办"的卡片数据(乐观更新用,只改卡片视图,不动数据库) */
|
||||||
|
private pushDataExcludingTodo(formId: string, excludeId: number): void {
|
||||||
|
Promise.all([
|
||||||
|
CardDataService.buildCardData(this.context, CardDataService.getDayOffset(formId)),
|
||||||
|
CardDataService.buildTodoCardData(this.context, excludeId),
|
||||||
|
]).then((results: CardData[]): void => {
|
||||||
|
const ev: CardData = results[0];
|
||||||
|
const td: CardData = results[1];
|
||||||
|
ev.todoListJson = td.todoListJson;
|
||||||
|
ev.todoCount = td.todoCount;
|
||||||
|
ev.todoTodayCount = td.todoTodayCount;
|
||||||
|
const binding: formBindingData.FormBindingData =
|
||||||
|
formBindingData.createFormBindingData(ev);
|
||||||
|
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
|
||||||
|
LogUtil.write(`卡片 ${formId} 乐观更新失败: ${err.message}`);
|
||||||
|
});
|
||||||
|
}).catch((err: BusinessError): void => {
|
||||||
|
LogUtil.write(`卡片乐观更新数据构建失败: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,27 @@ export class EditCalendarItem {
|
|||||||
href: string;
|
href: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string; // 服务器定义的颜色(calendar-color),可能为空
|
color: string; // 服务器定义的颜色(calendar-color),可能为空
|
||||||
|
/** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */
|
||||||
|
comps: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
|
||||||
constructor(href: string, name: string, color: string) {
|
constructor(href: string, name: string, color: string, comps: string = 'unknown') {
|
||||||
this.href = href;
|
this.href = href;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.color = color;
|
this.color = color;
|
||||||
|
this.comps = comps;
|
||||||
this.selected = false;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
|
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) {
|
if (list.length === 0) {
|
||||||
LogUtil.write('编辑账号:未发现任何日历本');
|
LogUtil.write('编辑账号:未发现任何日历本');
|
||||||
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
|
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
|
||||||
@@ -246,6 +259,7 @@ struct EditAccountPage {
|
|||||||
target.calendarHrefs = newHrefs;
|
target.calendarHrefs = newHrefs;
|
||||||
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
|
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
|
||||||
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
|
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} 个日历本`);
|
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
|
||||||
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
|
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
|
||||||
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
|
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
|
||||||
@@ -530,6 +544,22 @@ struct EditCalendarRow {
|
|||||||
|
|
||||||
build() {
|
build() {
|
||||||
Row({ space: 12 }) {
|
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 !== '') {
|
if (this.item.color !== '') {
|
||||||
Circle()
|
Circle()
|
||||||
.width(12)
|
.width(12)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
|
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
|
||||||
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
|
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
|
||||||
import { notificationManager } from '@kit.NotificationKit';
|
import { notificationManager } from '@kit.NotificationKit';
|
||||||
import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore';
|
import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore';
|
||||||
import { EventDb, LocalEvent } from '../common/EventDb';
|
import { EventDb, LocalEvent } from '../common/EventDb';
|
||||||
@@ -14,6 +14,7 @@ import { LogUtil } from '../common/LogUtil';
|
|||||||
import { IcsUtil } from '../common/IcsUtil';
|
import { IcsUtil } from '../common/IcsUtil';
|
||||||
import { RruleUtil } from '../common/RruleUtil';
|
import { RruleUtil } from '../common/RruleUtil';
|
||||||
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
|
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
|
||||||
|
import { PlaceParser, ParsedPlace } from '../common/PlaceParser';
|
||||||
|
|
||||||
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
|
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
|
||||||
class SeriesBounds {
|
class SeriesBounds {
|
||||||
@@ -112,6 +113,8 @@ struct EventEditPage {
|
|||||||
@State sheetMode: string = 'picker';
|
@State sheetMode: string = 'picker';
|
||||||
@State pickerIsEnd: boolean = false;
|
@State pickerIsEnd: boolean = false;
|
||||||
@State pickDate: Date = new Date();
|
@State pickDate: Date = new Date();
|
||||||
|
/** 新建"非今天"日程时的默认开始小时(24 小时制) */
|
||||||
|
private static readonly DEFAULT_START_HOUR: number = 9;
|
||||||
@State pickHour: number = 9;
|
@State pickHour: number = 9;
|
||||||
@State pickMin: number = 0;
|
@State pickMin: number = 0;
|
||||||
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
|
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
|
||||||
@@ -271,12 +274,23 @@ struct EventEditPage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 新建:日期取所选日(未选则今天),时间取当前时刻;结束 = 开始 + 1 小时
|
// 新建:日期取所选日(未选则今天)。默认开始时刻分两种情况:
|
||||||
|
// ① 今天 → 取当前时刻("现在就记一笔"最自然;而且今天的 09:00 往往已经过了)
|
||||||
|
// ② 非今天 → 固定 09:00("现在几点"对别的日子毫无参考价值,09:00 才是通用默认)
|
||||||
|
// 结束时间统一 = 开始 + 1 小时。
|
||||||
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
|
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
|
||||||
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
|
const pickedDay = new Date(base);
|
||||||
new Date(base).getDate()).getTime();
|
const dayStart = new Date(pickedDay.getFullYear(), pickedDay.getMonth(),
|
||||||
|
pickedDay.getDate()).getTime();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
|
if (dayStart === todayStart) {
|
||||||
|
// ① 今天:当前时刻
|
||||||
this.startMs = dayStart + now.getHours() * 3600000 + now.getMinutes() * 60000;
|
this.startMs = dayStart + now.getHours() * 3600000 + now.getMinutes() * 60000;
|
||||||
|
} else {
|
||||||
|
// ② 非今天:09:00
|
||||||
|
this.startMs = dayStart + EventEditPage.DEFAULT_START_HOUR * 3600000;
|
||||||
|
}
|
||||||
this.endMs = this.startMs + 3600000;
|
this.endMs = this.startMs + 3600000;
|
||||||
if (choices.length > 0) {
|
if (choices.length > 0) {
|
||||||
this.chosenKey = choices[0].calKey;
|
this.chosenKey = choices[0].calKey;
|
||||||
@@ -430,6 +444,123 @@ struct EventEditPage {
|
|||||||
.padding({ top: 4, bottom: 4 })
|
.padding({ top: 4, bottom: 4 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从剪贴板粘贴并解析地点。
|
||||||
|
*
|
||||||
|
* ⚠️ 必须由 PasteButton 的 onClick 触发,权限仅在回调内有效。
|
||||||
|
* 在回调外调用 pasteboard.getData() 会因缺少权限而失败。
|
||||||
|
*/
|
||||||
|
private async pastePlace(): Promise<void> {
|
||||||
|
this.cancelSuggestTimer();
|
||||||
|
let text: string = '';
|
||||||
|
try {
|
||||||
|
const sys = pasteboard.getSystemPasteboard();
|
||||||
|
const has: boolean = await sys.hasData();
|
||||||
|
if (!has) {
|
||||||
|
this.toast('剪贴板是空的');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data: pasteboard.PasteData = await sys.getData();
|
||||||
|
text = data.getPrimaryText();
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write(`粘贴读取失败: ${e.code} ${e.message}`);
|
||||||
|
this.toast('无法读取剪贴板');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text === undefined || text === null || text.trim() === '') {
|
||||||
|
this.toast('剪贴板里没有文字');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.resolvePastedText(text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把粘贴到的文本解析成地点。
|
||||||
|
*
|
||||||
|
* 三级降级(从准到松):
|
||||||
|
* ① 高德链接 → 直接有坐标,无需检索
|
||||||
|
* ② 微信文本 → 拆出名称,用**名称**检索(实测命中率最高)
|
||||||
|
* ③ 名称搜不到 → 用完整地址再搜一次
|
||||||
|
* ④ 都失败 → 把原文填进地点栏,提示用户手动确认
|
||||||
|
*
|
||||||
|
* 第 ④ 步兜底很重要:宁可让用户自己看一眼,
|
||||||
|
* 也不能因为解析失败就清空输入框或卡住保存。
|
||||||
|
*/
|
||||||
|
private async resolvePastedText(text: string): Promise<void> {
|
||||||
|
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
|
||||||
|
let parsed: ParsedPlace | null = null;
|
||||||
|
try {
|
||||||
|
parsed = await PlaceParser.parse(text, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write(`粘贴解析异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析不出东西 → 原文填入,让用户自行判断
|
||||||
|
if (parsed === null) {
|
||||||
|
this.lastAppliedText = text;
|
||||||
|
this.location = text;
|
||||||
|
this.toast('已填入,请确认地点是否正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有坐标(高德链接):直接用,坐标已包含在解析结果里
|
||||||
|
if (parsed.hasCoord) {
|
||||||
|
const locText: string = PlaceParser.mergeForLocationText(parsed.name, parsed.address);
|
||||||
|
this.lastAppliedText = locText !== '' ? locText : text;
|
||||||
|
this.location = this.lastAppliedText;
|
||||||
|
this.placePicked = true;
|
||||||
|
this.toast('已从链接解析地点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无坐标(微信文本):名称优先检索,失败再用地址
|
||||||
|
const tryKeywords: string[] = [];
|
||||||
|
if (parsed.name !== '') {
|
||||||
|
tryKeywords.push(parsed.name);
|
||||||
|
}
|
||||||
|
if (parsed.address !== '' && parsed.address !== parsed.name) {
|
||||||
|
tryKeywords.push(parsed.address);
|
||||||
|
}
|
||||||
|
if (tryKeywords.length === 0) {
|
||||||
|
tryKeywords.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < tryKeywords.length; i++) {
|
||||||
|
const kw: string = tryKeywords[i];
|
||||||
|
try {
|
||||||
|
let items: PlaceItem[] = await PlaceSuggest.query(kw, ctx);
|
||||||
|
if (items.length === 0) {
|
||||||
|
items = await PlaceSuggest.search(kw, ctx);
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
// 命中:直接应用第一条(与用户点选候选等价)
|
||||||
|
const best: PlaceItem = items[0];
|
||||||
|
this.placePicked = true;
|
||||||
|
this.lastAppliedText = best.locationText;
|
||||||
|
this.location = best.locationText;
|
||||||
|
this.toast('已匹配到地点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
LogUtil.write(`粘贴检索失败(${kw}): ${(err as BusinessError).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部检索失败:填原文,不阻塞
|
||||||
|
const fallback: string = parsed.name !== '' ? parsed.name : parsed.address;
|
||||||
|
this.lastAppliedText = fallback !== '' ? fallback : text;
|
||||||
|
this.location = this.lastAppliedText;
|
||||||
|
this.placePicked = true;
|
||||||
|
this.toast('已填入,请确认地点是否正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轻量提示(复用页面已有的 toast 机制;没有就静默) */
|
||||||
|
private toast(msg: string): void {
|
||||||
|
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 1800 });
|
||||||
|
}
|
||||||
|
|
||||||
/** 选中一条候选:写进地点输入框并收起列表 */
|
/** 选中一条候选:写进地点输入框并收起列表 */
|
||||||
private applyPlace(it: PlaceItem): void {
|
private applyPlace(it: PlaceItem): void {
|
||||||
// ⭐ 顺序很关键:先收起浮层,再写文本。
|
// ⭐ 顺序很关键:先收起浮层,再写文本。
|
||||||
@@ -686,7 +817,8 @@ struct EventEditPage {
|
|||||||
this.endMs = dayStart + 86399999;
|
this.endMs = dayStart + 86399999;
|
||||||
} else {
|
} else {
|
||||||
const s = new Date(this.startMs);
|
const s = new Date(this.startMs);
|
||||||
this.startMs = s.getTime() + 9 * 3600000;
|
this.startMs = new Date(s.getFullYear(), s.getMonth(), s.getDate()).getTime()
|
||||||
|
+ EventEditPage.DEFAULT_START_HOUR * 3600000;
|
||||||
this.endMs = this.startMs + 3600000;
|
this.endMs = this.startMs + 3600000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1259,6 +1391,7 @@ struct EventEditPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
Column({ space: 14 }) {
|
Column({ space: 14 }) {
|
||||||
// 顶部(与内容区左右对齐,避免贴边误触)
|
// 顶部(与内容区左右对齐,避免贴边误触)
|
||||||
Row({ space: 6 }) {
|
Row({ space: 6 }) {
|
||||||
@@ -1610,6 +1743,33 @@ struct EventEditPage {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.id('placeAnchor')
|
.id('placeAnchor')
|
||||||
|
// ⭐ 粘贴地址:系统 PasteButton 安全控件
|
||||||
|
// 用它而不是自绘按钮 + pasteboard.getData(),原因见 pastePlace() 注释:
|
||||||
|
// READ_PASTEBOARD 是受限权限,普通应用申请会被拒绝。
|
||||||
|
// PasteButton 无需声明权限、无需弹窗,权限仅在 onClick 内有效。
|
||||||
|
// ⭐ PasteButton:安全控件,点击时临时获得剪贴板读权限
|
||||||
|
// (无需申请 READ_PASTEBOARD —— 那是受限权限,普通应用申请会被拒)
|
||||||
|
//
|
||||||
|
// ⚠️ icon/text/buttonType **只能走构造参数**,写成链式属性会编译报错。
|
||||||
|
// 文字内容由系统枚举决定(PasteDescription 只有 PASTE 一个值,即「粘贴」)。
|
||||||
|
// 外观与右边的「地图」按钮保持一致:同字号、同色、同底色、同圆角、同高度。
|
||||||
|
PasteButton({
|
||||||
|
text: PasteDescription.PASTE,
|
||||||
|
// ⚠️ 必须用 Normal,不能用 Capsule:
|
||||||
|
// 按 SDK 文档,Capsule / Circle 下 borderRadius **不生效**,
|
||||||
|
// 圆角由按钮类型自动决定(= 两端全圆),与「地图」的圆角矩形不一致。
|
||||||
|
// 只有 Normal / ROUNDED_RECTANGLE 才认 borderRadius。
|
||||||
|
buttonType: ButtonType.Normal
|
||||||
|
})
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.brand_text'))
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
.height(44)
|
||||||
|
.padding({ left: 12, right: 12 })
|
||||||
|
.borderRadius(12)
|
||||||
|
.onClick(() => {
|
||||||
|
this.pastePlace();
|
||||||
|
})
|
||||||
// 在地图上选点(系统地图界面,可选可搜可周边)
|
// 在地图上选点(系统地图界面,可选可搜可周边)
|
||||||
Text('地图')
|
Text('地图')
|
||||||
.fontSize(13)
|
.fontSize(13)
|
||||||
@@ -1680,20 +1840,34 @@ struct EventEditPage {
|
|||||||
.backgroundColor($r('app.color.page_bg'))
|
.backgroundColor($r('app.color.page_bg'))
|
||||||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
// ⚠️ 整个页面**只允许一个 bindSheet**:挂两个时后者会覆盖前者,
|
|
||||||
// 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。
|
// ===== 居中弹层 =====
|
||||||
// 这里用 sheetMode 在同一个弹层里切换内容。
|
if (this.sheetShow) {
|
||||||
.bindSheet($$this.sheetShow, this.bottomSheet(), {
|
Column()
|
||||||
height: this.sheetMode === 'scope' ? 320 : (this.sheetMode === 'repeatEnd' ? 340 : 380),
|
.width('100%')
|
||||||
showClose: false,
|
.height('100%')
|
||||||
dragBar: true,
|
.backgroundColor('#66000000')
|
||||||
backgroundColor: $r('app.color.card_bg')
|
.onClick(() => {
|
||||||
|
this.sheetShow = false;
|
||||||
})
|
})
|
||||||
|
this.centerSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"/"重复范围选择"/"重复截止日期" */
|
/**
|
||||||
|
* 居中弹层(自绘覆盖层,与待办页一致)。
|
||||||
|
*
|
||||||
|
* 原来用 bindSheet 从底部弹出,用户反馈"点完之后选择框在下面出来,操作不顺畅"。
|
||||||
|
* 改成屏幕居中,视线不用上下跳。
|
||||||
|
*
|
||||||
|
* 为什么不用 bindSheet:SDK 的 SheetOptions 没有 position/alignment,只能底部弹。
|
||||||
|
*/
|
||||||
@Builder
|
@Builder
|
||||||
bottomSheet() {
|
centerSheet() {
|
||||||
if (this.sheetMode === 'scope') {
|
if (this.sheetMode === 'scope') {
|
||||||
this.scopeSheet()
|
this.scopeSheet()
|
||||||
} else if (this.sheetMode === 'repeatEnd') {
|
} else if (this.sheetMode === 'repeatEnd') {
|
||||||
@@ -1743,8 +1917,10 @@ struct EventEditPage {
|
|||||||
this.sheetShow = false;
|
this.sheetShow = false;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('86%')
|
||||||
.padding({ left: 16, right: 16, bottom: 24 })
|
.padding(18)
|
||||||
|
.borderRadius(16)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
|
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
|
||||||
@@ -1780,8 +1956,10 @@ struct EventEditPage {
|
|||||||
this.sheetShow = false;
|
this.sheetShow = false;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('86%')
|
||||||
.padding({ left: 20, right: 20 })
|
.padding(18)
|
||||||
|
.borderRadius(16)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
@@ -1990,8 +2168,10 @@ struct EventEditPage {
|
|||||||
this.sheetShow = false;
|
this.sheetShow = false;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('86%')
|
||||||
.padding({ left: 16, right: 16, bottom: 24 })
|
.padding(18)
|
||||||
|
.borderRadius(16)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2015,6 +2195,13 @@ class CalendarDataBridge {
|
|||||||
if (!detected || manualKeys.includes(`${acc.id}_${i}`)) {
|
if (!detected || manualKeys.includes(`${acc.id}_${i}`)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// ⭐ 只列**能放日程**的本:纯待办本(COMP_SET 只有 VTODO)写不进 VEVENT。
|
||||||
|
// 'both' / 'unknown' 保留,理由同 Index.openTodoCreator。
|
||||||
|
const comp: string = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
|
||||||
|
? acc.calendarComps[i] : 'unknown';
|
||||||
|
if (comp === 'todo') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const s = new CalSourceWithHref();
|
const s = new CalSourceWithHref();
|
||||||
s.calKey = `${acc.id}_${i}`;
|
s.calKey = `${acc.id}_${i}`;
|
||||||
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
|
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
|
||||||
|
|||||||
+2083
-121
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,7 @@ struct SettingsPage {
|
|||||||
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
|
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
|
||||||
@State mirrorRunning: boolean = false; // 正在执行镜像
|
@State mirrorRunning: boolean = false; // 正在执行镜像
|
||||||
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
|
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
|
||||||
|
@State wipeAllRunning: boolean = false; // 正在清除全部镜像(卸载前清干净)
|
||||||
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
|
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
|
||||||
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
|
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
|
||||||
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
|
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
|
||||||
@@ -681,6 +682,81 @@ struct SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐「彻底清除全部镜像数据」(卸载前清干净用)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:鸿蒙**没有**「应用即将被卸载」的回调,系统日历属于**沙箱外**数据,
|
||||||
|
* 卸载时不会自动清理。用户卸载后,系统日历里会永久留着一堆
|
||||||
|
* 「同步日历·xxx」账户(还会继续提醒),只有重装并重新同步才能被当孤儿清掉。
|
||||||
|
* ⇒ 提供显式入口,让用户在卸载前主动清干净。
|
||||||
|
*
|
||||||
|
* ⚠️ 清除范围(务必与文案一致):
|
||||||
|
* - 只删**系统日历里本应用建立的镜像账户及其日程**
|
||||||
|
* - **不动** CalDAV 账号配置(账号密码仍保留,重装后可直接用)
|
||||||
|
* - **不动** 服务器上的任何日程(同步过去的内容一条都不会少)
|
||||||
|
*/
|
||||||
|
private confirmWipeAllMirrors(): void {
|
||||||
|
if (this.context === undefined || this.wipeAllRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.getUIContext().showAlertDialog({
|
||||||
|
title: '彻底清除系统日历镜像数据',
|
||||||
|
message: '将删除本应用写入系统日历的全部镜像日历本及其日程。\n\n'
|
||||||
|
+ '只影响系统日历:你的 CalDAV 账号与服务器上的日程完全不受影响。\n'
|
||||||
|
+ '清除后系统日历里不再显示这些日程,也不再提醒;镜像开关会一并关闭。\n\n'
|
||||||
|
+ '准备卸载本应用时,请先点这里清干净(卸载时系统不会自动清除)。',
|
||||||
|
autoCancel: true,
|
||||||
|
alignment: DialogAlignment.Center,
|
||||||
|
primaryButton: {
|
||||||
|
value: '取消',
|
||||||
|
action: (): void => {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
value: '确认清除',
|
||||||
|
action: (): void => {
|
||||||
|
this.doWipeAllMirrors();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doWipeAllMirrors(): Promise<void> {
|
||||||
|
if (this.context === undefined || this.wipeAllRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.wipeAllRunning = true;
|
||||||
|
try {
|
||||||
|
const n: number = await SystemCalendarMirror.removeAll(this.context);
|
||||||
|
// ⭐ 必须同时把"镜像开关"关掉并落库:否则下一轮同步会立刻按勾选重新建回来,
|
||||||
|
// 用户会以为"清了等于没清"。
|
||||||
|
this.mirrorEnabled = false;
|
||||||
|
await AppSettings.setMirrorEnabled(this.context, false);
|
||||||
|
// 镜像本没了 → 提醒要交回 App 负责,否则这些本会既没系统提醒、也没 App 提醒(漏提醒)
|
||||||
|
ReminderService.invalidateMirrorCache();
|
||||||
|
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
|
||||||
|
// ⭐ 清空结果必须说清楚:用户点这个按钮就是为了"确认卸载后不留东西",
|
||||||
|
// 所以"0 个"也要区分是权限没给(没清成)还是本来就干净(放心)
|
||||||
|
const granted: boolean = await SystemCalendarMirror.hasPermission();
|
||||||
|
let msg: string = '';
|
||||||
|
if (n > 0) {
|
||||||
|
msg = `已清除 ${n} 个镜像日历本,系统日历中不再有本应用的日程;镜像开关已关闭`;
|
||||||
|
} else if (!granted) {
|
||||||
|
msg = '未授予"写入系统日历"权限,无法清除。请先在设置中允许本应用读写日历后重试';
|
||||||
|
} else {
|
||||||
|
msg = '系统日历中已没有本应用的镜像数据,无需清除';
|
||||||
|
}
|
||||||
|
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 5000 });
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
this.getUIContext().getPromptAction().showToast({
|
||||||
|
message: `清除失败:${e.message ?? '请确认已授予"写入系统日历"权限'}`
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.wipeAllRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async saveInterval(minutes: number): Promise<void> {
|
private async saveInterval(minutes: number): Promise<void> {
|
||||||
if (this.context === undefined) {
|
if (this.context === undefined) {
|
||||||
return;
|
return;
|
||||||
@@ -1163,6 +1239,7 @@ struct SettingsPage {
|
|||||||
this.runMirrorNow();
|
this.runMirrorNow();
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
||||||
.fontSize(13)
|
.fontSize(13)
|
||||||
.fontColor($r('app.color.brand_text'))
|
.fontColor($r('app.color.brand_text'))
|
||||||
@@ -1206,6 +1283,82 @@ struct SettingsPage {
|
|||||||
.backgroundColor($r('app.color.card_bg'))
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||||
|
|
||||||
|
// ⭐ WIPE_CARD_MOVED:数据清除独立成卡(不依赖镜像开关是否开启)
|
||||||
|
// 鸿蒙没有"应用即将被卸载"的回调,而系统日历属于沙箱外的系统数据,
|
||||||
|
// 卸载时不会自动清理 ⇒ 必须提供显式入口,让用户卸载前主动清干净。
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text('清除系统日历数据')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text('卸载本应用前请先清除,否则系统日历里会一直留着这些日程(还会提醒)')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Button(this.wipeAllRunning ? '正在清除…' : '彻底清除系统日历镜像数据')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.error'))
|
||||||
|
.backgroundColor(Color.Transparent)
|
||||||
|
.border({ width: 1, color: $r('app.color.error') })
|
||||||
|
.width('100%')
|
||||||
|
.enabled(!this.wipeAllRunning)
|
||||||
|
.onClick(() => {
|
||||||
|
this.confirmWipeAllMirrors();
|
||||||
|
})
|
||||||
|
|
||||||
|
Text('只删系统日历里的镜像本;CalDAV 账号与服务器上的日程一条都不会少。清除后镜像开关会自动关闭。')
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.text_hint'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.brand_text'))
|
||||||
|
.backgroundColor(Color.Transparent)
|
||||||
|
.width('100%')
|
||||||
|
.enabled(!this.purgeRunning)
|
||||||
|
.onClick(() => {
|
||||||
|
this.runPurgeOrphans();
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.accountDump !== '') {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):')
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
Scroll() {
|
||||||
|
Text(this.accountDump)
|
||||||
|
.fontSize(10)
|
||||||
|
.lineHeight(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.copyOption(CopyOptions.LocalDevice)
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.height(150)
|
||||||
|
.width('100%')
|
||||||
|
.backgroundColor($r('app.color.page_bg'))
|
||||||
|
.borderRadius(8)
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 4 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.borderRadius(12)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||||
|
|
||||||
// 默认视图:打开 App 后默认展示的视图(月/周/列表)
|
// 默认视图:打开 App 后默认展示的视图(月/周/列表)
|
||||||
Column({ space: 8 }) {
|
Column({ space: 8 }) {
|
||||||
Row({ space: 10 }) {
|
Row({ space: 10 }) {
|
||||||
@@ -1544,6 +1697,36 @@ struct SettingsPage {
|
|||||||
this.openDoc(AppSettings.HELP_TITLE, AppSettings.HELP_URL);
|
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 挂在本卡片上,避免与其它弹层冲突)
|
// 隐私政策与用户协议(点击弹层;bindSheet 挂在本卡片上,避免与其它弹层冲突)
|
||||||
Column({ space: 8 }) {
|
Column({ space: 8 }) {
|
||||||
Row({ space: 10 }) {
|
Row({ space: 10 }) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
|||||||
|
// entry/src/main/ets/pages/widget/TodoWidget2x2.ets
|
||||||
|
// 2x2 待办服务卡片:突出未完成待办总数 + 今日到期提示。
|
||||||
|
// 本卡片**不展示待办列表**,因此没有打勾交互;点击整卡进入 App 的待办页。
|
||||||
|
let storageTodo2x2 = new LocalStorage();
|
||||||
|
|
||||||
|
@Entry(storageTodo2x2)
|
||||||
|
@Component
|
||||||
|
struct TodoWidget2x2Card {
|
||||||
|
@LocalStorageProp('dateMd') dateMd: string = '';
|
||||||
|
@LocalStorageProp('weekday') weekday: string = '';
|
||||||
|
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||||
|
@LocalStorageProp('todoCount') todoCount: number = 0;
|
||||||
|
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
|
||||||
|
|
||||||
|
/** 右上角添加按钮:拉起 App 直接进入新建待办页 */
|
||||||
|
@Builder
|
||||||
|
addButton() {
|
||||||
|
Button() {
|
||||||
|
Text('+')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(30)
|
||||||
|
.height(30)
|
||||||
|
.borderRadius(15)
|
||||||
|
.padding(0)
|
||||||
|
.backgroundColor('#6A4C93')
|
||||||
|
.onClick(() => {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'router',
|
||||||
|
abilityName: 'EntryAbility',
|
||||||
|
params: { widgetAction: 'addTodo' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开 App 并进入待办 Tab */
|
||||||
|
private openApp(): void {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'router',
|
||||||
|
abilityName: 'EntryAbility',
|
||||||
|
params: { widgetAction: 'openTodo' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
// 顶部:日期 + 添加按钮
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(this.dateMd)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
.maxLines(1)
|
||||||
|
Text(`${this.weekday === '' ? '' : this.weekday + ' '}${this.lunarText}`)
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor('#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
this.addButton()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.strokeWidth(0.5)
|
||||||
|
.color('#E5E5E5')
|
||||||
|
|
||||||
|
// 未完成待办大数字
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(`${this.todoCount}`)
|
||||||
|
.fontSize(40)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#6A4C93')
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text('待办未完成')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
.maxLines(1)
|
||||||
|
// 今日到期优先提示,没有则给操作引导
|
||||||
|
Text(this.todoTodayCount > 0 ? `今日到期 ${this.todoTodayCount} 项`
|
||||||
|
: (this.todoCount === 0 ? '点击添加' : '今日无到期'))
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor('#FFFFFF')
|
||||||
|
.borderRadius(16)
|
||||||
|
.onClick(() => this.openApp())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// entry/src/main/ets/pages/widget/TodoWidget2x4.ets
|
||||||
|
// 2x4 待办服务卡片:左侧 = 未完成总数 + 今日到期(同 2x2);右侧 = 最近 2 条待办。
|
||||||
|
// 右侧带**圆形勾选框**,点击即完成并推送回服务器。
|
||||||
|
let storageTodo2x4 = new LocalStorage();
|
||||||
|
|
||||||
|
/** 待办卡片单条(解析 todoListJson) */
|
||||||
|
class TodoItem2x4 {
|
||||||
|
id: number = 0;
|
||||||
|
title: string = '';
|
||||||
|
dueText: string = '';
|
||||||
|
/** 副行:截止时间 · 日历本 */
|
||||||
|
subText: string = '';
|
||||||
|
color: string = '#007DFF';
|
||||||
|
calName: string = '';
|
||||||
|
completed: boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry(storageTodo2x4)
|
||||||
|
@Component
|
||||||
|
struct TodoWidget2x4Card {
|
||||||
|
@LocalStorageProp('dateMd') dateMd: string = '';
|
||||||
|
@LocalStorageProp('weekday') weekday: string = '';
|
||||||
|
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||||
|
@LocalStorageProp('todoCount') todoCount: number = 0;
|
||||||
|
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
|
||||||
|
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
|
||||||
|
|
||||||
|
/** 右侧列表数据(可滚动;数据侧已按截止时间排序并限制在 20 条内) */
|
||||||
|
private parseTodos(): TodoItem2x4[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(this.todoListJson) as TodoItem2x4[];
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
addButton() {
|
||||||
|
Button() {
|
||||||
|
Text('+')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(30)
|
||||||
|
.height(30)
|
||||||
|
.borderRadius(15)
|
||||||
|
.padding(0)
|
||||||
|
.backgroundColor('#6A4C93')
|
||||||
|
.onClick(() => {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'router',
|
||||||
|
abilityName: 'EntryAbility',
|
||||||
|
params: { widgetAction: 'addTodo' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开 App 并进入待办 Tab */
|
||||||
|
private openApp(): void {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'router',
|
||||||
|
abilityName: 'EntryAbility',
|
||||||
|
params: { widgetAction: 'openTodo' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击圆形勾选框 → 让 FormExtensionAbility 去完成这条待办。
|
||||||
|
*
|
||||||
|
* ⭐ 用 message 而不是 call:message 会进入 onFormEvent,能在
|
||||||
|
* FormExtension 的 10 秒窗口内完成"改库 + PUT 推送 + 刷新卡片",
|
||||||
|
* 不需要拉起主 App(拉起会闪界面,体验差)。
|
||||||
|
* ⭐ 带上 localId:卡片只知道自己渲染的是哪一条,改库要靠主键。
|
||||||
|
*/
|
||||||
|
private checkTodo(id: number): void {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'message',
|
||||||
|
params: { todoCheck: `${id}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右侧单条待办:勾选框 + 标题 + 截止时间 */
|
||||||
|
/**
|
||||||
|
* 单条待办:与 4×4 / 6×4 同款结构(圆圈用日历本色 + 标题 + 副行),
|
||||||
|
* 尺寸再收一档 —— 2×4 只有 2 格高,圈 16 / 标题 12 / 副行 9。
|
||||||
|
*/
|
||||||
|
@Builder
|
||||||
|
todoRow(item: TodoItem2x4) {
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
// 勾选圈:颜色用日历本色,点击即完成
|
||||||
|
Row() {
|
||||||
|
Text('')
|
||||||
|
.width(16)
|
||||||
|
.height(16)
|
||||||
|
.borderRadius(8)
|
||||||
|
.border({ width: 2, color: item.color })
|
||||||
|
}
|
||||||
|
.padding({ right: 2 })
|
||||||
|
.onClick(() => {
|
||||||
|
this.checkTodo(item.id);
|
||||||
|
})
|
||||||
|
|
||||||
|
Column({ space: 1 }) {
|
||||||
|
Text(item.title)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Text(item.subText)
|
||||||
|
.fontSize(9)
|
||||||
|
.fontColor('#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 4, right: 8, top: 5, bottom: 5 })
|
||||||
|
.borderRadius(9)
|
||||||
|
.backgroundColor('#F5F7FA')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右侧空态 */
|
||||||
|
@Builder
|
||||||
|
emptyHint() {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(this.todoCount === 0 ? '暂无待办' : '今日无到期')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#8A8A8A')
|
||||||
|
.maxLines(2)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
// ===== 左半:未完成总数 =====
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(this.dateMd)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Text(`${this.weekday === '' ? '' : this.weekday + ' '}${this.lunarText}`)
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor('#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
this.addButton()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.strokeWidth(0.5)
|
||||||
|
.color('#E5E5E5')
|
||||||
|
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Text(`${this.todoCount}`)
|
||||||
|
.fontSize(38)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#6A4C93')
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text('待办未完成')
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
.maxLines(1)
|
||||||
|
Text(this.todoTodayCount > 0 ? `今日到期 ${this.todoTodayCount} 项`
|
||||||
|
: (this.todoCount === 0 ? '点击添加' : '今日无到期'))
|
||||||
|
.fontSize(9)
|
||||||
|
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.height('100%')
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.vertical(true)
|
||||||
|
.height('92%')
|
||||||
|
.strokeWidth(0.5)
|
||||||
|
.color('#E5E5E5')
|
||||||
|
|
||||||
|
// ===== 右半:最近 2 条待办(可打勾)=====
|
||||||
|
Column() {
|
||||||
|
if (this.parseTodos().length === 0) {
|
||||||
|
this.emptyHint()
|
||||||
|
} else {
|
||||||
|
// ⭐ List 可滚动:2x4 原来只渲染固定 2 条(Column + slice),
|
||||||
|
// 待办是清单性质、条数常十几条,只看 2 条价值太低。
|
||||||
|
// List 自带虚拟化与滚动惯性,条目多也不占内存。
|
||||||
|
List({ space: 3 }) {
|
||||||
|
ForEach(this.parseTodos(), (item: TodoItem2x4, idx: number) => {
|
||||||
|
ListItem() {
|
||||||
|
this.todoRow(item)
|
||||||
|
}
|
||||||
|
}, (item: TodoItem2x4, idx: number) => `t${item.id}_${idx}`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Off)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding(12)
|
||||||
|
.backgroundColor('#FFFFFF')
|
||||||
|
.borderRadius(16)
|
||||||
|
.onClick(() => this.openApp())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// entry/src/main/ets/pages/widget/TodoWidget4x4.ets
|
||||||
|
// 4x4 待办服务卡片:顶部标题栏(未完成数 / 今日到期)+ 可滚动待办列表。
|
||||||
|
// 每条待办左侧为圆形勾选框,点击即完成并推送回服务器。
|
||||||
|
let storageTodo4x4 = new LocalStorage();
|
||||||
|
|
||||||
|
/** 待办卡片单条(解析 todoListJson) */
|
||||||
|
class TodoItem4x4 {
|
||||||
|
id: number = 0;
|
||||||
|
title: string = '';
|
||||||
|
dueText: string = '';
|
||||||
|
/** 副行:截止时间 · 日历本 */
|
||||||
|
subText: string = '';
|
||||||
|
color: string = '#007DFF';
|
||||||
|
calName: string = '';
|
||||||
|
completed: boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry(storageTodo4x4)
|
||||||
|
@Component
|
||||||
|
struct TodoWidget4x4Card {
|
||||||
|
@LocalStorageProp('dateMd') dateMd: string = '';
|
||||||
|
@LocalStorageProp('weekday') weekday: string = '';
|
||||||
|
@LocalStorageProp('todoCount') todoCount: number = 0;
|
||||||
|
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
|
||||||
|
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
|
||||||
|
|
||||||
|
private parseTodos(): TodoItem4x4[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(this.todoListJson) as TodoItem4x4[];
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
addButton() {
|
||||||
|
Button() {
|
||||||
|
Text('+')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(30)
|
||||||
|
.height(30)
|
||||||
|
.borderRadius(15)
|
||||||
|
.padding(0)
|
||||||
|
.backgroundColor('#6A4C93')
|
||||||
|
.onClick(() => {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'router',
|
||||||
|
abilityName: 'EntryAbility',
|
||||||
|
params: { widgetAction: 'addTodo' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private openApp(): void {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'router',
|
||||||
|
abilityName: 'EntryAbility',
|
||||||
|
params: { widgetAction: 'openTodo' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击勾选框 → 交给 FormExtension 完成(带 localId) */
|
||||||
|
private checkTodo(id: number): void {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'message',
|
||||||
|
params: { todoCheck: `${id}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 圆形勾选框(空心圆,点击后由数据刷新从列表中消失) */
|
||||||
|
@Builder
|
||||||
|
checkbox(id: number) {
|
||||||
|
// ⚠️ 不用 Circle().stroke() —— 那是 SDK 26 的 API,本工程 compatibleSdkVersion 为
|
||||||
|
// 6.1.1(24),真机上 stroke 不生效会导致圆圈不可见。
|
||||||
|
// 改用 border + 50% 圆角,视觉一致且从 API 7 起就支持。
|
||||||
|
Column()
|
||||||
|
.width(20)
|
||||||
|
.height(20)
|
||||||
|
.borderRadius(10)
|
||||||
|
.border({ width: 1.5, color: '#B0B0B0' })
|
||||||
|
.backgroundColor(Color.Transparent)
|
||||||
|
.onClick(() => this.checkTodo(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条待办:与 App 内 Index.todoRow 同款结构 ——
|
||||||
|
* 圆圈(颜色跟随日历本)+ 标题 + 副行「截止时间 · 日历本」
|
||||||
|
*
|
||||||
|
* ⚠️ 未完成=空心环、已完成=实心+✓,这套语义与 App 内一致;
|
||||||
|
* 卡片上只显示未完成,所以这里主要是空心环。
|
||||||
|
* ⚠️ 4×4 与 6×4 的 todoRow 必须**完全一致**(用户要求),改一处要同步另一处。
|
||||||
|
*/
|
||||||
|
@Builder
|
||||||
|
todoRow(item: TodoItem4x4) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
// 勾选圈:颜色用日历本色(与 App 内一致),点击即完成
|
||||||
|
Row() {
|
||||||
|
Text('')
|
||||||
|
.width(18)
|
||||||
|
.height(18)
|
||||||
|
.borderRadius(9)
|
||||||
|
.border({ width: 2, color: item.color })
|
||||||
|
}
|
||||||
|
.padding({ right: 2 })
|
||||||
|
.onClick(() => {
|
||||||
|
this.checkTodo(item.id);
|
||||||
|
})
|
||||||
|
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(item.title)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Text(item.subText)
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor('#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 6, right: 8, top: 6, bottom: 6 })
|
||||||
|
.borderRadius(10)
|
||||||
|
.backgroundColor('#F5F7FA')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部标题栏 */
|
||||||
|
@Builder
|
||||||
|
headerBar() {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Text('待办')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
Text(`${this.todoCount}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor('#6A4C93')
|
||||||
|
.borderRadius(9)
|
||||||
|
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
|
||||||
|
}
|
||||||
|
Text(this.todoTodayCount > 0
|
||||||
|
? `${this.dateMd} ${this.weekday} · 今日到期 ${this.todoTodayCount} 项`
|
||||||
|
: `${this.dateMd} ${this.weekday} · 今日无到期`)
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
this.addButton()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空态 */
|
||||||
|
@Builder
|
||||||
|
emptyState() {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('✓')
|
||||||
|
.fontSize(30)
|
||||||
|
.fontColor('#C7C7CC')
|
||||||
|
Text(this.todoCount === 0 ? '所有待办都完成了' : '暂无待办')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor('#8A8A8A')
|
||||||
|
Text('点右上角 + 新建')
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor('#B0B0B0')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
this.headerBar()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.strokeWidth(0.5)
|
||||||
|
.color('#E5E5E5')
|
||||||
|
|
||||||
|
if (this.parseTodos().length === 0) {
|
||||||
|
this.emptyState()
|
||||||
|
} else {
|
||||||
|
// List 虚拟化:待办多时滑动流畅
|
||||||
|
List({ space: 4 }) {
|
||||||
|
ForEach(this.parseTodos(), (item: TodoItem4x4, idx: number) => {
|
||||||
|
ListItem() {
|
||||||
|
this.todoRow(item)
|
||||||
|
}
|
||||||
|
}, (item: TodoItem4x4, idx: number) => `t${item.id}_${idx}`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding(12)
|
||||||
|
.backgroundColor('#FFFFFF')
|
||||||
|
.borderRadius(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// entry/src/main/ets/pages/widget/TodoWidget6x4.ets
|
||||||
|
// 6x4 待办服务卡片:4x4 的横向扩展。左侧 = 统计概览,右侧 = 可滚动的待办列表(更多条目)。
|
||||||
|
// 每条待办左侧圆形勾选框,点击即完成并推送回服务器。
|
||||||
|
let storageTodo6x4 = new LocalStorage();
|
||||||
|
|
||||||
|
/** 待办卡片单条(解析 todoListJson) */
|
||||||
|
class TodoItem6x4 {
|
||||||
|
id: number = 0;
|
||||||
|
title: string = '';
|
||||||
|
dueText: string = '';
|
||||||
|
/** 副行:截止时间 · 日历本 */
|
||||||
|
subText: string = '';
|
||||||
|
color: string = '#007DFF';
|
||||||
|
calName: string = '';
|
||||||
|
completed: boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry(storageTodo6x4)
|
||||||
|
@Component
|
||||||
|
struct TodoWidget6x4Card {
|
||||||
|
@LocalStorageProp('dateMd') dateMd: string = '';
|
||||||
|
@LocalStorageProp('weekday') weekday: string = '';
|
||||||
|
@LocalStorageProp('todoCount') todoCount: number = 0;
|
||||||
|
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
|
||||||
|
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
|
||||||
|
|
||||||
|
private parseTodos(): TodoItem6x4[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(this.todoListJson) as TodoItem6x4[];
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
addButton() {
|
||||||
|
Button() {
|
||||||
|
Text('+')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(30)
|
||||||
|
.height(30)
|
||||||
|
.borderRadius(15)
|
||||||
|
.padding(0)
|
||||||
|
.backgroundColor('#6A4C93')
|
||||||
|
.onClick(() => {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'router',
|
||||||
|
abilityName: 'EntryAbility',
|
||||||
|
params: { widgetAction: 'addTodo' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private openApp(): void {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'router',
|
||||||
|
abilityName: 'EntryAbility',
|
||||||
|
params: { widgetAction: 'openTodo' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkTodo(id: number): void {
|
||||||
|
postCardAction(this, {
|
||||||
|
action: 'message',
|
||||||
|
params: { todoCheck: `${id}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
checkbox(id: number) {
|
||||||
|
// ⚠️ 不用 Circle().stroke() —— 那是 SDK 26 的 API,本工程 compatibleSdkVersion 为
|
||||||
|
// 6.1.1(24),真机上 stroke 不生效会导致圆圈不可见。
|
||||||
|
// 改用 border + 50% 圆角,视觉一致且从 API 7 起就支持。
|
||||||
|
Column()
|
||||||
|
.width(20)
|
||||||
|
.height(20)
|
||||||
|
.borderRadius(10)
|
||||||
|
.border({ width: 1.5, color: '#B0B0B0' })
|
||||||
|
.backgroundColor(Color.Transparent)
|
||||||
|
.width(20)
|
||||||
|
.height(20)
|
||||||
|
.onClick(() => this.checkTodo(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条待办:与 App 内 Index.todoRow 同款结构 ——
|
||||||
|
* 圆圈(颜色跟随日历本)+ 标题 + 副行「截止时间 · 日历本」
|
||||||
|
*
|
||||||
|
* ⚠️ 未完成=空心环、已完成=实心+✓,这套语义与 App 内一致;
|
||||||
|
* 卡片上只显示未完成,所以这里主要是空心环。
|
||||||
|
* ⚠️ 4×4 与 6×4 的 todoRow 必须**完全一致**(用户要求),改一处要同步另一处。
|
||||||
|
*/
|
||||||
|
@Builder
|
||||||
|
todoRow(item: TodoItem6x4) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
// 勾选圈:颜色用日历本色(与 App 内一致),点击即完成
|
||||||
|
Row() {
|
||||||
|
Text('')
|
||||||
|
.width(18)
|
||||||
|
.height(18)
|
||||||
|
.borderRadius(9)
|
||||||
|
.border({ width: 2, color: item.color })
|
||||||
|
}
|
||||||
|
.padding({ right: 2 })
|
||||||
|
.onClick(() => {
|
||||||
|
this.checkTodo(item.id);
|
||||||
|
})
|
||||||
|
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(item.title)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Text(item.subText)
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor('#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 6, right: 8, top: 6, bottom: 6 })
|
||||||
|
.borderRadius(10)
|
||||||
|
.backgroundColor('#F5F7FA')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
headerBar() {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Text('待办')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#1A1A1A')
|
||||||
|
Text(`${this.todoCount}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor('#6A4C93')
|
||||||
|
.borderRadius(9)
|
||||||
|
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
|
||||||
|
}
|
||||||
|
Text(this.todoTodayCount > 0
|
||||||
|
? `${this.dateMd} ${this.weekday} · 今日到期 ${this.todoTodayCount} 项`
|
||||||
|
: `${this.dateMd} ${this.weekday} · 今日无到期`)
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
|
||||||
|
.maxLines(1)
|
||||||
|
.layoutWeight(1)
|
||||||
|
this.addButton()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
emptyState() {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('✓')
|
||||||
|
.fontSize(30)
|
||||||
|
.fontColor('#C7C7CC')
|
||||||
|
Text(this.todoCount === 0 ? '所有待办都完成了' : '暂无待办')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor('#8A8A8A')
|
||||||
|
Text('点右上角 + 新建')
|
||||||
|
.fontSize(10)
|
||||||
|
.fontColor('#B0B0B0')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
this.headerBar()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.strokeWidth(0.5)
|
||||||
|
.color('#E5E5E5')
|
||||||
|
|
||||||
|
if (this.parseTodos().length === 0) {
|
||||||
|
this.emptyState()
|
||||||
|
} else {
|
||||||
|
List({ space: 4 }) {
|
||||||
|
ForEach(this.parseTodos(), (item: TodoItem6x4, idx: number) => {
|
||||||
|
ListItem() {
|
||||||
|
this.todoRow(item)
|
||||||
|
}
|
||||||
|
}, (item: TodoItem6x4, idx: number) => `t${item.id}_${idx}`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding(12)
|
||||||
|
.backgroundColor('#FFFFFF')
|
||||||
|
.borderRadius(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,10 @@ struct Widget4x2Card {
|
|||||||
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
|
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
|
||||||
@LocalStorageProp('selectedDate') selectedDate: number = 0;
|
@LocalStorageProp('selectedDate') selectedDate: number = 0;
|
||||||
|
|
||||||
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */
|
/** 右侧列表数据(可滚动;数据侧已按时间升序并限制在 15 条内) */
|
||||||
private parseOngoing(): CardItem2x4[] {
|
private parseOngoing(): CardItem2x4[] {
|
||||||
try {
|
try {
|
||||||
const all: CardItem2x4[] = JSON.parse(this.ongoingJson) as CardItem2x4[];
|
return JSON.parse(this.ongoingJson) as CardItem2x4[];
|
||||||
return all.slice(0, 2);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -102,7 +101,7 @@ struct Widget4x2Card {
|
|||||||
}
|
}
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
|
.padding({ left: 6, right: 8, top: 4, bottom: 4 })
|
||||||
.borderRadius(8)
|
.borderRadius(8)
|
||||||
.backgroundColor('#F5F7FA')
|
.backgroundColor('#F5F7FA')
|
||||||
}
|
}
|
||||||
@@ -183,19 +182,24 @@ struct Widget4x2Card {
|
|||||||
.strokeWidth(0.5)
|
.strokeWidth(0.5)
|
||||||
.color('#E5E5E5')
|
.color('#E5E5E5')
|
||||||
|
|
||||||
// ===== 右半:当前时间之后最近的两条日程(不滚动)=====
|
// ===== 右半:未来日程列表(可滚动,最多 15 条)=====
|
||||||
|
// ⭐ 原来固定 Column + 只取 2 条,用户想看后天的安排就看不到。
|
||||||
|
// 改为 List:自带虚拟化与滚动惯性,条目多也不占内存。
|
||||||
Column() {
|
Column() {
|
||||||
if (this.parseOngoing().length === 0) {
|
if (this.parseOngoing().length === 0) {
|
||||||
this.emptyHint()
|
this.emptyHint()
|
||||||
} else {
|
} else {
|
||||||
Column({ space: 6 }) {
|
List({ space: 4 }) {
|
||||||
ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => {
|
ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => {
|
||||||
|
ListItem() {
|
||||||
this.ongoingRow(item)
|
this.ongoingRow(item)
|
||||||
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`)
|
}
|
||||||
|
}, (item: CardItem2x4, idx: number) => `o${idx}_${item.time}_${item.title}`)
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.justifyContent(FlexAlign.Center)
|
.scrollBar(BarState.Off)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
{ "name": "grid_line", "value": "#14000000" },
|
{ "name": "grid_line", "value": "#14000000" },
|
||||||
{ "name": "today_bg", "value": "#14007DFF" },
|
{ "name": "today_bg", "value": "#14007DFF" },
|
||||||
{ "name": "selected_bg", "value": "#007DFF" },
|
{ "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" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,26 @@
|
|||||||
{
|
{
|
||||||
"name": "sync_work_desc",
|
"name": "sync_work_desc",
|
||||||
"value": "日历后台兜底同步任务"
|
"value": "日历后台兜底同步任务"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "todo_card_2x2_name",
|
||||||
|
"value": "待办 2×2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "todo_card_2x4_name",
|
||||||
|
"value": "待办 2×4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "todo_card_4x4_name",
|
||||||
|
"value": "待办 4×4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "todo_card_6x4_name",
|
||||||
|
"value": "待办 6×4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "todo_card_desc",
|
||||||
|
"value": "待办服务卡片:查看未完成待办,可直接点击完成"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 |
@@ -16,7 +16,9 @@
|
|||||||
"scheduledUpdateTime": "07:30",
|
"scheduledUpdateTime": "07:30",
|
||||||
"updateDuration": 1,
|
"updateDuration": 1,
|
||||||
"defaultDimension": "2*2",
|
"defaultDimension": "2*2",
|
||||||
"supportDimensions": ["2*2"]
|
"supportDimensions": [
|
||||||
|
"2*2"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "WidgetCard2x4",
|
"name": "WidgetCard2x4",
|
||||||
@@ -34,7 +36,9 @@
|
|||||||
"scheduledUpdateTime": "07:30",
|
"scheduledUpdateTime": "07:30",
|
||||||
"updateDuration": 1,
|
"updateDuration": 1,
|
||||||
"defaultDimension": "2*4",
|
"defaultDimension": "2*4",
|
||||||
"supportDimensions": ["2*4"]
|
"supportDimensions": [
|
||||||
|
"2*4"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "WidgetCard4x4",
|
"name": "WidgetCard4x4",
|
||||||
@@ -52,7 +56,9 @@
|
|||||||
"scheduledUpdateTime": "07:30",
|
"scheduledUpdateTime": "07:30",
|
||||||
"updateDuration": 1,
|
"updateDuration": 1,
|
||||||
"defaultDimension": "4*4",
|
"defaultDimension": "4*4",
|
||||||
"supportDimensions": ["4*4"]
|
"supportDimensions": [
|
||||||
|
"4*4"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "WidgetCard6x4",
|
"name": "WidgetCard6x4",
|
||||||
@@ -70,7 +76,89 @@
|
|||||||
"scheduledUpdateTime": "07:30",
|
"scheduledUpdateTime": "07:30",
|
||||||
"updateDuration": 1,
|
"updateDuration": 1,
|
||||||
"defaultDimension": "6*4",
|
"defaultDimension": "6*4",
|
||||||
"supportDimensions": ["6*4"]
|
"supportDimensions": [
|
||||||
|
"6*4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TodoCard2x2",
|
||||||
|
"displayName": "$string:todo_card_2x2_name",
|
||||||
|
"description": "$string:todo_card_desc",
|
||||||
|
"src": "./ets/pages/widget/TodoWidget2x2.ets",
|
||||||
|
"window": {
|
||||||
|
"designWidth": 720,
|
||||||
|
"autoDesignWidth": true
|
||||||
|
},
|
||||||
|
"colorMode": "light",
|
||||||
|
"uiSyntax": "arkts",
|
||||||
|
"isDefault": false,
|
||||||
|
"updateEnabled": true,
|
||||||
|
"scheduledUpdateTime": "07:30",
|
||||||
|
"updateDuration": 1,
|
||||||
|
"defaultDimension": "2*2",
|
||||||
|
"supportDimensions": [
|
||||||
|
"2*2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TodoCard2x4",
|
||||||
|
"displayName": "$string:todo_card_2x4_name",
|
||||||
|
"description": "$string:todo_card_desc",
|
||||||
|
"src": "./ets/pages/widget/TodoWidget2x4.ets",
|
||||||
|
"window": {
|
||||||
|
"designWidth": 720,
|
||||||
|
"autoDesignWidth": true
|
||||||
|
},
|
||||||
|
"colorMode": "light",
|
||||||
|
"uiSyntax": "arkts",
|
||||||
|
"isDefault": false,
|
||||||
|
"updateEnabled": true,
|
||||||
|
"scheduledUpdateTime": "07:30",
|
||||||
|
"updateDuration": 1,
|
||||||
|
"defaultDimension": "2*4",
|
||||||
|
"supportDimensions": [
|
||||||
|
"2*4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TodoCard4x4",
|
||||||
|
"displayName": "$string:todo_card_4x4_name",
|
||||||
|
"description": "$string:todo_card_desc",
|
||||||
|
"src": "./ets/pages/widget/TodoWidget4x4.ets",
|
||||||
|
"window": {
|
||||||
|
"designWidth": 720,
|
||||||
|
"autoDesignWidth": true
|
||||||
|
},
|
||||||
|
"colorMode": "light",
|
||||||
|
"uiSyntax": "arkts",
|
||||||
|
"isDefault": false,
|
||||||
|
"updateEnabled": true,
|
||||||
|
"scheduledUpdateTime": "07:30",
|
||||||
|
"updateDuration": 1,
|
||||||
|
"defaultDimension": "4*4",
|
||||||
|
"supportDimensions": [
|
||||||
|
"4*4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TodoCard6x4",
|
||||||
|
"displayName": "$string:todo_card_6x4_name",
|
||||||
|
"description": "$string:todo_card_desc",
|
||||||
|
"src": "./ets/pages/widget/TodoWidget6x4.ets",
|
||||||
|
"window": {
|
||||||
|
"designWidth": 720,
|
||||||
|
"autoDesignWidth": true
|
||||||
|
},
|
||||||
|
"colorMode": "light",
|
||||||
|
"uiSyntax": "arkts",
|
||||||
|
"isDefault": false,
|
||||||
|
"updateEnabled": true,
|
||||||
|
"scheduledUpdateTime": "07:30",
|
||||||
|
"updateDuration": 1,
|
||||||
|
"defaultDimension": "6*4",
|
||||||
|
"supportDimensions": [
|
||||||
|
"6*4"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"pages/CalendarListPage",
|
"pages/CalendarListPage",
|
||||||
"pages/EditAccountPage",
|
"pages/EditAccountPage",
|
||||||
"pages/EventEditPage",
|
"pages/EventEditPage",
|
||||||
|
"pages/TodoEditPage",
|
||||||
"pages/SettingsPage"
|
"pages/SettingsPage"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
{ "name": "grid_line", "value": "#1FFFFFFF" },
|
{ "name": "grid_line", "value": "#1FFFFFFF" },
|
||||||
{ "name": "today_bg", "value": "#330A84FF" },
|
{ "name": "today_bg", "value": "#330A84FF" },
|
||||||
{ "name": "selected_bg", "value": "#0A84FF" },
|
{ "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