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 | ||
|
|
d993dd3d4b | ||
|
|
b7bc4b5403 | ||
|
|
96335728f5 | ||
|
|
a32c049199 | ||
|
|
6ab7bcfb76 | ||
|
|
8e0c08ccfb | ||
|
|
8be539d8a3 | ||
|
|
29898d5dff | ||
|
|
c711cdb7d5 | ||
|
|
2f124eada0 | ||
|
|
942f71bfdf | ||
|
|
24d8117ce7 | ||
|
|
8ae4e11507 | ||
|
|
c4dc27ef3a | ||
|
|
bd3afb7bd1 | ||
|
|
3025f92cb6 | ||
|
|
d890a2b053 | ||
|
|
19cfe3cf34 | ||
|
|
a49e7d03b1 | ||
|
|
ed26da8dc4 | ||
|
|
68329c57a0 | ||
|
|
2b29f197e8 | ||
|
|
da0249d9f4 | ||
|
|
17d7602d7d | ||
|
|
cd2401242c | ||
|
|
5e6f347fc6 | ||
|
|
5c80c0b773 | ||
|
|
d5e5d54d35 | ||
|
|
c76c78b8a6 | ||
|
|
32162b8f04 | ||
|
|
b83ce83d43 | ||
|
|
d0b8720c41 | ||
|
|
8b5519bc31 | ||
|
|
2f4657ced3 | ||
|
|
736a0a6587 |
+2
-2
@@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "synccalendar.yangyq.net",
|
||||
"vendor": "yangyq",
|
||||
"versionCode": 200,
|
||||
"versionName": "0.0.2",
|
||||
"versionCode": 10201,
|
||||
"versionName": "0.2.1",
|
||||
"buildVersion": "1",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
|
||||
@@ -1,48 +1,479 @@
|
||||
# SyncCalendar(同步日历)
|
||||
|
||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||
|
||||
- 应用市场送审版本:**0.2.0** | 仓库当前:**0.2.0**(versionCode 10200)
|
||||
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://feedlite.yangyq.net/?sw=synccalendar>
|
||||
- 开源协议: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 协议的完整同步:PROPFIND 发现日历本、REPORT 全量拉取、ETag 增量比对、PUT/DELETE 双向推送
|
||||
- 一个账号可挂载多个日历本,按需勾选同步范围,支持在线编辑账号、重选日历本、删除账号
|
||||
- 全链路同步引擎:拉取 → 解析(VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID)→ 本地库落库 → 推送变更
|
||||
|
||||
### 日历视图
|
||||
- **月 / 周 / 日 / 列表 / 待办** 五种视图自由切换
|
||||
- 月视图支持左右滑动翻月、农历显示(初一显示月名)、点击查看当日日程
|
||||
- 重复日程(RRULE)按规则完整展开,支持 DAILY / WEEKLY / MONTHLY / YEARLY 及 INTERVAL、BYDAY、UNTIL、EXDATE 等
|
||||
- 列表视图按天分组、今日置顶,配合 List 虚拟化保证流畅滚动
|
||||
- VTODO 待办独立展示,未完成 / 已完成分组,勾选框颜色跟随日历本
|
||||
- 完整协议链路:**PROPFIND** 发现日历本 → **REPORT** 全量拉取 → **ETag** 增量比对 → **PUT / DELETE** 双向推送
|
||||
- 一个账号可挂载多个日历本,按需勾选同步范围;支持在线编辑账号、重选日历本、删除账号
|
||||
- 解析 **VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID**,重复日程的**单次覆盖实例**也能正确还原
|
||||
- 完整 **VTODO 待办**支持:新建 / 编辑 / 打勾完成 / 删除,均可双向推送回服务器
|
||||
- 待办与日程分列:日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
|
||||
- 打勾完成会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT 回服务器
|
||||
- 修改时先 GET 原始 `.ics` 再原地改写,保留服务器上本应用不认识的属性
|
||||
- 「重建日程数据」可强制整表重拉,用于修复提醒丢失等本地数据问题
|
||||
|
||||
### 日历视图与交互
|
||||
|
||||
- **月 / 周 / 列表** 三种视图(顶部标签切换,也支持左右滑动手势);默认视图可在设置里指定
|
||||
- 月视图三页 Swiper 左右翻月、显示**农历**(初一显示月名),点击查看当日日程
|
||||
- 周视图支持按天左右滑动切换日期,跨月自动重载
|
||||
- 列表视图按天分组、今日置顶,配合 List 虚拟化保证长列表流畅
|
||||
- 日程显示方式两种:**竖向时间轴**(按时间刻度看一天的占用与冲突,带当前时间红线)/**列表**(快速扫读,适合长标题)
|
||||
- 重复日程(RRULE)按规则完整展开:DAILY / WEEKLY / MONTHLY / YEARLY 及 INTERVAL、BYDAY、UNTIL、EXDATE 等
|
||||
- 可选**混合显示系统日历**,并与 DAV 数据自动去重
|
||||
- 点击日程里的「地点」可**拉起高德 App 路线规划**(系统地理编码 + WGS84→GCJ02 坐标转换,地址文本不落日志)
|
||||
|
||||
### 服务卡片
|
||||
- 四种规格:**2x2 / 2x4 / 4x4 / 6x4**,时间轴样式设计
|
||||
### 日程编辑与提醒
|
||||
|
||||
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
|
||||
- **地点搜索补全**:在「地点」栏输入关键词即可列出匹配地点(华为 Map Kit / Site Kit),点一下填入完整地址;也可点「地图」进入系统地图界面搜索、拖动图钉选点。**不申请任何定位权限**,手动输入同样可用
|
||||
- **一键导航**:日程详情中点击「导航 ›」,用系统地理编码把地点地址换算为坐标后唤起高德地图;未安装则回退网页版。**不申请定位权限**(`getAddressesFromLocationName` 无需该权限),不会读取设备实时位置
|
||||
- **必填项校验强提醒**:未填标题等必填项时,以 Toast + 输入框标红 + 自动滚回定位三管齐下提示,不会出现"提示在屏幕外看不到"的情况
|
||||
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
|
||||
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
||||
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
|
||||
|
||||
### CalDAV 地址自动发现
|
||||
|
||||
- 只填服务器域名也能用:按标准 **`/.well-known/caldav`** 自动发现端点,并在常见路径(Nextcloud、群晖等)中依次尝试
|
||||
- 发现时只认 `resourcetype` 含 `calendar` 的集合,避免把 DAV 根或分享链接误当成日历
|
||||
- 失败时给出**人话错误提示**(如"正确形式通常为 `https://域名/remote.php/dav/calendars/用户名/`"),而不是抛原始状态码
|
||||
|
||||
### 系统日历镜像(可选增强,默认关闭)
|
||||
|
||||
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
|
||||
|
||||
- **镜像到系统日历**:把选中的 CalDAV 日历本写入系统日历,这样**小艺语音、桌面日历卡片和手表**也能看到你的日程。每个日历本在系统日历里是**独立账户**,可单独设色与隐藏
|
||||
- **把系统日历的改动回写**:你在系统日历里新建、修改、删除的日程,同步回指定的 CalDAV 日历本(相当于把本机日程备份到服务器)
|
||||
- 采用**星型架构**:服务器 ↔ 本地库、本地库 ↔ 系统日历各做一次两方同步,不会互相污染
|
||||
- 日历本用**稳定的 bookId**(`账号ID-资源路径哈希`)标识,不随勾选顺序变化;取消勾选、目标失效时会自动关闭备份并把相关账户清理干净,绝不把日程写进你没选过的日历本
|
||||
- 重复日程不写入系统 `recurrenceRule`,而是按时间窗口自行展开(过去 1 年 ~ 未来 2 年),保证 `RECURRENCE-ID` 覆盖与 `EXDATE` 排除精确对应
|
||||
|
||||
### 桌面服务卡片
|
||||
|
||||
- 四种规格:**2×2 / 2×4 / 4×4 / 6×4**,时间轴样式设计
|
||||
- 按天分组展示未来日程,组内全天事件置顶;跨天日程智能归入"今天",标注"全天"
|
||||
- 每条日程左侧竖条颜色与日历本一致,右侧显示所属日历本名
|
||||
- 同步完成后自动刷新,formId 持久化保证 App 重启后推送不丢
|
||||
- 同步完成后自动刷新,`formId` 持久化保证 App 重启后推送不丢
|
||||
|
||||
### 日程提醒
|
||||
- 自动读取服务器端 VALARM 提醒(如"开始前 10 分钟")
|
||||
- 基于**系统级代理提醒**(reminderAgentManager)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
||||
### 后台同步与数据备份
|
||||
|
||||
- **前台自动同步**:App 在前台时按设定间隔自动同步(可设 1 / 5 / 15 / 30 分钟)
|
||||
- **后台自动同步**:开启后由系统定时唤起同步,退到后台也会继续(受系统省电策略限制,**最短约 2 小时一次**)
|
||||
- **回到前台 / 网络恢复补同步**:切回应用、或从断网恢复到有网时,**立即补同步一次**,保证你打开日历时数据总是最新的
|
||||
- **应用数据备份/恢复**:接入 Backup 扩展能力,换机时可恢复账号与本地数据
|
||||
|
||||
> 说明:HarmonyOS 出于省电考虑,**不允许普通应用在后台常驻并高频轮询**
|
||||
> (长时任务 `dataTransfer` 超过 10 分钟无进度即被系统取消,延迟任务存在小时级下限)。
|
||||
> 本应用遵循系统机制,采用「前台按间隔同步 + 后台定时唤起 + 回前台/网络恢复即时补同步」的分层组合。
|
||||
|
||||
### 界面与适配
|
||||
|
||||
- **深色模式**:全程使用资源引用(`$r('app.color.*')`),深浅两套色板一一对应,跟随系统自动切换
|
||||
- **沉浸式**:系统状态栏与导航栏全透明,每页(含内置文档页)都做了安全区扩展
|
||||
- **大屏与横竖屏**:以**窗口短边是否达到 840vp** 作为大屏判据(而非屏幕方向),因此折叠屏展开态、平板可自由横竖屏切换,而在屏幕比例接近 1:1 的设备上旋转时布局保持一致
|
||||
- **转场动效**:所有页面使用系统转场,淡入淡出 + 弹簧曲线(`curves.springMotion`),不做单帧直切、左右平移或上下位移
|
||||
|
||||
### 设置项一览
|
||||
|
||||
**系统日历**
|
||||
- 镜像到系统日历(含「选择要镜像的日历本」)
|
||||
- 把系统日历的改动回写
|
||||
- 系统日历中的显示名称(三种命名格式可选)
|
||||
- 混合显示系统日历(关闭后只显示 DAV 账号的日程)
|
||||
- 清除系统日历数据(卸载前清理镜像数据,防止残留孤儿账户)
|
||||
|
||||
**通用**
|
||||
- 默认视图(月 / 周 / 列表)
|
||||
- 日程显示方式(竖向时间轴 / 列表)
|
||||
- 自动同步间隔(1 / 5 / 15 / 30 分钟)
|
||||
- 后台自动同步
|
||||
- 通知权限
|
||||
|
||||
**数据**
|
||||
- 重建日程数据(强制整表重拉)
|
||||
- 重置账号库
|
||||
- 日历本只读 / 静音(逐本设置)
|
||||
|
||||
**关于**
|
||||
- 软件特性 · 使用帮助 · 问题反馈 · 隐私政策与用户协议
|
||||
|
||||
---
|
||||
|
||||
## 🔒 隐私
|
||||
|
||||
我们遵循"**数据自持、最小必要**"原则:
|
||||
|
||||
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机,且以 **AES-256-GCM** 加密存储,密钥由系统 HUKS/TEE 保管、不可导出)
|
||||
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
|
||||
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
|
||||
- **不申请定位权限**:本应用从不读取设备实时位置。「一键导航」所需的坐标由系统地理编码服务按**你填写的地址文本**换算,故 `module.json5` 中不含 `LOCATION` / `APPROXIMATELY_LOCATION`
|
||||
- 地点关键词检索由 HarmonyOS 系统能力**华为地图服务**(Map Kit / Site Kit)提供,仅用于检索,不用于广告、画像或统计分析
|
||||
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
||||
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
|
||||
|
||||
---
|
||||
|
||||
## 🛠 技术要点
|
||||
|
||||
- **架构**:SyncEngine 同步引擎 + DavClient(HTTP/WebDAV)+ EventDb(relationalStore 本地库)+ CalendarDataService(多源合并展示)
|
||||
- **服务卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动卡片渲染
|
||||
- **农历算法**:1901–2099 压缩表推算
|
||||
- **权限**:INTERNET / READ_CALENDAR / WRITE_CALENDAR / PUBLISH_AGENT_REMINDER
|
||||
- **架构**:`SyncEngine` 同步引擎 + `DavClient`(HTTP / WebDAV)+ `EventDb`(relationalStore 本地库)+ `CalendarDataService`(多源合并展示)
|
||||
- **镜像**:`SystemCalendarMirror`(出站写入)+ `MirrorInbound`(入站回写)+ `MirrorSnapshot`(变更快照)
|
||||
- **后台调度**:`BackgroundSyncRunner`(后台同步唯一执行体)+ `BackgroundSyncService`(延迟任务/短时任务)+ `NetworkSyncWatcher`(网络恢复补同步)
|
||||
- **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
|
||||
- **农历**:`LunarUtil`,1901–2099 压缩表推算
|
||||
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
|
||||
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER
|
||||
- ⚠️ **LOCATION 已于 0.1.0 移除**:`geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,地理编码不需要定位权限;应用也从不调用 `getCurrentLocation`。
|
||||
隐私政策/隐私标签里**不得**再声称读取位置。
|
||||
|
||||
## 📦 快速开始
|
||||
## 🔐 权限清单与用途
|
||||
|
||||
1. 使用 DevEco Studio 打开项目,连接鸿蒙真机或模拟器
|
||||
2. 编译安装后进入「≡ → 账号管理」,添加你的 CalDAV 服务器地址、用户名和密码
|
||||
| 权限 | 类型 | 用途 | 何时申请 |
|
||||
|---|---|---|---|
|
||||
| `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,隐私政策中**不得**再出现"读取位置"之类表述。
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
SyncCalendar/
|
||||
├─ AppScope/ 应用级配置(包名、版本号)
|
||||
└─ entry/src/main/
|
||||
├─ ets/
|
||||
│ ├─ common/ 核心能力
|
||||
│ │ ├─ SyncEngine.ets 同步引擎(拉取 → 解析 → 落库 → 推送)
|
||||
│ │ ├─ DavClient.ets CalDAV / WebDAV 协议客户端
|
||||
│ │ ├─ EventDb.ets relationalStore 本地库
|
||||
│ │ ├─ IcsUtil.ets ICS 解析与生成
|
||||
│ │ ├─ RruleUtil.ets 重复规则展开
|
||||
│ │ ├─ CalendarDataService.ets 多源合并、系统日历读取
|
||||
│ │ ├─ SystemCalendarMirror.ets / MirrorInbound.ets / MirrorSnapshot.ets
|
||||
│ │ ├─ ReminderService.ets 代理提醒
|
||||
│ │ ├─ CardDataService.ets 卡片数据
|
||||
│ │ ├─ AppSettings.ets / AccountStore.ets
|
||||
│ │ └─ LunarUtil.ets / TimelineUtil.ets / DocViewer.ets / TipsPanel.ets …
|
||||
│ ├─ pages/ Index / Accounts / AddAccount / CalendarList /
|
||||
│ │ EditAccount / EventEdit / Settings + widget/*
|
||||
│ ├─ entryability/ 主能力
|
||||
│ ├─ entrybackupability/ 应用数据备份恢复
|
||||
│ ├─ entryformability/ 桌面卡片
|
||||
│ └─ syncwork/ 后台定时同步(WorkScheduler 扩展能力)
|
||||
└─ resources/ base / dark 两套资源与颜色
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 构建与运行
|
||||
|
||||
### 一、准备工程
|
||||
|
||||
1. 安装 **DevEco Studio**(建议 5.0 及以上,需配套 HarmonyOS NEXT SDK)
|
||||
2. 用 DevEco Studio 打开工程根目录,等待自动同步(`ohpm install`)完成
|
||||
3. 连接鸿蒙真机(需 HarmonyOS NEXT)或模拟器
|
||||
|
||||
### 二、首次构建:先补上签名配置(重要)
|
||||
|
||||
> **为什么需要自己配?**
|
||||
> 鸿蒙应用在编译安装与上架时都必须签名。工程根目录的 **`build-profile.json5`** 里记录了
|
||||
> 签名文件的**绝对路径与加密口令**,属于机器相关且敏感的凭据,因此该文件**被 `.gitignore` 排除、不在仓库中**。
|
||||
> 你从仓库克隆下来的代码默认**不含任何签名信息**,直接编译会失败。下面是补全方法。
|
||||
|
||||
#### 1. 需要准备哪些文件
|
||||
|
||||
签名一共涉及 **4 个文件**,全部放在仓库**之外**的某个目录里(例如 `D:\DevEcoSign\`):
|
||||
|
||||
| 文件 | 格式 | 说明 |
|
||||
|---|---|---|
|
||||
| 密钥库 | `.p12` | 存放你的私钥与证书链,**最关键、绝不能泄露** |
|
||||
| 证书 | `.cer` | 与密钥库配套的应用证书 |
|
||||
| Profile | `.p7b` | 授权档案,决定该包能装到哪些设备 / 能否上架 |
|
||||
| (口令) | — | 密钥库口令、私钥口令(在生成时自己设定,请务必记牢) |
|
||||
|
||||
#### 2. 这些文件怎么申请、怎么得到
|
||||
|
||||
有两种途径,按你的目的选一种:
|
||||
|
||||
**途径 A:调试用(自用真机安装)—— 全自动,推荐**
|
||||
|
||||
DevEco Studio 可以一键生成并自动完成签名配置,无需手工操作:
|
||||
|
||||
1. 打开 `File → Project Structure → Project → Signing Configs`
|
||||
2. 勾选 **`Automatically generate signature`**(自动生成签名)
|
||||
3. 按提示登录你的**华为开发者账号**(需先在 [AppGallery Connect](https://developer.huawei.com/consumer/cn/) 完成实名认证)
|
||||
4. 等待自动完成——DevEco 会替你:生成 `.p12` 密钥库与 `.cer` 证书 → 向华为**申请调试 Profile(`.p7b`)**
|
||||
并自动注册当前设备 → 把 `signingConfigs` 写入 `build-profile.json5`
|
||||
5. 生成的文件默认落在 `C:\Users\<你>\.ohos\config\` 下
|
||||
|
||||
> 这是最省事的方式,**只要一个已实名认证的华为开发者账号**即可,不需要额外审批。
|
||||
|
||||
**途径 B:上架用(发布到应用市场)—— 需在 AGC 后台申请**
|
||||
|
||||
若要打**上架包**(`.app`),必须使用**发布证书 + 发布 Profile**,需手动申请:
|
||||
|
||||
1. 登录 [AppGallery Connect](https://developer.huawei.com/consumer/cn/),进入
|
||||
`用户与访问 → 证书管理`,创建并下载:
|
||||
- **发布证书(`.cer`)**——需先在本机用 `keytool` 或 DevEco 生成一个 `.p12` 密钥库,
|
||||
再上传其生成的 **CSR 文件**申请证书;
|
||||
- **发布 Profile(`.p7b`)**——在 `证书、APP ID 和 Profile → Profile` 中新建,
|
||||
类型选 **发布(Release)**,关联上面的证书与你的 App ID。
|
||||
2. 记住创建密钥库时设定的**密钥库口令**与**私钥口令**。
|
||||
|
||||
> 官方文档入口:AppGallery Connect → 帮助中心 → **「配置应用签名」** / 「证书管理」。
|
||||
|
||||
#### 3. 在哪个文件里填写
|
||||
|
||||
在**仓库根目录**新建 `build-profile.json5`(就是被忽略的那份),填入你的签名信息。
|
||||
可直接复制仓库中的模板 **`build-profile.example.json5`** 改名为 `build-profile.json5` 后使用,格式如下:
|
||||
|
||||
```json5
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [
|
||||
{
|
||||
// 调试配置:真机安装用这项
|
||||
"name": "default",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "D:/DevEcoSign/your.p12", // ← 改成你的密钥库绝对路径
|
||||
"storePassword": "<你的密钥库口令>", // ← 自动生成时是一段密文
|
||||
"keyAlias": "your_key_alias", // ← 密钥别名(生成时指定)
|
||||
"keyPassword": "<你的私钥口令>", // ← 同上
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "D:/DevEcoSign/yourDebug.p7b", // ← 调试 Profile
|
||||
"certpath": "D:/DevEcoSign/yourDebug.cer" // ← 配套证书
|
||||
}
|
||||
},
|
||||
{
|
||||
// 上架配置:打 .app 包用这项(真机安装勿用)
|
||||
"name": "release",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "D:/DevEcoSign/your.p12",
|
||||
"storePassword": "<你的密钥库口令>",
|
||||
"keyAlias": "your_key_alias",
|
||||
"keyPassword": "<你的私钥口令>",
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "D:/DevEcoSign/yourRelease.p7b", // ← 发布 Profile
|
||||
"certpath": "D:/DevEcoSign/yourRelease.cer" // ← 发布证书
|
||||
}
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
"signingConfig": "default", // 用哪套签名:debug 用 "default",上架改 "release"
|
||||
"targetSdkVersion": "26.0.0",
|
||||
"compatibleSdkVersion": "6.1.1(24)",
|
||||
"runtimeOS": "HarmonyOS",
|
||||
"buildOption": {
|
||||
"strictMode": { "caseSensitiveCheck": true, "useNormalizedOHMUrl": true }
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildModeSet": [ { "name": "debug" }, { "name": "release" } ]
|
||||
},
|
||||
"modules": [
|
||||
{ "name": "entry", "srcPath": "./entry",
|
||||
"targets": [ { "name": "default", "applyToProducts": [ "default" ] } ] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键位置说明:**
|
||||
|
||||
| 位置 | 作用 |
|
||||
|---|---|
|
||||
| `signingConfigs[].material.*` | 四个签名文件的**绝对路径**与两个口令 |
|
||||
| `products[0].signingConfig` | 选哪套签名:真机安装填 `"default"`,打上架包填 `"release"` |
|
||||
|
||||
> ⚠️ **`build-profile.json5` 已在 `.gitignore` 中,请勿提交**。
|
||||
> 若用「途径 A」自动生成,DevEco 会自动创建并填好该文件,你通常无需手写。
|
||||
> 注意:`entry/build-profile.json5` 是**模块级**普通构建配置(不含密钥),它在仓库中是**需要保留**的,两者不要混淆。
|
||||
|
||||
#### 4. 一个更省事的替代做法
|
||||
|
||||
如果不想手写,也可以直接在 DevEco Studio 里操作:
|
||||
`File → Project Structure → Project → Signing Configs`,勾选 `Automatically generate signature`
|
||||
并登录华为开发者账号——DevEco 会自动把 `build-profile.json5` 生成好,随后直接编译即可。
|
||||
|
||||
### 三、编译运行
|
||||
|
||||
1. 点击 DevEco Studio 的 **Run ▶**,或执行:
|
||||
|
||||
```bash
|
||||
hvigorw assembleHap --mode module -p product=default
|
||||
```
|
||||
|
||||
2. 安装后进入「≡ → 账号管理」,添加 CalDAV 服务器地址、用户名和密码
|
||||
3. 选择要同步的日历本,等待首次同步完成
|
||||
4. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
|
||||
|
||||
### 四、打上架包(App Pack)
|
||||
|
||||
应用市场上架需要提交 **`.app`(App Pack)**,而不是 `.hap`(`.hap` 仅用于真机侧载):
|
||||
|
||||
```bash
|
||||
hvigorw assembleApp --mode project -p product=default -p buildMode=release
|
||||
```
|
||||
|
||||
- 产物:`build/outputs/default/SyncCalendar-default-signed.app`
|
||||
- **前提**:请先把 `products[0].signingConfig` 改为 `"release"`(用发布证书 + 发布 Profile),
|
||||
否则会因签名不匹配而无法通过审核。
|
||||
- 校验签名(可选,`-inForm zip` 不可省,否则会误报 `can not find codesign block`):
|
||||
|
||||
```bash
|
||||
java -jar hap-sign-tool.jar verify-app -inForm zip \
|
||||
-inFile SyncCalendar-default-signed.app \
|
||||
-outCertChain out.cer -outProfile out.p7b
|
||||
```
|
||||
|
||||
出现 `verify-app success` 即表示签名有效。
|
||||
|
||||
---
|
||||
|
||||
## 📷 截图
|
||||
|
||||
| 月视图 | 周视图 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| 列表 / 时间轴 | 日程详情 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| 设置 | 账号管理 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| 桌面卡片 | 深色模式 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 官方网站 | <https://synccalendar.yangyq.net> |
|
||||
| 使用帮助 | <https://synccalendar.yangyq.net/uh.html> |
|
||||
| 软件特性 | <https://synccalendar.yangyq.net/sf.html> |
|
||||
| 隐私政策 | <https://synccalendar.yangyq.net/upa.html> |
|
||||
| 用户服务协议 | <https://synccalendar.yangyq.net/usa.html> |
|
||||
| 问题反馈 | <https://feedlite.yangyq.net/?sw=synccalendar> |
|
||||
|
||||
网站备案:**鲁ICP备17014997号-6A**
|
||||
|
||||
---
|
||||
|
||||
## 📄 开源说明
|
||||
|
||||
本项目仅供学习交流使用。
|
||||
本项目仅供学习交流使用,采用 **Apache License 2.0** 协议,详见 [LICENSE](LICENSE)。
|
||||
|
||||
该项目使用人工智能辅助完成。
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* build-profile.json5 模板(示例)
|
||||
*
|
||||
* 用法:
|
||||
* 1. 把本文件复制为同目录下的 build-profile.json5
|
||||
* 2. 按注释把 <尖括号> 内容替换成你自己的签名信息
|
||||
* 3. 不要提交生成后的 build-profile.json5(它已在 .gitignore 中)
|
||||
*
|
||||
* 4 个签名文件(.p12 / .cer / .p7b ×2)需要放在仓库之外的任意目录,
|
||||
* 这里用 D:/DevEcoSign/ 作为示例路径,请改成你自己的真实路径。
|
||||
*
|
||||
* 如何获取这些文件、在哪里填写,详见 README.md 的「📦 构建与运行 → 二、首次构建:先补上签名配置」。
|
||||
*
|
||||
* 最省事的替代方案:用 DevEco Studio 打开工程 →
|
||||
* File → Project Structure → Project → Signing Configs →
|
||||
* 勾选 "Automatically generate signature" 并登录华为开发者账号,
|
||||
* IDE 会自动生成并填好本文件,无需手工编辑。
|
||||
*/
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [
|
||||
{
|
||||
// ── 调试配置:真机安装 / 本地运行用这一项 ──
|
||||
"name": "default",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "D:/DevEcoSign/<你的密钥库>.p12",
|
||||
"storePassword": "<密钥库口令>",
|
||||
"keyAlias": "<密钥别名>",
|
||||
"keyPassword": "<私钥口令>",
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "D:/DevEcoSign/<你的调试Profile>.p7b",
|
||||
"certpath": "D:/DevEcoSign/<你的调试证书>.cer"
|
||||
}
|
||||
},
|
||||
{
|
||||
// ── 上架配置:打 .app 上架包用这一项(真机安装请勿使用)──
|
||||
"name": "release",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "D:/DevEcoSign/<你的密钥库>.p12",
|
||||
"storePassword": "<密钥库口令>",
|
||||
"keyAlias": "<密钥别名>",
|
||||
"keyPassword": "<私钥口令>",
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "D:/DevEcoSign/<你的发布Profile>.p7b",
|
||||
"certpath": "D:/DevEcoSign/<你的发布证书>.cer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
// 用哪套签名:真机安装填 "default";打上架包前改成 "release"
|
||||
"signingConfig": "default",
|
||||
"targetSdkVersion": "26.0.0",
|
||||
"compatibleSdkVersion": "6.1.1(24)",
|
||||
"runtimeOS": "HarmonyOS",
|
||||
"buildOption": {
|
||||
"strictMode": {
|
||||
"caseSensitiveCheck": true,
|
||||
"useNormalizedOHMUrl": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildModeSet": [
|
||||
{
|
||||
"name": "debug"
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
}
|
||||
]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"name": "entry",
|
||||
"srcPath": "./entry",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,6 +57,14 @@ export class DavAccount {
|
||||
calendarColors: string[] = [];
|
||||
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
|
||||
calendarWritable: string[] = [];
|
||||
/**
|
||||
* ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应:
|
||||
* 'event' 纯日历本(只有 VEVENT)
|
||||
* 'todo' 纯待办本(只有 VTODO)
|
||||
* 'both' 混合本
|
||||
* 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容)
|
||||
*/
|
||||
calendarComps: string[] = [];
|
||||
itemCount: number = 0;
|
||||
lastSyncTime: string = '';
|
||||
}
|
||||
@@ -69,6 +77,14 @@ export class CalSource {
|
||||
source: string = 'dav'; // 'dav' | 'system' | 'local'
|
||||
visible: boolean = true;
|
||||
writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑
|
||||
/**
|
||||
* 本支持哪些组件(来自服务器 COMP_SET 声明):
|
||||
* 'event' 纯日历本(新建日程可选,新建待办不可选)
|
||||
* 'todo' 纯待办本(反过来)
|
||||
* 'both' 两者都可
|
||||
* 'unknown' 服务器未声明 → 两边都按可用处理(旧数据兼容)
|
||||
*/
|
||||
comps: string = 'unknown';
|
||||
}
|
||||
|
||||
/** 与 UI 无关的调色板 */
|
||||
@@ -474,6 +490,9 @@ export class AccountStore {
|
||||
if (parts.length >= 12) {
|
||||
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
|
||||
}
|
||||
if (parts.length >= 13) {
|
||||
acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';');
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -565,6 +584,7 @@ export class AccountStore {
|
||||
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
|
||||
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
|
||||
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : [];
|
||||
a.calendarComps = o['calendarComps'] !== undefined ? o['calendarComps'] as string[] : [];
|
||||
a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
|
||||
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
|
||||
if (a.id === '') {
|
||||
@@ -699,4 +719,45 @@ export class AccountStore {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。
|
||||
*
|
||||
* 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`,
|
||||
* 于是调用方无法从返回值区分两种情况:
|
||||
* ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**);
|
||||
* ② 密文存在但解不开,或读取过程抛错(临时故障)。
|
||||
*
|
||||
* 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理,
|
||||
* 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。
|
||||
*
|
||||
* 判定口径(三者同时成立才算"确认空白"):
|
||||
* - 偏好里**没有**账号密文(`data_b64` 为空);
|
||||
* - 旧版明文存储也没有账号;
|
||||
* - 读取过程没有抛异常。
|
||||
*
|
||||
* @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过
|
||||
*/
|
||||
static async isEmptyVaultConfirmed(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
const store: preferences.Preferences = await Vault.prefs(context);
|
||||
const data: string = await store.get(VAULT_DATA, '') as string;
|
||||
if (data !== '') {
|
||||
// 有密文:只有能解出"0 个账号"才算确认空白;解不开一律视为拿不准
|
||||
const plain: string | null = await Vault.decrypt(context, data);
|
||||
if (plain === null) {
|
||||
return false;
|
||||
}
|
||||
const arr: Array<Record<string, Object>> = JSON.parse(plain) as Array<Record<string, Object>>;
|
||||
return arr.length === 0;
|
||||
}
|
||||
// 无密文:再看旧版明文存储是否还有残留账号
|
||||
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
|
||||
return legacy === null || legacy.length === 0;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`[AccountStore] isEmptyVaultConfirmed 判定失败(按"拿不准"处理): ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,32 @@ export class AppSettings {
|
||||
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
|
||||
private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync';
|
||||
private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup'
|
||||
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本
|
||||
// ⚠️ 备份目标**必须**用稳定标识 bookId 存(由服务器 href 派生,见 SystemCalendarMirror.listBooks)。
|
||||
// 老键存的是 calKey(`accId_序号`),序号会随"取消勾选某个日历本/服务器新增日历本"整体漂移
|
||||
// → 目标本会**悄悄变成另一个本**,把系统日历导进用户没选过的本(用户实测发现)。
|
||||
// KEY_BACKUP_KEY 仅保留用于老值一次性迁移,读到后立即换存 KEY_BACKUP_BOOK_ID 并清空。
|
||||
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 【已废弃】备份目标 calKey(仅迁移用)
|
||||
private static readonly KEY_BACKUP_BOOK_ID: string = 'sys_backup_book_id'; // ⭐ 备份目标稳定标识 bookId
|
||||
private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
|
||||
private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey
|
||||
private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳
|
||||
// ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(用于节流)。
|
||||
// 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1),
|
||||
// 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。
|
||||
private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at';
|
||||
/** ⭐ 诊断开关:卡片驱动同步的时间预算(毫秒)。0/未设置 = 用内置默认(见 BackgroundSyncRunner)。
|
||||
* 留给真机排查用 —— 不用重编译就能做"9 秒 vs 长窗口"对照实验。 */
|
||||
private static readonly KEY_CARD_BUDGET_MS: string = 'card_budget_ms';
|
||||
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
|
||||
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
|
||||
private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list'
|
||||
private static readonly KEY_MIRROR_ENABLED: string = 'mirror_enabled'; // 是否把选中 CalDAV 日历本镜像到系统日历(默认关)
|
||||
private static readonly KEY_MIRROR_KEYS: string = 'mirror_cal_keys'; // 要镜像的 DAV 日历本 calKey 列表
|
||||
private static readonly KEY_MIRROR_BOOK_IDS: string = 'mirror_book_ids'; // ⭐ 要镜像的日历本**稳定标识** bookId(由服务器 href 派生,不随序号变)
|
||||
private static readonly KEY_MIRROR_INBOUND: string = 'mirror_inbound'; // 是否把系统日历的改动回写 CalDAV(默认关)
|
||||
// ⭐ 系统日历账户的**显示名格式**(1/2/3,默认 3=`<账号>·<本名>`)。
|
||||
// 因平台无"改账户名"API,切换格式需删账户重建(用户显式点「应用」才执行)。
|
||||
private static readonly KEY_MIRROR_DISPLAY_FORMAT: string = 'mirror_display_format';
|
||||
private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议
|
||||
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
|
||||
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**——
|
||||
@@ -26,8 +45,13 @@ export class AppSettings {
|
||||
|
||||
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
|
||||
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
|
||||
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / LOCATION / APPROXIMATELY_LOCATION(均前台),
|
||||
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)与"添加/修改/删除日历活动"(WRITE_CALENDAR)。
|
||||
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(均前台)。
|
||||
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)。
|
||||
// ⚠️ 0.1.0 起已**移除 LOCATION / APPROXIMATELY_LOCATION**:实测本 SDK(API 26)的
|
||||
// `geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,
|
||||
// 地理编码不需要定位权限;且应用从不调用 getCurrentLocation,本就不读实时位置。
|
||||
// ⇒ 隐私政策/隐私标签里都**不得**再声称"读取设备精确位置或大致位置"。
|
||||
// ⇒ 若将来真要用实时定位,必须同时改 module.json5、本注释、upa.html 与 AGC 隐私标签。
|
||||
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
|
||||
/** 隐私政策网址 */
|
||||
static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html';
|
||||
@@ -37,11 +61,20 @@ export class AppSettings {
|
||||
static readonly FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html';
|
||||
/** 使用帮助网址 */
|
||||
static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.html';
|
||||
/**
|
||||
* ⭐ 问题反馈网址。
|
||||
*
|
||||
* 换到 FeedLite(多站点共用一套反馈平台),`?sw=` 指定本项目站点标识。
|
||||
* 旧地址 synccalendar.yangyq.net/feedback/ 是自建 PHP 论坛,
|
||||
* 里面还有 3 条历史反馈(含一条完整 PR 贡献),服务暂不删除。
|
||||
*/
|
||||
static readonly FEEDBACK_URL: string = 'https://feedlite.yangyq.net/?sw=synccalendar';
|
||||
/** 文档标题(内置浏览器标题栏用) */
|
||||
static readonly PRIVACY_TITLE: string = '隐私政策';
|
||||
static readonly AGREEMENT_TITLE: string = '用户服务协议';
|
||||
static readonly FEATURES_TITLE: string = '软件特性';
|
||||
static readonly HELP_TITLE: string = '使用帮助';
|
||||
static readonly FEEDBACK_TITLE: string = '问题反馈';
|
||||
|
||||
/**
|
||||
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
|
||||
@@ -149,6 +182,202 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否把选中的 CalDAV 日历本镜像到系统日历(默认关)。
|
||||
* ⚠️ 该开关必须与 WRITE_CALENDAR 授权状态一致:未授权即为关(权限按需申请约束)。
|
||||
*/
|
||||
static async getMirrorEnabled(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_MIRROR_ENABLED, false) as boolean;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorEnabled(context: common.Context, value: boolean): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_ENABLED, value);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存镜像开关失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **已废弃**:早期用 calKey 存"要镜像哪些本",但 calKey 是 `<账号ID>_<序号>`,
|
||||
* 日历本重排后序号会变 → 选中的本串成另一个本、镜像账户名也变 → 系统日历出现重复账户与串本。
|
||||
* 新代码一律用 `getMirrorBookIds()`。这里只为读取历史值做迁移。
|
||||
*/
|
||||
static async getMirrorKeys(context: common.Context): Promise<string[]> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const raw: string = await store.get(AppSettings.KEY_MIRROR_KEYS, '') as string;
|
||||
if (raw === '') {
|
||||
return [];
|
||||
}
|
||||
const arr: string[] = JSON.parse(raw) as string[];
|
||||
if (!Array.isArray(arr)) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (let i: number = 0; i < arr.length; i++) {
|
||||
const v: string = arr[i];
|
||||
if (typeof v === 'string' && v !== '') {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorKeys(context: common.Context, keys: string[]): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_KEYS, JSON.stringify(keys));
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存镜像日历本失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 要镜像到系统日历的日历本**稳定标识** bookId 列表,空 = 未选择。
|
||||
* bookId = `<账号ID>-<href哈希>`,由服务器分配的 href 派生 —— 日历本增删改序都不会变。
|
||||
*/
|
||||
static async getMirrorBookIds(context: common.Context): Promise<string[]> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const raw: string = await store.get(AppSettings.KEY_MIRROR_BOOK_IDS, '') as string;
|
||||
if (raw === '') {
|
||||
return [];
|
||||
}
|
||||
const arr: string[] = JSON.parse(raw) as string[];
|
||||
if (!Array.isArray(arr)) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (let i: number = 0; i < arr.length; i++) {
|
||||
const v: string = arr[i];
|
||||
if (typeof v === 'string' && v !== '') {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorBookIds(context: common.Context, ids: string[]): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_BOOK_IDS, JSON.stringify(ids));
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存镜像日历本(bookId)失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否把"用户在系统日历里的改动"回写到 CalDAV(**默认关**)。
|
||||
* ⚠️ 默认关是有意的:这个功能会写用户的服务器数据,首次上线必须先单向观察一轮再开。
|
||||
*/
|
||||
static async getMirrorInbound(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_MIRROR_INBOUND, false) as boolean;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorInbound(context: common.Context, value: boolean): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_INBOUND, value);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存回写开关失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 系统日历账户显示名格式:1=`同步日历·本名`,2=`同步日历·账号·本名`,3=`账号·本名`(默认)。
|
||||
* 只接受 1/2/3,其它值一律回落到默认 3(防止脏数据导致拼出意外名字)。
|
||||
*/
|
||||
static async getMirrorDisplayFormat(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const v = await store.get(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, 3) as number;
|
||||
return (v === 1 || v === 2 || v === 3) ? v : 3;
|
||||
} catch (err) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorDisplayFormat(context: common.Context, value: number): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, value);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存显示名格式失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(0 = 从未执行) */
|
||||
static async getCardSyncAt(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_CARD_SYNC_AT, 0) as number;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static async setCardSyncAt(context: common.Context, ts: number): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_CARD_SYNC_AT, ts);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存卡片同步时间失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ⭐ 诊断用:卡片驱动同步的时间预算(毫秒)。0 = 未设置(用内置默认值)。 */
|
||||
static async getCardBudgetMs(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_CARD_BUDGET_MS, 0) as number;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
|
||||
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
|
||||
@@ -286,9 +515,14 @@ export class AppSettings {
|
||||
if (muted2.length !== muted.length) {
|
||||
await AppSettings.setMutedReminderKeys(context, muted2);
|
||||
}
|
||||
const backupKey: string = await AppSettings.getBackupCalKey(context);
|
||||
if (backupKey.startsWith(`${accId}_`)) {
|
||||
await AppSettings.setBackupCalKey(context, '');
|
||||
// 备份目标指向该账号名下某个本 → 目标必失效:清空目标**并关闭备份功能**
|
||||
// (只清空目标会让"备份到 CalDAV"开关停在开着却什么都不做的状态,状态与实际不符)
|
||||
const bookId: string = await AppSettings.getBackupBookId(context);
|
||||
const legacyKey: string = await AppSettings.getBackupCalKey(context);
|
||||
if (bookId.startsWith(`${accId}-`) || legacyKey.startsWith(`${accId}_`)) {
|
||||
await AppSettings.clearBackupTarget(context);
|
||||
await AppSettings.setSysCalMode(context, 'display');
|
||||
console.log(`删除账号 ${accId}:其名下的系统日历备份目标已失效,备份功能自动关闭`);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
@@ -319,7 +553,12 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/** 备份目标日历本 calKey(accId_序号),空 = 未选择 */
|
||||
/**
|
||||
* 【已废弃,仅供老值迁移】备份目标 calKey(`accId_序号`)。
|
||||
* ⚠️ 不要再用它做判断!序号会漂移(见 KEY_BACKUP_KEY 处注释)。
|
||||
* 新代码一律用 `SystemCalendarMirror.backupTargetRef()` / `checkBackupTarget()`,
|
||||
* 它内部会把这个老值翻译成 bookId 并清空本键。
|
||||
*/
|
||||
static async getBackupCalKey(context: common.Context): Promise<string> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
@@ -330,18 +569,49 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/** 【已废弃】写老键。新代码请用 setBackupBookId()。 */
|
||||
static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_BACKUP_KEY, calKey);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存备份目标(老键)失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ⭐ 备份目标日历本的稳定标识 bookId(`<accId>-<href哈希>`),空 = 未选择 */
|
||||
static async getBackupBookId(context: common.Context): Promise<string> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_BACKUP_BOOK_ID, '') as string;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** ⭐ 保存备份目标(bookId)。传空字符串 = 取消选择。 */
|
||||
static async setBackupBookId(context: common.Context, bookId: string): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_BACKUP_BOOK_ID, bookId);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存备份目标失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次清空备份目标(新键 + 老键一起清,避免迁移残留又复活) */
|
||||
static async clearBackupTarget(context: common.Context): Promise<void> {
|
||||
await AppSettings.setBackupBookId(context, '');
|
||||
await AppSettings.setBackupCalKey(context, '');
|
||||
}
|
||||
|
||||
/** 是否开启后台持续同步(长时任务,默认关) */
|
||||
static async getBackgroundSync(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
@@ -392,15 +662,23 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动同步间隔(分钟),默认 1 分钟 */
|
||||
/**
|
||||
* 自动同步间隔(分钟),默认 15 分钟。
|
||||
*
|
||||
* ⭐ 0.0.8:默认值由 1 分钟改为 15 分钟。原因(真机日志 2026-10-16 17:16–17:20 实测):
|
||||
* 1 分钟间隔会让「App 打开期间」几乎不间断地跑"12 个日历本全量比对 + 出站镜像 + 提醒刷新",
|
||||
* 5 分钟内连跑 5 轮后触发系统提示「短时任务超时(配额用尽),后台同步将被挂起」——
|
||||
* 反而把真正的后台同步通道一起拖垮。改为 15 分钟后,前台同步仍足够及时,且不再烧配额。
|
||||
* 用户仍可在设置页自行改成 1 / 5 / 15 / 30 分钟 或 1 小时。
|
||||
*/
|
||||
static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number;
|
||||
return v > 0 ? v : 1;
|
||||
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 15) as number;
|
||||
return v > 0 ? v : 15;
|
||||
} catch (err) {
|
||||
return 1;
|
||||
return 15;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// entry/src/main/ets/common/BackgroundSyncRunner.ets
|
||||
// ⭐ 后台同步的**唯一**执行体:延迟任务 / 短时任务 / 网络恢复 / 回到前台 都调它。
|
||||
//
|
||||
// 设计要点(2026-10-10 定时同步方案调研结论):
|
||||
// - 不再依赖"进程常驻"(系统不允许,dataTransfer 长时任务会被强制取消)
|
||||
// - 而是让"最合适的机制在最合适的时机把数据对齐"
|
||||
// - 本类只负责"跑一次完整同步",幂等(基于 etag 全量比对 + dirty 标记)
|
||||
//
|
||||
// ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import process from '@ohos.process';
|
||||
import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore';
|
||||
import { SyncEngine } from './SyncEngine';
|
||||
import { CardDataService } from './CardDataService';
|
||||
import { ReminderService } from './ReminderService';
|
||||
import { SystemCalendarImport } from './SystemCalendarImport';
|
||||
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
import { MirrorInbound } from './MirrorInbound';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 一次后台同步的结果(供日志/埋点,不做 UI) */
|
||||
export class BackgroundSyncResult {
|
||||
ok: number = 0;
|
||||
attempted: number = 0;
|
||||
failed: number = 0;
|
||||
}
|
||||
|
||||
export class BackgroundSyncRunner {
|
||||
/**
|
||||
* ⭐ 卡片的系统定时刷新间隔(与 form_config.json 的 updateDuration=1 保持一致,单位 30 分钟)。
|
||||
* ⚠️ 两处必须同步修改:改这里也要改 form_config.json,否则节流窗口对不上。
|
||||
*/
|
||||
static readonly CARD_TICK_MS: number = 30 * 60 * 1000;
|
||||
/** 距上次同步不足该时长则跳过(略小于卡片周期,避免系统刷新漂移导致被误跳过) */
|
||||
private static readonly CARD_MIN_GAP_MS: number = 25 * 60 * 1000;
|
||||
/**
|
||||
* 卡片进程的软超时(**未申请到短时任务时**):官方规定 FormExtensionAbility
|
||||
* 生命周期回调后进程**仅存续 10 秒**,超过就被回收(异步任务被强制中止)。
|
||||
* 这里主动在 9 秒收手,让"这一次同步"**干净地放弃**而不是被硬杀在半途。
|
||||
*/
|
||||
private static readonly CARD_SOFT_TIMEOUT_MS: number = 9 * 1000;
|
||||
/**
|
||||
* 申请到短时任务后的软超时:短时任务单次最长 3 分钟(低电量 1 分钟),
|
||||
* 但实际时长以 `actualDelayTime` 为准 → 留 15 秒余量提前收手,保证能干净地
|
||||
* `cancelSuspendDelay`(否则超时不取消会被系统强制终止进程、并浪费当日配额)。
|
||||
*/
|
||||
private static readonly CARD_EXT_SOFT_RESERVE_MS: number = 15 * 1000;
|
||||
/**
|
||||
* 卡片驱动同步可用的**总时长上限**:即使申请到短时任务也不超过这个值。
|
||||
* 理由:短时任务**单日总配额仅 10 分钟**,30 分钟一次的话每次平均只能分到约 20 秒,
|
||||
* 全用光会让"退后台那一次同步"(EntryAbility.onBackground 申请)无配额可用。
|
||||
*/
|
||||
private static readonly CARD_MAX_TOTAL_MS: number = 60 * 1000;
|
||||
|
||||
/** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */
|
||||
private static cardTransientId: number = 0;
|
||||
|
||||
/** 当前进程 pid(取不到返回 -1)。用来判断卡片回调是否与主 App 同进程。 */
|
||||
private static pidOf(): number {
|
||||
try {
|
||||
return process.pid;
|
||||
} catch (err) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。
|
||||
*
|
||||
* 为什么用它:系统给普通应用的后台调度很受限 ——
|
||||
* `workScheduler` 的周期被"应用活跃分组"压制到 **≥2 小时**;
|
||||
* 而服务卡片配置了 `updateDuration: 1`(**单位 30 分钟**),系统会**每 30 分钟**
|
||||
* 触发一次 `onUpdateForm()`,且不受活跃分组压制(每天上限 50 次)。
|
||||
* 借这个时机跑一次完整同步,即可得到"约 30 分钟一次"的定期后台同步。
|
||||
*
|
||||
* ⚠️ 节流:系统刷新本身可能有漂移、也可能因故重入 → 用时间戳挡一道,
|
||||
* 保证两次真实同步之间至少间隔 `CARD_MIN_GAP_MS`。
|
||||
* ⚠️ 本方法**永不抛错**(后台路径没有 UI 承担异常)。
|
||||
*/
|
||||
static async runOnceThrottledByCard(context: common.Context): Promise<void> {
|
||||
try {
|
||||
const last: number = await AppSettings.getCardSyncAt(context);
|
||||
const now: number = Date.now();
|
||||
if (last > 0 && now - last < BackgroundSyncRunner.CARD_MIN_GAP_MS) {
|
||||
const mins: number = Math.round((now - last) / 60000);
|
||||
LogUtil.write(`卡片驱动同步:距上次仅 ${mins} 分钟,跳过(节流 ${BackgroundSyncRunner.CARD_MIN_GAP_MS / 60000} 分钟)`);
|
||||
return;
|
||||
}
|
||||
// 先写时间戳:避免同步执行期间卡片再次刷新导致并发重入
|
||||
await AppSettings.setCardSyncAt(context, now);
|
||||
// ⭐ 尝试申请**短时任务**,把 10 秒窗口延长到最多 3 分钟。
|
||||
// ⚠️ 官方规定 requestSuspendDelay 应在"应用处于前台"或"onBackground 回调"里调用;
|
||||
// 卡片进程(FormExtensionAbility)是否符合**未知** → 故此处**容错尝试**:
|
||||
// 成功则按实际时长延长,失败(抛错/时长为 0)则退回 9 秒软超时。
|
||||
// ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟),
|
||||
// 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。
|
||||
const windowMs: number = await BackgroundSyncRunner.acquireCardWindow();
|
||||
let budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS);
|
||||
// ⭐ 诊断开关(preferences:card_budget_ms):>0 时**覆盖**上面算出的可用窗口。
|
||||
// 用途:在真机上回答"卡片进程究竟能活多久""一次完整同步要多久",
|
||||
// 从而决定卡片这条路该做多少事 —— 不必为此重新编译。
|
||||
const overrideMs: number = await AppSettings.getCardBudgetMs(context);
|
||||
if (overrideMs > 0) {
|
||||
budget = overrideMs;
|
||||
LogUtil.write(`卡片驱动同步:预算被诊断开关覆盖为 ${Math.round(budget / 1000)} 秒`);
|
||||
}
|
||||
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,pid=${BackgroundSyncRunner.pidOf()},可用窗口 ${Math.round(budget / 1000)} 秒)`);
|
||||
// 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试)
|
||||
const worker: Promise<void> =
|
||||
BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {});
|
||||
const guard: Promise<void> = new Promise<void>((_res: () => void, rej: (e: Error) => void) => {
|
||||
setTimeout((): void => rej(new Error('本次同步时间预算用尽,主动收手')),
|
||||
budget);
|
||||
});
|
||||
try {
|
||||
await Promise.race<void>([worker, guard]);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
LogUtil.write(`卡片驱动同步提前收手:${e.message}(幂等,下轮会重试)`);
|
||||
} finally {
|
||||
// 无论成败都释放短时任务:① 归还当日配额 ② 避免超时不取消被系统强杀进程
|
||||
BackgroundSyncRunner.releaseCardWindow();
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`卡片驱动同步异常: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为卡片同步申请短时任务,返回**实际可用窗口(毫秒)**。
|
||||
* 申请失败/异常/时长为 0 → 返回 0,由调用方退回默认的 9 秒软超时(不阻塞流程)。
|
||||
*/
|
||||
private static async acquireCardWindow(): Promise<number> {
|
||||
try {
|
||||
const info: backgroundTaskManager.DelaySuspendInfo =
|
||||
backgroundTaskManager.requestSuspendDelay('正在同步日历数据', (): void => {
|
||||
// 超时回调:配额即将用尽,清掉本地记录(系统会自行处理挂起)
|
||||
LogUtil.write('卡片同步的短时任务即将超时');
|
||||
BackgroundSyncRunner.cardTransientId = 0;
|
||||
});
|
||||
BackgroundSyncRunner.cardTransientId = info.requestId;
|
||||
const actual: number = info.actualDelayTime ?? 0;
|
||||
if (actual <= 0) {
|
||||
// 拿到了 requestId 但没有实际时长 → 视为不可用
|
||||
BackgroundSyncRunner.releaseCardWindow();
|
||||
LogUtil.write('卡片同步:短时任务时长为 0,退回 9 秒软超时');
|
||||
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
|
||||
}
|
||||
// 留 15 秒余量提前收手,保证能干净地取消
|
||||
const usable: number = Math.max(actual - BackgroundSyncRunner.CARD_EXT_SOFT_RESERVE_MS,
|
||||
BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS);
|
||||
LogUtil.write(`卡片同步:短时任务已获批,实际时长 ${Math.round(actual / 1000)} 秒`);
|
||||
return usable;
|
||||
} catch (err) {
|
||||
// 例如"未在前台/退后台时机申请"被拒 —— 退回 9 秒软超时,不影响流程
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`卡片同步:短时任务申请失败(${e.code ?? ''} ${e.message}),退回 9 秒软超时`);
|
||||
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/** 释放卡片同步的短时任务(幂等) */
|
||||
private static releaseCardWindow(): void {
|
||||
if (BackgroundSyncRunner.cardTransientId === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncRunner.cardTransientId);
|
||||
} catch (err) {
|
||||
// 取消失败无需处理
|
||||
}
|
||||
BackgroundSyncRunner.cardTransientId = 0;
|
||||
}
|
||||
/**
|
||||
* 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。
|
||||
* @param context 应用上下文
|
||||
* @param tag 日志标签,用于区分触发来源(deferred/transient/netRecover/foreground)
|
||||
*/
|
||||
static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> {
|
||||
const r = new BackgroundSyncResult();
|
||||
// ⭐ 阶段计时:后台同步被时间预算切断时,这些 `+Xms` 就是"钱花在哪了"的直接证据。
|
||||
const t0: number = Date.now();
|
||||
const el = (): number => Date.now() - t0;
|
||||
try {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:载入账号 ${accounts.length} 个 +${el()}ms`);
|
||||
if (accounts.length === 0) {
|
||||
LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
|
||||
return r;
|
||||
}
|
||||
// 备份模式:先导入系统本地日历(幂等),再随同步推送
|
||||
try {
|
||||
await SystemCalendarImport.importIfNeeded(context);
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:系统日历导入 +${el()}ms`);
|
||||
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
|
||||
try {
|
||||
await MirrorInbound.reconcile(context);
|
||||
} catch (err) {
|
||||
// 对账失败不影响正常同步
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:入站对账 +${el()}ms`);
|
||||
for (const acc of accounts) {
|
||||
if (acc.type !== TYPE_CALDAV) {
|
||||
continue;
|
||||
}
|
||||
r.attempted++;
|
||||
try {
|
||||
await SyncEngine.syncAccount(context, acc);
|
||||
r.ok++;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
// "已有同步进行中"不算失败(前台可能正在同步)
|
||||
r.failed++;
|
||||
LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`);
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:账号 ${acc.id} 同步 +${el()}ms(ok=${r.ok} failed=${r.failed})`);
|
||||
}
|
||||
// 全部账号同步成功才结束"一次性全量重拉"状态
|
||||
if (r.attempted > 0 && r.ok === r.attempted) {
|
||||
await AppSettings.markFullRefetchDone(context);
|
||||
}
|
||||
await SyncEngine.settleLocalEvents(context);
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:本地收尾 +${el()}ms`);
|
||||
await SyncEngine.pruneOrphanRows(context, accounts);
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:孤儿行清理 +${el()}ms`);
|
||||
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
|
||||
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。
|
||||
await AccountStore.saveAll(context, accounts);
|
||||
// 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点)
|
||||
try {
|
||||
if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) {
|
||||
const mr = await SystemCalendarMirror.syncNow(context);
|
||||
LogUtil.write(`后台同步[${tag}] 自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 镜像失败不影响同步
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:出站镜像 +${el()}ms`);
|
||||
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
||||
try {
|
||||
await CardDataService.pushToAllForms(context);
|
||||
await ReminderService.refreshReminders(context as common.UIAbilityContext);
|
||||
} catch (err) {
|
||||
// 卡片/提醒刷新失败不影响同步结果
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号,总耗时 ${Math.round(el() / 1000)} 秒`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}(已耗时 ${Math.round(el() / 1000)} 秒)`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,152 @@
|
||||
// entry/src/main/ets/common/BackgroundSyncService.ets
|
||||
// 后台同步管理:
|
||||
// 1) 长时任务(DATA_TRANSFER):进程不被挂起,前台定时器在后台继续跑(需用户在设置中开启)
|
||||
// 2) 延迟任务(workScheduler):App 被杀/重启后由系统按条件拉起兜底同步(最小周期 20 分钟)
|
||||
// ⭐ 后台同步调度(2026-10-10 重构,依据《定时同步方案调研.md》)
|
||||
//
|
||||
// 为什么不再用长时任务(重要):
|
||||
// 旧版申请 `dataTransfer` 长时任务想让进程常驻,但系统对 DATA_TRANSFER 有**一致性校验**——
|
||||
// "超过 10 分钟未更新传输进度 → 任务被强制取消"。日历同步是"间歇跑、平时无进度",
|
||||
// 本质不符合"持续传输中"的语义 → 必被取消(常驻通知消失就是这个原因),
|
||||
// 且频繁触发校验失败可能被系统列入管控名单。故**彻底移除**。
|
||||
//
|
||||
// 现在的分层调度(每层各司其职):
|
||||
// ① 前台:Index 里的 setInterval 按用户设定间隔同步(最准,已在前台实现)
|
||||
// ② 退后台瞬间:**短时任务**(requestSuspendDelay)兜住"退后台后那一次"
|
||||
// ③ 长期后台:**延迟任务**(workScheduler,≥2 小时,对齐系统活跃分组下限)
|
||||
// ④ 网络恢复 / 回到前台:各补一次(性价比最高,见 NetworkSyncWatcher / Index.onForeground)
|
||||
//
|
||||
// ⚠️ 关键约束(踩过的坑,勿改):
|
||||
// - workScheduler 的 repeatCycleTime **不得低于 2 小时**:系统按"应用活跃分组"压制
|
||||
// (活跃≥2h / 经常使用≥4h / 常用≥24h / 极少使用≥48h / 受限·从未使用禁止调度),
|
||||
// 请求更短不报错但会被压制,形同虚设。
|
||||
// - WorkSchedulerExtensionAbility 单次回调**上限 2 分钟**,超时进程被杀。
|
||||
// - 短时任务配额:单日 10 分钟、单次最长 3 分钟(低电量 1 分钟)→ 只做短收尾,用完必须 cancel。
|
||||
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
|
||||
import { wantAgent, common } from '@kit.AbilityKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { workScheduler } from '@kit.BackgroundTasksKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
const WORK_ID: number = 1001;
|
||||
/** 延迟任务周期:2 小时(系统"活跃分组"下限,不可再短) */
|
||||
const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000;
|
||||
|
||||
export class BackgroundSyncService {
|
||||
/** 申请长时任务(DATA_TRANSFER):申请成功后进程在后台不被挂起,失败抛错由调用方提示用户 */
|
||||
static async startContinuousTask(context: common.UIAbilityContext): Promise<void> {
|
||||
try {
|
||||
const info: wantAgent.WantAgentInfo = {
|
||||
wants: [{
|
||||
bundleName: context.abilityInfo.bundleName,
|
||||
abilityName: 'EntryAbility'
|
||||
}],
|
||||
operationType: wantAgent.OperationType.START_ABILITY,
|
||||
requestCode: 0
|
||||
};
|
||||
const agent = await wantAgent.getWantAgent(info);
|
||||
await backgroundTaskManager.startBackgroundRunning(
|
||||
context, backgroundTaskManager.BackgroundMode.DATA_TRANSFER, agent);
|
||||
LogUtil.write('长时任务已申请:后台持续同步开启');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`长时任务申请失败: ${e.code} - ${e.message}`);
|
||||
throw new Error(`长时任务申请失败(错误码 ${e.code})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止长时任务 */
|
||||
static async stopContinuousTask(context: common.UIAbilityContext): Promise<void> {
|
||||
try {
|
||||
await backgroundTaskManager.stopBackgroundRunning(context);
|
||||
LogUtil.write('长时任务已停止');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`长时任务停止失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
/** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */
|
||||
private static transientId: number = 0;
|
||||
|
||||
/**
|
||||
* 注册延迟任务兜底同步:无论长时任务开关与否都注册。
|
||||
* 条件:有任意网络即执行;周期重复;重启后自动恢复。
|
||||
* 注:repeatCycleTime 各版本下限不同(20 分钟 ~ 2 小时),先用 20 分钟,
|
||||
* 被系统拒绝(9700003 条件无效)时降级为 2 小时再试。
|
||||
* 注册延迟任务兜底同步:周期 2 小时、重启后自动恢复(isPersisted)。
|
||||
* 条件:有任意网络即执行。
|
||||
* ⚠️ 周期**不得低于 2 小时**(系统按"应用活跃分组"压制:活跃≥2h / 经常使用≥4h / 常用≥24h /
|
||||
* 极少使用≥48h / 受限·从未使用禁止调度)。请求更短不报错但会被压制,形同虚设。
|
||||
*/
|
||||
static scheduleDeferredSync(context: common.UIAbilityContext): void {
|
||||
const build = (cycleMs: number): workScheduler.WorkInfo => {
|
||||
const info: workScheduler.WorkInfo = {
|
||||
workId: WORK_ID,
|
||||
bundleName: context.abilityInfo.bundleName,
|
||||
abilityName: 'SyncWorkAbility',
|
||||
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
||||
isRepeat: true,
|
||||
isPersisted: true,
|
||||
repeatCycleTime: cycleMs
|
||||
};
|
||||
return info;
|
||||
const info: workScheduler.WorkInfo = {
|
||||
workId: WORK_ID,
|
||||
bundleName: context.abilityInfo.bundleName,
|
||||
abilityName: 'SyncWorkAbility',
|
||||
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
||||
isRepeat: true,
|
||||
isPersisted: true,
|
||||
repeatCycleTime: DEFERRED_CYCLE_MS
|
||||
};
|
||||
try {
|
||||
workScheduler.startWork(build(20 * 60 * 1000));
|
||||
LogUtil.write('延迟任务已注册:20 分钟兜底同步');
|
||||
workScheduler.startWork(info);
|
||||
LogUtil.write('延迟任务已注册:2 小时兜底同步(系统活跃分组下限)');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
if (e.code === 9700003) {
|
||||
try {
|
||||
workScheduler.startWork(build(2 * 60 * 60 * 1000));
|
||||
LogUtil.write('延迟任务降级注册:2 小时兜底同步');
|
||||
return;
|
||||
} catch (err2) {
|
||||
const e2 = err2 as BusinessError;
|
||||
LogUtil.write(`延迟任务注册失败(2h): ${e2.code} - ${e2.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
LogUtil.write(`延迟任务注册失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** App 启动时统一入口:注册延迟任务 + 按设置恢复长时任务(恢复失败静默,不影响启动) */
|
||||
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> {
|
||||
BackgroundSyncService.scheduleDeferredSync(context);
|
||||
const enabled: boolean = await AppSettings.getBackgroundSync(context);
|
||||
if (enabled) {
|
||||
try {
|
||||
await BackgroundSyncService.startContinuousTask(context);
|
||||
} catch (err) {
|
||||
// 启动时恢复失败不阻塞 App:下次开关切换或重启再试
|
||||
}
|
||||
/** 取消延迟任务(用户关闭"后台自动同步"时调用) */
|
||||
static cancelDeferredSync(context: common.UIAbilityContext): void {
|
||||
const info: workScheduler.WorkInfo = {
|
||||
workId: WORK_ID,
|
||||
bundleName: context.abilityInfo.bundleName,
|
||||
abilityName: 'SyncWorkAbility',
|
||||
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
||||
isRepeat: true,
|
||||
isPersisted: true,
|
||||
repeatCycleTime: DEFERRED_CYCLE_MS
|
||||
};
|
||||
try {
|
||||
workScheduler.stopWork(info, true);
|
||||
LogUtil.write('延迟任务已取消(后台自动同步关闭)');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`延迟任务取消失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页开关切换:失败抛错由页面提示并回滚 UI */
|
||||
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
|
||||
if (enabled) {
|
||||
// 先申请任务,成功才落盘设置(失败时设置页回滚开关)
|
||||
await BackgroundSyncService.startContinuousTask(context);
|
||||
await AppSettings.setBackgroundSync(context, true);
|
||||
} else {
|
||||
await AppSettings.setBackgroundSync(context, false);
|
||||
try {
|
||||
await BackgroundSyncService.stopContinuousTask(context);
|
||||
} catch (err) {
|
||||
// 停止失败(如任务本就不在运行)不影响设置保存
|
||||
}
|
||||
/**
|
||||
* ⭐ 退到后台时申请**短时任务**:让"刚退后台的这一次同步"能跑完。
|
||||
* 必须在**前台或退后台 5 秒内**调用,否则申请失败。
|
||||
* 单次最长 3 分钟(实际申请到的时长以返回值为准),单日总配额 10 分钟。
|
||||
*/
|
||||
static requestTransientSync(reason: string): void {
|
||||
if (BackgroundSyncService.transientId !== 0) {
|
||||
return; // 已有短时任务在跑,不重复申请(重复申请会浪费配额)
|
||||
}
|
||||
try {
|
||||
const info: backgroundTaskManager.DelaySuspendInfo =
|
||||
backgroundTaskManager.requestSuspendDelay(reason, (): void => {
|
||||
// 超时回调:配额用完,系统即将挂起;清状态,等下次前台/退后台再申请
|
||||
LogUtil.write('短时任务超时(配额用尽),后台同步将被挂起');
|
||||
BackgroundSyncService.transientId = 0;
|
||||
});
|
||||
BackgroundSyncService.transientId = info.requestId;
|
||||
LogUtil.write(`短时任务已申请:requestId=${info.requestId},可用 ${Math.round(info.actualDelayTime / 1000)} 秒`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`短时任务申请失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 回到前台时取消短时任务:不占用当日配额(很重要,配额只有 10 分钟) */
|
||||
static cancelTransientSync(): void {
|
||||
if (BackgroundSyncService.transientId === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncService.transientId);
|
||||
LogUtil.write('短时任务已取消(回到前台,归还配额)');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`短时任务取消失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
BackgroundSyncService.transientId = 0;
|
||||
}
|
||||
|
||||
/** 查询短时任务剩余时间(毫秒);无任务或无配额返回 0 */
|
||||
static async remainingTransientMs(): Promise<number> {
|
||||
if (BackgroundSyncService.transientId === 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return await backgroundTaskManager.getRemainingDelayTime(BackgroundSyncService.transientId);
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** App 启动时统一入口:按用户设置注册/不注册延迟任务兜底同步 */
|
||||
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> {
|
||||
const enabled: boolean = await AppSettings.getBackgroundSync(context);
|
||||
if (enabled) {
|
||||
BackgroundSyncService.scheduleDeferredSync(context);
|
||||
} else {
|
||||
LogUtil.write('后台自动同步未开启:不注册延迟任务');
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页开关切换:控制"后台自动同步"(延迟任务注册与否) */
|
||||
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
|
||||
await AppSettings.setBackgroundSync(context, enabled);
|
||||
if (enabled) {
|
||||
BackgroundSyncService.scheduleDeferredSync(context);
|
||||
} else {
|
||||
BackgroundSyncService.cancelDeferredSync(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { IcsUtil } from './IcsUtil';
|
||||
/** 界面展示用事件 */
|
||||
export class DisplayEvent {
|
||||
id: number = 0; // 本地库 id 或系统日历事件 id
|
||||
uid: string = ''; // 服务器 UID(待办父子关系用它匹配 parentUid)
|
||||
isSystem: boolean = false;
|
||||
title: string = '';
|
||||
description: string = '';
|
||||
@@ -25,6 +26,21 @@ export class DisplayEvent {
|
||||
color: string = '#007DFF';
|
||||
completed: 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; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
||||
}
|
||||
|
||||
@@ -112,6 +128,9 @@ export class CalendarDataService {
|
||||
const detected: boolean = acc.calendarWritable.length > i
|
||||
? acc.calendarWritable[i] !== '0' : true;
|
||||
s.writable = detected && !manualKeys.includes(s.calKey);
|
||||
// 透传本支持的类型(新建待办/日程时据此过滤,避免把待办写进纯日历本)
|
||||
s.comps = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
|
||||
? acc.calendarComps[i] : 'unknown';
|
||||
sources.push(s);
|
||||
}
|
||||
}
|
||||
@@ -202,12 +221,14 @@ export class CalendarDataService {
|
||||
}
|
||||
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。
|
||||
// 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本);
|
||||
// ②只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
|
||||
// ②键必须用 **recurrenceId(原始发生时刻)**——覆盖行的 startTime 是"被改到的新时间",
|
||||
// 用它做键时主事件在原时间的那次发生不会被排除 → 同一日程显示两条(幽灵日程);
|
||||
// ③只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
|
||||
// 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示"
|
||||
const overrideKeys: string[] = [];
|
||||
for (const e of rows) {
|
||||
if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) {
|
||||
overrideKeys.push(`${e.calKey}_${e.uid}_${e.startTime}`);
|
||||
overrideKeys.push(EventDb.overrideKey(e.calKey, e.uid, e.recurrenceId, e.startTime));
|
||||
}
|
||||
}
|
||||
let occTotal: number = 0;
|
||||
@@ -232,7 +253,7 @@ export class CalendarDataService {
|
||||
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
|
||||
if (occs.length > 0) {
|
||||
times = occs.filter((occ: number): boolean =>
|
||||
!overrideKeys.includes(`${e.calKey}_${e.uid}_${occ}`));
|
||||
!overrideKeys.includes(EventDb.overrideKey(e.calKey, e.uid, 0, occ)));
|
||||
}
|
||||
}
|
||||
for (const occ of times) {
|
||||
@@ -339,6 +360,11 @@ export class CalendarDataService {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.color : '#9AA0A6';
|
||||
};
|
||||
// ⭐ 待办按日历本分组显示需要"本名";原来只带 calKey,分组标题无法显示
|
||||
const nameOf = (key: string): string => {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.name : '';
|
||||
};
|
||||
try {
|
||||
const rows: LocalEvent[] = await EventDb.queryTodos(context);
|
||||
for (const e of rows) {
|
||||
@@ -347,6 +373,7 @@ export class CalendarDataService {
|
||||
}
|
||||
const d = new DisplayEvent();
|
||||
d.id = e.id;
|
||||
d.uid = e.uid;
|
||||
d.isSystem = false;
|
||||
d.title = e.title;
|
||||
d.description = e.description;
|
||||
@@ -355,8 +382,17 @@ export class CalendarDataService {
|
||||
d.endTime = e.endTime;
|
||||
d.isAllDay = e.isAllDay;
|
||||
d.calKey = e.calKey;
|
||||
d.calName = nameOf(e.calKey);
|
||||
d.color = colorOf(e.calKey);
|
||||
d.completed = e.completed;
|
||||
d.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);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -36,6 +36,24 @@ export class CardAllDay {
|
||||
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 {
|
||||
eventsJson: string = '[]';
|
||||
@@ -62,6 +80,15 @@ export class CardData {
|
||||
isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线)
|
||||
selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天)
|
||||
dayCount: number = 0; // 当前显示日的日程条数(含全天)
|
||||
// ===== 待办卡片专用字段(isTodoCard=true 时才有效)=====
|
||||
/** 待办卡片列表:TodoCardItem[] JSON(按截止时间升序,未完成在前) */
|
||||
todoListJson: string = '[]';
|
||||
/** 未完成待办总数(2x2 / 2x4 卡片大数字) */
|
||||
todoCount: number = 0;
|
||||
/** 今日到期待办数(卡片副标题用) */
|
||||
todoTodayCount: number = 0;
|
||||
/** 是否待办卡片(卡片侧据此切换渲染分支) */
|
||||
isTodoCard: boolean = false;
|
||||
}
|
||||
|
||||
export class CardDataService {
|
||||
@@ -127,25 +154,57 @@ export class CardDataService {
|
||||
}
|
||||
|
||||
/** 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 重启后内存注册表为空)
|
||||
await CardDataService.loadPersistedFormIds(context);
|
||||
if (CardDataService.formIds.length === 0) {
|
||||
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> =
|
||||
new Map<number, formBindingData.FormBindingData>();
|
||||
const fallbackData: CardData = await CardDataService.buildCardData(context, 0);
|
||||
if (todoData !== null) {
|
||||
CardDataService.mergeTodoInto(fallbackData, todoData);
|
||||
}
|
||||
const fallback: formBindingData.FormBindingData =
|
||||
formBindingData.createFormBindingData(await CardDataService.buildCardData(context, 0));
|
||||
formBindingData.createFormBindingData(fallbackData);
|
||||
cache.set(0, fallback);
|
||||
const stale: string[] = [];
|
||||
for (const formId of CardDataService.formIds) {
|
||||
const off: number = CardDataService.getDayOffset(formId);
|
||||
let binding: formBindingData.FormBindingData | undefined = cache.get(off);
|
||||
if (binding === undefined) {
|
||||
binding = formBindingData.createFormBindingData(
|
||||
await CardDataService.buildCardData(context, off));
|
||||
const d: CardData = await CardDataService.buildCardData(context, off);
|
||||
if (todoData !== null) {
|
||||
CardDataService.mergeTodoInto(d, todoData);
|
||||
}
|
||||
binding = formBindingData.createFormBindingData(d);
|
||||
cache.set(off, binding);
|
||||
}
|
||||
try {
|
||||
@@ -305,14 +364,16 @@ export class CardDataService {
|
||||
// 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中)
|
||||
data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length;
|
||||
|
||||
// ===== 2x4 卡片右侧:**当前时间之后最近的 2 条**日程 =====
|
||||
// ===== 2x4 卡片右侧:**当前时间之后最近的若干条**日程(卡片可滚动,最多 15 条)=====
|
||||
// 规则:只取"还没开始"的有时间日程(startTime >= now),
|
||||
// **排除全天 / 跨天**,按开始时间升序取前 2 条。
|
||||
// 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。
|
||||
const upcomingNext: DisplayEvent[] = upcoming
|
||||
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs)
|
||||
.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[] = [];
|
||||
for (const e of ongoingPick) {
|
||||
const dayLong: boolean = isDayLong(e);
|
||||
@@ -381,4 +442,109 @@ export class CardDataService {
|
||||
}
|
||||
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,可能为空
|
||||
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
|
||||
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
|
||||
/**
|
||||
* ⭐ COMP_SET 该集合支持的组件类型(来自 supported-calendar-component-set)。
|
||||
* 'event' = 纯日历本(只有 VEVENT)
|
||||
* 'todo' = 纯待办本(只有 VTODO)
|
||||
* 'both' = 混合本(两者都有)
|
||||
* 'unknown' = 服务器未声明该属性(老实现常见)→ 按日历对待,保持兼容
|
||||
*/
|
||||
comps: string = 'unknown';
|
||||
|
||||
}
|
||||
|
||||
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
|
||||
@@ -25,6 +34,8 @@ export class DavCalendarEntry {
|
||||
href: string = ''; // 完整 URL(已补全域名)
|
||||
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
|
||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||||
/** ⭐ COMP_SET 'event' 纯日历 / 'todo' 纯待办 / 'both' 混合 / 'unknown' 服务器未声明 */
|
||||
comps: string = 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,6 +47,20 @@ export class DavCalendarDiscovery {
|
||||
entries: DavCalendarEntry[] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 带 HTTP 状态码的 DAV 请求错误。
|
||||
* 同步引擎据此区分「404 日历本/资源在服务器上已不存在」(可降级处理:
|
||||
* 单本跳过、自动从账号移除)与其它网络/服务器故障(整个账号同步失败)。
|
||||
*/
|
||||
export class DavHttpError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class DavClient {
|
||||
/**
|
||||
* PROPFIND 拉取某路径下所有集合的 calendar-color
|
||||
@@ -47,6 +72,8 @@ export class DavClient {
|
||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||
'<d:current-user-privilege-set/>' +
|
||||
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
|
||||
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||||
'</d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
@@ -90,6 +117,7 @@ export class DavClient {
|
||||
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
|
||||
}
|
||||
entry.color = color;
|
||||
entry.comps = DavClient.parseComps(block);
|
||||
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
|
||||
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
|
||||
if (privSet !== '') {
|
||||
@@ -109,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)。
|
||||
*
|
||||
@@ -122,6 +177,7 @@ export class DavClient {
|
||||
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||||
'</d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
const out = new DavCalendarDiscovery();
|
||||
@@ -183,6 +239,9 @@ export class DavClient {
|
||||
entry.href = href.startsWith('http') ? href : origin + href;
|
||||
entry.displayName = name;
|
||||
entry.color = color;
|
||||
entry.comps = DavClient.parseComps(block);
|
||||
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
|
||||
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
|
||||
out.entries.push(entry);
|
||||
}
|
||||
return out;
|
||||
@@ -236,6 +295,37 @@ export class DavClient {
|
||||
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 拉取单个资源原文,**失败抛错**(与 getRaw 相反)。
|
||||
*
|
||||
* 为什么需要严格版:待办写回要"先 GET 原文、改字段、再 PUT"。
|
||||
* 若 GET 失败却拿到一个 `"HTTP 404"` 之类的字符串并当 ICS 内容 PUT 回去,
|
||||
* 会把服务器上那条待办**覆盖成一句错误信息**(数据破坏)。
|
||||
* 原 getRaw 保留原行为不动(诊断用,已有调用方)。
|
||||
*/
|
||||
static async getRawStrict(url: string, auth: string): Promise<string> {
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: http.RequestMethod.GET,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 20000,
|
||||
expectDataType: http.HttpDataType.STRING
|
||||
});
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
throw new DavHttpError(resp.responseCode, `拉取待办原文失败,服务器返回 ${resp.responseCode}`);
|
||||
}
|
||||
return resp.result as string;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
||||
static async getRaw(url: string, auth: string): Promise<string> {
|
||||
const httpRequest = http.createHttp();
|
||||
@@ -346,7 +436,7 @@ export class DavClient {
|
||||
});
|
||||
LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`);
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
throw new Error(`服务器返回状态码 ${resp.responseCode}`);
|
||||
throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
|
||||
}
|
||||
const xml: string = resp.result as string;
|
||||
const items: RemoteItem[] = [];
|
||||
@@ -375,6 +465,52 @@ export class DavClient {
|
||||
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 */
|
||||
private static async reportComponents(href: string, auth: string,
|
||||
compName: string, timeRange: string): Promise<RemoteItem[]> {
|
||||
@@ -385,6 +521,12 @@ export class DavClient {
|
||||
`<c:comp-filter name="${compName}">` +
|
||||
timeRange +
|
||||
'</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();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(href, {
|
||||
@@ -403,14 +545,16 @@ export class DavClient {
|
||||
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
|
||||
if (resp.responseCode === 401) {
|
||||
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
|
||||
throw new Error('服务器拒绝凭据(401)');
|
||||
throw new DavHttpError(401, '服务器拒绝凭据(401)');
|
||||
}
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`);
|
||||
throw new Error(`服务器返回状态码 ${resp.responseCode}`);
|
||||
throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
|
||||
}
|
||||
const xml: string = resp.result as string;
|
||||
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 origin: string = originMatch !== null ? originMatch[0] : '';
|
||||
const items: RemoteItem[] = [];
|
||||
@@ -435,6 +579,13 @@ export class DavClient {
|
||||
item.ics = block.substring(start, end + 'END:VCALENDAR'.length);
|
||||
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;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
@@ -459,7 +610,7 @@ export class DavClient {
|
||||
console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
|
||||
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
throw new Error(`推送失败,服务器返回 ${resp.responseCode}`);
|
||||
throw new DavHttpError(resp.responseCode, `推送失败,服务器返回 ${resp.responseCode}`);
|
||||
}
|
||||
const headers = resp.header as Record<string, string>;
|
||||
if (headers !== undefined && headers !== null) {
|
||||
@@ -489,7 +640,7 @@ export class DavClient {
|
||||
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
|
||||
// 404 视为已删除,成功
|
||||
if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) {
|
||||
throw new Error(`删除失败,服务器返回 ${resp.responseCode}`);
|
||||
throw new DavHttpError(resp.responseCode, `删除失败,服务器返回 ${resp.responseCode}`);
|
||||
}
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
// entry/src/main/ets/common/DavDiscovery.ets
|
||||
// ⭐ CalDAV 地址自动发现与智能补全(0.0.7)
|
||||
//
|
||||
// 解决的用户问题(见《CalDAV排查指南-405与读不出日历.md》):
|
||||
// 用户习惯只填域名或 DAV 根,而 Nextcloud 等要求填「日历家目录」
|
||||
// (/remote.php/dav/calendars/<用户名>/)。填错表现为"读不出日历"或 405。
|
||||
// DAVx5 能用是因为它内置 .well-known 自动发现 —— 本模块补上这一能力。
|
||||
//
|
||||
// 发现顺序(逐级降级,任一成功即停):
|
||||
// ① 用户填的地址原样探测(保持既有行为,兼容已能用的用户)
|
||||
// ② .well-known/caldav(RFC 6764 标准;Nextcloud/Radicale/Baïkal 均支持)
|
||||
// ③ 常见候选路径拼接(remote.php/dav/calendars/<用户名>/ 等)
|
||||
//
|
||||
// ⚠️ 设计约束:
|
||||
// - 全部探测都是 PROPFIND + Depth:0,只判断「是不是能列出日历的端点」,不做副作用;
|
||||
// - 单次探测失败不抛错(网络异常吞掉继续试下一个),保证不会因某个候选不可达而整体失败;
|
||||
// - 最终由调用方用 listCalendars 校验(能列出 calendar 集合才算真成功)。
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 发现结果:拿到一个可用的"日历集合列表"端点 */
|
||||
export class DiscoverResult {
|
||||
ok: boolean = false;
|
||||
url: string = ''; // 最终可用的端点 URL
|
||||
via: string = ''; // 来源:direct / well-known / candidate / public
|
||||
readOnly: boolean = false; // 是否只读(公开日历分享);本版本仅识别,不做写入
|
||||
hint: string = ''; // 失败时给用户的可操作提示
|
||||
}
|
||||
|
||||
/** 415/405 之外:用于判断"不是 DAV 端点"的状态码集合 */
|
||||
const NOT_DAV_CODES: number[] = [404, 405, 501];
|
||||
|
||||
export class DavDiscovery {
|
||||
// ───────────────────────── 公开工具:URL 形态识别 ─────────────────────────
|
||||
|
||||
/** 取 origin(scheme://host[:port]) */
|
||||
static originOf(url: string): string {
|
||||
const m = /^https?:\/\/[^/]+/i.exec(url.trim());
|
||||
return m !== null ? m[0] : '';
|
||||
}
|
||||
|
||||
/** 规范化:去掉末尾多余斜杠、去空白 */
|
||||
static tidy(url: string): string {
|
||||
let s: string = url.trim();
|
||||
// 去掉查询与片段(发现阶段不保留)
|
||||
s = s.replace(/[?#].*$/, '');
|
||||
while (s.endsWith('/') && s.length > DavDiscovery.originOf(s).length + 1) {
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 识别「网页分享链接」——这类地址对 PROPFIND 一律 405,必须提前拦下并提示。
|
||||
* 覆盖:
|
||||
* - Nextcloud 日历分享页:/apps/calendar/p/<token>
|
||||
* - Nextcloud 文件分享页:/s/<token>、/index.php/s/<token>
|
||||
* - Nextcloud 公开日历(只读,可用):/remote.php/dav/public-calendars/<token>
|
||||
*/
|
||||
static classify(url: string): string {
|
||||
const u: string = url.trim();
|
||||
const path = u.replace(/^https?:\/\/[^/]+/i, '');
|
||||
if (/^\/apps\/calendar\/p\//i.test(path)) {
|
||||
return 'share_calendar'; // 日历分享页面(网页)
|
||||
}
|
||||
if (/^\/(index\.php\/)?s\//i.test(path)) {
|
||||
return 'share_file'; // 文件分享页面(网页)
|
||||
}
|
||||
if (/^\/remote\.php\/dav\/public-calendars\//i.test(path)) {
|
||||
return 'public_caldav'; // 公开日历(DAV,只读)
|
||||
}
|
||||
if (/^\/remote\.php\/dav\/public-calendars$/i.test(path.replace(/\/$/, ''))) {
|
||||
return 'public_caldav';
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/** 分享链接 → 给用户的人话解释 */
|
||||
static shareHint(kind: string): string {
|
||||
if (kind === 'share_calendar') {
|
||||
return '这是 Nextcloud 的「日历分享链接」(网页地址),CalDAV 客户端不能直接使用。'
|
||||
+ '请在 Nextcloud 网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」,'
|
||||
+ '把得到的私有链接填到「服务器地址」。';
|
||||
}
|
||||
if (kind === 'share_file') {
|
||||
return '这是文件分享链接(网页地址),不是 CalDAV 地址。'
|
||||
+ '请改用日历的「私有链接」:Nextcloud 网页版「日历 → 设置 → 复制私有链接」。';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为"只可能返回 405/404 的网页地址"——用于在探测前直接拦截,省一次请求。
|
||||
*/
|
||||
static isWebPage(url: string): boolean {
|
||||
const k: string = DavDiscovery.classify(url);
|
||||
return k === 'share_calendar' || k === 'share_file';
|
||||
}
|
||||
|
||||
/**
|
||||
* DAV 根 / 站点根 → 需要补全。用于判断是否值得走候选路径。
|
||||
* `/remote.php/dav` 或仅 origin 时,多半缺"日历家目录"这一层。
|
||||
*/
|
||||
static looksIncomplete(url: string): boolean {
|
||||
const path: string = DavDiscovery.tidy(url).replace(/^https?:\/\/[^/]+/i, '').replace(/\/$/, '');
|
||||
if (path === '' || path === '/') {
|
||||
return true; // 只填了域名
|
||||
}
|
||||
if (/\/remote\.php\/dav$/i.test(path)) {
|
||||
return true; // DAV 根
|
||||
}
|
||||
if (/\/remote\.php\/dav\/(calendars|addressbooks)$/i.test(path)) {
|
||||
return true; // 少了用户名一层
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ───────────────────────── 探测原语 ─────────────────────────
|
||||
|
||||
/**
|
||||
* PROPFIND Depth:0 探测端点:只关心"能不能列、是不是 DAV"。
|
||||
* 不抛错:网络异常/状态码异常都返回可判定的结果。
|
||||
*/
|
||||
static async probe(url: string, auth: string): Promise<number> {
|
||||
const body: string =
|
||||
'<?xml version="1.0" encoding="utf-8"?>' +
|
||||
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: 'PROPFIND' as http.RequestMethod,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Depth': '0',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: body,
|
||||
connectTimeout: 8000,
|
||||
readTimeout: 12000
|
||||
});
|
||||
LogUtil.write(`HTTP PROPFIND(发现) ${url} → ${resp.responseCode}`);
|
||||
return resp.responseCode;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`PROPFIND(发现) ${url} 异常: ${e.code} - ${e.message}`);
|
||||
return 0; // 0 = 网络层失败
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ OPTIONS 探测服务器的 DAV 能力:
|
||||
* - 返回 `DAV:` 响应头 → 标准 WebDAV
|
||||
* - 返回 `Allow` 里含 PROPFIND/REPORT → 支持 CalDAV 所需方法
|
||||
* 用于区分"这不是 DAV 端点"(如网页路由)与"服务端把方法挡了"。
|
||||
*/
|
||||
static async optionsCapability(url: string, auth: string): Promise<DavCapability> {
|
||||
const cap = new DavCapability();
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: http.RequestMethod.OPTIONS,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
connectTimeout: 8000,
|
||||
readTimeout: 12000
|
||||
});
|
||||
cap.status = resp.responseCode;
|
||||
const headers = resp.header as Record<string, string>;
|
||||
if (headers !== undefined && headers !== null) {
|
||||
// 头名大小写不统一,逐个大小写变体试
|
||||
const dav = headers['DAV'] ?? headers['Dav'] ?? headers['dav'] ?? '';
|
||||
const allow = headers['Allow'] ?? headers['allow'] ?? '';
|
||||
cap.davHeader = typeof dav === 'string' ? dav : '';
|
||||
cap.allow = typeof allow === 'string' ? allow : '';
|
||||
cap.isDav = cap.davHeader !== '' || /PROPFIND/i.test(cap.allow);
|
||||
cap.supportsPropfind = /PROPFIND/i.test(cap.allow) || cap.isDav;
|
||||
cap.supportsReport = /REPORT/i.test(cap.allow);
|
||||
// Allow 明确列出但两者都没有 → 大概率不是 DAV
|
||||
if (cap.allow !== '' && !cap.supportsPropfind) {
|
||||
cap.isDav = false;
|
||||
}
|
||||
}
|
||||
LogUtil.write(`OPTIONS ${url} → ${resp.responseCode},DAV=${cap.davHeader || '(无)'},Allow=${cap.allow || '(无)'}`);
|
||||
return cap;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`OPTIONS ${url} 异常: ${e.code} - ${e.message}`);
|
||||
return cap;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 候选路径生成 ─────────────────────────
|
||||
|
||||
/**
|
||||
* 生成候选「日历家目录 / 集合列表」端点。
|
||||
* @param base 用户填入并规范化后的地址
|
||||
* @param username 用户名(用于 Nextcloud 的 calendars/<用户名>/ 等模式)
|
||||
*/
|
||||
static candidates(base: string, username: string): string[] {
|
||||
const origin: string = DavDiscovery.originOf(base);
|
||||
const out: string[] = [];
|
||||
if (origin === '') {
|
||||
return out;
|
||||
}
|
||||
const path: string = DavDiscovery.tidy(base).substring(origin.length);
|
||||
// 站点可能部署在子目录(如 /nextcloud)——保留用户填的前缀,只补 DAV 相关部分
|
||||
// 例:填 https://h/nextcloud → 前缀 /nextcloud
|
||||
let prefix: string = '';
|
||||
const idx: number = path.toLowerCase().indexOf('/remote.php');
|
||||
if (idx >= 0) {
|
||||
prefix = path.substring(0, idx);
|
||||
} else if (path !== '' && path !== '/') {
|
||||
prefix = path.replace(/\/$/, '');
|
||||
}
|
||||
const u: string = encodeURIComponent(username);
|
||||
const push = (p: string): void => {
|
||||
const full: string = origin + p;
|
||||
if (out.indexOf(full) < 0 && DavDiscovery.tidy(full) !== DavDiscovery.tidy(base)) {
|
||||
out.push(full);
|
||||
}
|
||||
};
|
||||
// Nextcloud / ownCloud
|
||||
push(`${prefix}/remote.php/dav/calendars/${u}/`);
|
||||
push(`${prefix}/remote.php/dav`);
|
||||
// Radicale(通常直接就是 /<用户名>/)
|
||||
push(`${prefix}/${u}/`);
|
||||
// Baïkal
|
||||
push(`${prefix}/dav.php/calendars/${u}/`);
|
||||
// 通用:直接在根下找
|
||||
push(`${prefix}/`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 主入口:在给定端点集合上依次探测,返回第一个"能列出日历"的端点。
|
||||
* 判据=PROPFIND 得 207/200。真正的"日历集合筛选"仍由 DavClient.listCalendars 完成
|
||||
* (本方法只负责找到"哪个地址对",避免与既有解析逻辑重复)。
|
||||
*
|
||||
* @param rawUrl 用户填入的原始地址(未规范化也可)
|
||||
* @param username 用户名
|
||||
* @param auth Authorization 头
|
||||
* @param verify 可选的校验函数:传入端点,返回是否真的能列出日历(由调用方注入 listCalendars)
|
||||
*/
|
||||
static async resolve(rawUrl: string, username: string, auth: string,
|
||||
verify?: (url: string) => Promise<boolean>): Promise<DiscoverResult> {
|
||||
const res = new DiscoverResult();
|
||||
const base: string = DavDiscovery.tidy(rawUrl);
|
||||
|
||||
// ① 网页分享链接:直接拦下,不浪费请求
|
||||
const kind: string = DavDiscovery.classify(base);
|
||||
if (kind === 'share_calendar' || kind === 'share_file') {
|
||||
res.ok = false;
|
||||
res.hint = DavDiscovery.shareHint(kind);
|
||||
LogUtil.write(`发现:识别为网页分享链接,已拦截(${kind})`);
|
||||
return res;
|
||||
}
|
||||
|
||||
const check = async (url: string, via: string): Promise<boolean> => {
|
||||
const code: number = await DavDiscovery.probe(url, auth);
|
||||
if (code !== 207 && code !== 200) {
|
||||
return false;
|
||||
}
|
||||
if (verify !== undefined) {
|
||||
const good: boolean = await verify(url);
|
||||
if (!good) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
res.ok = true;
|
||||
res.url = url;
|
||||
res.via = via;
|
||||
res.readOnly = (kind === 'public_caldav');
|
||||
LogUtil.write(`发现成功(${via}):${url}`);
|
||||
return true;
|
||||
};
|
||||
|
||||
// ① 用户填的地址优先(保持既有行为)
|
||||
if (await check(base, 'direct')) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// ①b ⭐ 公开日历(只读订阅):Nextcloud 的 public-calendars 链接本身即端点。
|
||||
// 此类端点通常无需认证,且只能读——后续由 SyncEngine 的写权限探测自动标记为只读。
|
||||
if (kind === 'public_caldav') {
|
||||
// 若用户填的是 public-calendars 根(少了 token),无法继续推断,直接给提示
|
||||
res.ok = false;
|
||||
res.hint = '这是「公开日历」链接但似乎不完整。请使用完整的公开日历地址'
|
||||
+ '(形如 https://你的域名/remote.php/dav/public-calendars/<令牌>/),'
|
||||
+ '或改用日历的「私有链接」以获得可读可写体验。';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ② .well-known/caldav(RFC 6764)——标准自动发现
|
||||
const origin: string = DavDiscovery.originOf(base);
|
||||
if (origin !== '') {
|
||||
const wk: string = await DavDiscovery.resolveWellKnown(origin, auth);
|
||||
if (wk !== '') {
|
||||
if (await check(wk, 'well-known')) {
|
||||
return res;
|
||||
}
|
||||
// .well-known 给了地址但直接探测不通过 → 也纳入候选由 ③ 继续尝试其下级
|
||||
LogUtil.write(`.well-known 指向 ${wk},但该地址未通过日历校验,继续尝试常见路径`);
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 常见候选路径
|
||||
const cands: string[] = DavDiscovery.candidates(base, username);
|
||||
for (const c of cands) {
|
||||
if (await check(c, 'candidate')) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部失败 → 给可操作提示(区分"不是 DAV 端点"与"能连但列不出日历")
|
||||
res.ok = false;
|
||||
const cap: DavCapability = await DavDiscovery.optionsCapability(base, auth);
|
||||
if (cap.status === 405 || (!cap.isDav && cap.allow !== '' && !cap.supportsPropfind)) {
|
||||
res.hint = '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。'
|
||||
+ '常见原因是填了网页分享链接、或只填了站点的 DAV 根。'
|
||||
+ 'Nextcloud 请填「日历 → 设置 → 复制私有链接」得到的地址,例如:'
|
||||
+ '`https://你的域名/remote.php/dav/calendars/你的用户名/`';
|
||||
} else if (cap.status === 401) {
|
||||
res.hint = '服务器拒绝凭据(401),请检查用户名与密码。'
|
||||
+ 'Nextcloud 若开启了二次验证,需要使用「应用密码」。';
|
||||
} else if (cap.status === 301 || cap.status === 302) {
|
||||
res.hint = '服务器发生了跳转,通常说明 `.well-known/caldav` 未正确配置'
|
||||
+ '(常见于反向代理或子目录部署)。可请 NAS 管理员把 `.well-known/caldav` '
|
||||
+ '指向 `/remote.php/dav`,或直接填写完整的「日历家目录」地址。';
|
||||
} else {
|
||||
res.hint = '已连接上服务器,但在该地址下未发现任何日历本。'
|
||||
+ '请确认填的是「日历家目录」而不是 DAV 根,例如:'
|
||||
+ '`https://你的域名/remote.php/dav/calendars/你的用户名/`';
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 .well-known/caldav:
|
||||
* - 直接返回 207/200 → 该 URL 本身即端点
|
||||
* - 返回 301/302/307/308 → 读 Location 头作为端点
|
||||
* - 返回 401 时也读 Location(Nextcloud 常在认证后跳转)
|
||||
*/
|
||||
static async resolveWellKnown(origin: string, auth: string): Promise<string> {
|
||||
const url: string = `${origin}/.well-known/caldav`;
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: 'PROPFIND' as http.RequestMethod,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Depth': '0',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: '<?xml version="1.0" encoding="utf-8"?>' +
|
||||
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',
|
||||
connectTimeout: 8000,
|
||||
readTimeout: 12000
|
||||
});
|
||||
LogUtil.write(`HTTP PROPFIND(.well-known) ${url} → ${resp.responseCode}`);
|
||||
if (resp.responseCode === 207 || resp.responseCode === 200) {
|
||||
return url;
|
||||
}
|
||||
if (resp.responseCode === 301 || resp.responseCode === 302
|
||||
|| resp.responseCode === 307 || resp.responseCode === 308 || resp.responseCode === 401) {
|
||||
const headers = resp.header as Record<string, string>;
|
||||
if (headers !== undefined && headers !== null) {
|
||||
const loc = headers['Location'] ?? headers['location'] ?? '';
|
||||
if (typeof loc === 'string' && loc !== '') {
|
||||
const full: string = loc.startsWith('http') ? loc : origin + (loc.startsWith('/') ? '' : '/') + loc;
|
||||
LogUtil.write(`.well-known 跳转到:${full}`);
|
||||
return DavDiscovery.tidy(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`.well-known 探测异常: ${e.code} - ${e.message}`);
|
||||
return '';
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态码是否表示"该地址不是 DAV 端点" */
|
||||
static isNotDav(code: number): boolean {
|
||||
return NOT_DAV_CODES.indexOf(code) >= 0;
|
||||
}
|
||||
|
||||
// ───────────────────────── 给用户的"人话提示"(两个页面共用) ─────────────────────────
|
||||
|
||||
/**
|
||||
* ⭐ 把 HTTP 状态码翻译成"人话 + 可操作指引"。
|
||||
* 用户看到"服务器返回 405"无从下手,这里直接告诉他下一步做什么。
|
||||
*/
|
||||
static statusHint(code: number): string {
|
||||
if (code === 405) {
|
||||
return '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
|
||||
+ '常见原因:填了「分享链接」(网页地址),或只填到 DAV 根。\n'
|
||||
+ '请填「日历家目录」,例如:\n'
|
||||
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
|
||||
+ 'Nextcloud 可在网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」拿到这个地址。';
|
||||
}
|
||||
if (code === 401) {
|
||||
return '服务器拒绝凭据(401):请检查用户名与密码。\n'
|
||||
+ '若服务器开启了二次验证(2FA),需要使用「应用密码」而不是登录密码。';
|
||||
}
|
||||
if (code === 403) {
|
||||
return '服务器拒绝访问(403):账号可能无权限,或 IP 被限制。请检查账号权限与服务器访问限制。';
|
||||
}
|
||||
if (code === 404) {
|
||||
return '地址不存在(404):请检查路径是否写错。\n'
|
||||
+ '正确形式通常为 https://你的域名/remote.php/dav/calendars/你的用户名/';
|
||||
}
|
||||
if (code === 301 || code === 302 || code === 307 || code === 308) {
|
||||
return '服务器发生跳转:通常说明 `.well-known/caldav` 未正确配置(常见于反向代理或子目录部署)。\n'
|
||||
+ '请直接填写完整的「日历家目录」地址,或请 NAS 管理员配置 `.well-known/caldav`。';
|
||||
}
|
||||
if (code === 0) {
|
||||
return '无法连接服务器:请确认地址、端口与网络(内网地址需在同一局域网)。';
|
||||
}
|
||||
return `获取日历列表失败,服务器返回:${code}`;
|
||||
}
|
||||
|
||||
/** ⭐ 能连上但"未发现日历本"时的提示:区分"地址差一层"与"确实是空的" */
|
||||
static emptyHint(url: string, username: string): string {
|
||||
if (DavDiscovery.looksIncomplete(url)) {
|
||||
const cands: string[] = DavDiscovery.candidates(url, username);
|
||||
const example: string = cands.length > 0 ? cands[0] : '';
|
||||
return '该地址下没有日历本 —— 多半是地址「少了一层」。\n\n'
|
||||
+ '你填的是服务器根路径,但 CalDAV 需要填到「日历家目录」。请试试:\n'
|
||||
+ (example !== '' ? example + '\n\n' : '\n')
|
||||
+ '(Nextcloud 可在网页版「日历 → 设置 → 复制私有链接」直接拿到)';
|
||||
}
|
||||
return '该地址下未发现日历本。请确认:\n'
|
||||
+ '1) 服务器上确实已创建日历;\n'
|
||||
+ '2) 填的是「日历家目录」(形如 .../remote.php/dav/calendars/用户名/)而不是 DAV 根。';
|
||||
}
|
||||
}
|
||||
|
||||
/** OPTIONS 探测结果 */
|
||||
export class DavCapability {
|
||||
status: number = 0;
|
||||
davHeader: string = ''; // `DAV:` 响应头(含 "1", "2", "calendar-access" 等)
|
||||
allow: string = ''; // `Allow` 头
|
||||
isDav: boolean = false;
|
||||
supportsPropfind: boolean = false;
|
||||
supportsReport: boolean = false;
|
||||
}
|
||||
@@ -24,9 +24,30 @@ export class LocalEvent {
|
||||
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
|
||||
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
|
||||
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
|
||||
/** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。
|
||||
* 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**,
|
||||
* 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */
|
||||
dtstart: number = 0;
|
||||
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
|
||||
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
|
||||
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
|
||||
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
|
||||
recurrenceId: number = 0;
|
||||
/**
|
||||
* 优先级,用 **iCal 原生值**:1=最高 … 9=最低,0=未设置。
|
||||
* 直接存原生值,写回时零转换,也不会与服务器上其他客户端写下的值语义不一致。
|
||||
*/
|
||||
priority: number = 0;
|
||||
/** 分类标签,逗号分隔(对应 iCal CATEGORIES,多值用逗号连接) */
|
||||
categories: string = '';
|
||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
|
||||
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
|
||||
/** 父任务 UID(空 = 顶层任务)。对应 VTODO 的 RELATED-TO:RELTYPE=PARENT。
|
||||
* 用 UID 而非本地 id:父子关系是服务器资源属性,跨设备同步时 UID 才稳定。 */
|
||||
parentUid: string = '';
|
||||
/** 附件 JSON 数组字符串:[{name,mime,size,base64}],空 = 无附件。
|
||||
* base64 直接存库,同步时拼 ATTACH 行零转换。 */
|
||||
attachments: string = '';
|
||||
}
|
||||
|
||||
/** 远端事件条目(REPORT 解析结果) */
|
||||
@@ -36,6 +57,10 @@ export class RemoteEvent {
|
||||
title: string = '';
|
||||
description: string = '';
|
||||
location: string = '';
|
||||
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
|
||||
priority: number = 0;
|
||||
/** 分类标签,逗号分隔(对应 CATEGORIES) */
|
||||
categories: string = '';
|
||||
startTime: number = 0;
|
||||
endTime: number = 0;
|
||||
isAllDay: boolean = false;
|
||||
@@ -43,14 +68,59 @@ export class RemoteEvent {
|
||||
isTodo: boolean = false; // VTODO 待办
|
||||
completed: boolean = false; // VTODO STATUS:COMPLETED
|
||||
rrule: string = ''; // 原始 RRULE
|
||||
dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开
|
||||
exdate: string = ''; // 原始 EXDATE(分号分隔)
|
||||
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
|
||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
|
||||
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 {
|
||||
private static db: relationalStore.RdbStore | null = null;
|
||||
|
||||
/**
|
||||
* ⭐ 统计"是覆盖实例(RECURRENCE-ID 行)但还没回填 recurrence_id"的行数。
|
||||
*
|
||||
* 语义:`recurring=1 且 rrule=''` ⟺ 这一行的 .ics 里有 RECURRENCE-ID。
|
||||
* 老库升级后这些行的 recurrence_id 一律是 0(字段以前被丢弃,无法从本地反推),
|
||||
* 必须靠一次全量重拉(GET 每个 .ics)才能填上。
|
||||
* **用数据本身当标志,而不是内存变量** —— 卡片进程/进程重启会把内存标志吃掉
|
||||
*(实测 2026-09-19 18:30/18:36 两次同步都没触发全量重拉)。
|
||||
* 回填完成后返回值自然变 0,不会反复触发。
|
||||
*/
|
||||
static async countOverridesMissingRecurrenceId(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store = await EventDb.getDb(context);
|
||||
const rs = await store.querySql(
|
||||
"select count(*) c from events " +
|
||||
"where deleted = 0 and recurring = 1 and rrule = '' and recurrence_id = 0");
|
||||
let n: number = 0;
|
||||
try {
|
||||
if (rs.goToNextRow()) {
|
||||
n = rs.getLong(rs.getColumnIndex('c'));
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
return n;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static async getDb(context: common.Context): Promise<relationalStore.RdbStore> {
|
||||
if (EventDb.db !== null) {
|
||||
return EventDb.db;
|
||||
@@ -89,6 +159,31 @@ export class EventDb {
|
||||
} 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 {
|
||||
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
|
||||
} catch (err) {
|
||||
@@ -104,6 +199,12 @@ export class EventDb {
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
// ⭐ 覆盖实例的"原始发生时刻"(RECURRENCE-ID)。老库升级后该列一律为 0,需要一次全量重拉回填
|
||||
try {
|
||||
await store.executeSql('ALTER TABLE events ADD COLUMN recurrence_id INTEGER DEFAULT 0');
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
EventDb.db = store;
|
||||
return store;
|
||||
}
|
||||
@@ -124,16 +225,32 @@ export class EventDb {
|
||||
return EventDb.normalizeReminders(list).join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 覆盖实例(RECURRENCE-ID 独立行)"排除主事件哪一次发生"用的键。
|
||||
*
|
||||
* 必须优先用 **recurrenceId**(原始发生时刻):覆盖行的 startTime 是**被改到的新时间**,
|
||||
* 用它做键时主事件在**原时间**的那次发生不会被排除 → App 与系统日历里同一日程出现两条
|
||||
* (2026-09-19 真机实测:窗口内 63 条这种"幽灵日程")。
|
||||
* recurrenceId = 0 表示旧库数据(还没回填)→ 回退到 startTime,保持老行为不变。
|
||||
*/
|
||||
static overrideKey(calKey: string, uid: string, recurrenceId: number, startTime: number): string {
|
||||
return `${calKey}_${uid}_${recurrenceId > 0 ? recurrenceId : startTime}`;
|
||||
}
|
||||
|
||||
private static fromRow(rs: relationalStore.ResultSet): LocalEvent {
|
||||
const e = new LocalEvent();
|
||||
e.id = rs.getLong(rs.getColumnIndex('id'));
|
||||
e.uid = rs.getString(rs.getColumnIndex('uid'));
|
||||
e.dtstart = rs.getLong(rs.getColumnIndex('dtstart'));
|
||||
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
|
||||
e.href = rs.getString(rs.getColumnIndex('href'));
|
||||
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
|
||||
e.title = rs.getString(rs.getColumnIndex('title'));
|
||||
e.description = rs.getString(rs.getColumnIndex('description'));
|
||||
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.endTime = rs.getLong(rs.getColumnIndex('end_time'));
|
||||
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
|
||||
@@ -146,8 +263,13 @@ export class EventDb {
|
||||
e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1;
|
||||
e.rrule = rs.getString(rs.getColumnIndex('rrule'));
|
||||
e.exdate = rs.getString(rs.getColumnIndex('exdate'));
|
||||
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
|
||||
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'));
|
||||
if (remStr !== '') {
|
||||
e.reminders = remStr.split(',').map((s: string): number => Number(s))
|
||||
@@ -178,8 +300,13 @@ export class EventDb {
|
||||
'completed': e.completed ? 1 : 0,
|
||||
'rrule': e.rrule,
|
||||
'exdate': e.exdate,
|
||||
'recurrence_id': e.recurrenceId,
|
||||
'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;
|
||||
}
|
||||
@@ -281,6 +408,90 @@ export class EventDb {
|
||||
}
|
||||
|
||||
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
|
||||
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
|
||||
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
|
||||
/** 保存循环规则与基准(设置/取消循环后调用) */
|
||||
static async updateTodoRecurrence(context: common.Context, id: number,
|
||||
rrule: string, dtstart: number, etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const values: relationalStore.ValuesBucket = {
|
||||
'rrule': rrule,
|
||||
'dtstart': dtstart,
|
||||
'recurring': rrule !== '' ? 1 : 0,
|
||||
'etag': etag,
|
||||
'dirty': 0
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(values, predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 循环待办"完成一轮"后更新本地:推后的基准/截止 + 清除完成态。
|
||||
*
|
||||
* ⚠️ 必须同时把 completed 置 0、dirty 清掉:
|
||||
* 否则界面会显示成"已完成",而服务器上其实是"下一轮未完成",两边不一致。
|
||||
*/
|
||||
static async updateTodoAfterRound(context: common.Context, id: number,
|
||||
dtstart: number, dueMs: number, etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const values: relationalStore.ValuesBucket = {
|
||||
'dtstart': dtstart,
|
||||
'start_time': dueMs,
|
||||
'end_time': dueMs,
|
||||
'completed': 0,
|
||||
'etag': etag,
|
||||
'dirty': 0
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(values, predicates);
|
||||
}
|
||||
|
||||
/** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */
|
||||
static async updateTodoFields(context: common.Context, id: number, title: string,
|
||||
desc: string, dueMs: number, etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const values: relationalStore.ValuesBucket = {
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'start_time': dueMs,
|
||||
'etag': etag,
|
||||
'dirty': 0
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(values, predicates);
|
||||
}
|
||||
|
||||
static async setTodoCompleted(context: common.Context, id: number, completed: boolean,
|
||||
etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const values: relationalStore.ValuesBucket = {
|
||||
'completed': completed ? 1 : 0,
|
||||
'dirty': 0,
|
||||
'etag': etag
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(values, predicates);
|
||||
}
|
||||
|
||||
static async queryById(context: common.Context, id: number): Promise<LocalEvent | null> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
const rs = await store.query(predicates);
|
||||
try {
|
||||
if (rs.goToNextRow()) {
|
||||
return EventDb.fromRow(rs);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
@@ -322,6 +533,72 @@ export class EventDb {
|
||||
}
|
||||
|
||||
/** 未来 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[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
@@ -370,20 +647,66 @@ export class EventDb {
|
||||
rs.close();
|
||||
}
|
||||
|
||||
const remoteKeys: string[] = [];
|
||||
// ⭐ 2226(2026-10-15 提速):下方逐条要判"这条远端事件本地有没有对应行"。
|
||||
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
|
||||
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
|
||||
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
|
||||
// dirty 行不入 `keyed`、同键取**最先**出现的那行。
|
||||
// ⭐ 但仅排除出 keyed不够(见下方 REVIVE_GUARD):还需单独索引 dirty 行并主动拦截,
|
||||
// 否则远端数据查不到 key 会走新增分支,反而插出重复行。
|
||||
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) {
|
||||
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
|
||||
if (e.dirty && e.deleted) {
|
||||
dirtyDeletedUids.add(e.uid);
|
||||
}
|
||||
if (e.dirty) {
|
||||
if (!dirtyKeyed.has(k)) {
|
||||
dirtyKeyed.set(k, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!keyed.has(k)) {
|
||||
keyed.set(k, e);
|
||||
}
|
||||
}
|
||||
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
|
||||
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
|
||||
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
|
||||
const seenKeys: Set<string> = new Set<string>();
|
||||
for (const r of remote) {
|
||||
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间
|
||||
const key: string = `${r.uid}_${r.startTime}`;
|
||||
remoteKeys.push(key);
|
||||
const found = existing.find((x: LocalEvent): boolean =>
|
||||
!x.dirty && x.uid === r.uid && x.startTime === r.startTime);
|
||||
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间 + RECURRENCE-ID
|
||||
//(同一时刻可能同时存在"主事件"与"覆盖该次发生的实例行",只带上 recurrenceId 才唯一)
|
||||
const key: string = `${r.uid}_${r.startTime}_${r.recurrenceId}`;
|
||||
if (seenKeys.has(key)) {
|
||||
continue; // 同一资源内的重复条目 → 只保留一条
|
||||
}
|
||||
seenKeys.add(key);
|
||||
// ⭐ REVIVE_GUARD 本地已删待推送 → 远端旧版本一律忽略,防止死灰复燃
|
||||
if (dirtyDeletedUids.has(r.uid)) {
|
||||
continue;
|
||||
}
|
||||
// ⭐ 本地有未推送的修改 → 保留本地版本,绝不能让远端旧版覆盖或重复插入
|
||||
if (dirtyKeyed.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const found: LocalEvent | undefined = keyed.get(key);
|
||||
if (found === undefined) {
|
||||
// 新增
|
||||
const e = new LocalEvent();
|
||||
e.uid = r.uid;
|
||||
e.calKey = calKey;
|
||||
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.description = r.description;
|
||||
e.location = r.location;
|
||||
@@ -398,14 +721,30 @@ export class EventDb {
|
||||
e.completed = r.completed;
|
||||
e.rrule = r.rrule;
|
||||
e.exdate = r.exdate;
|
||||
e.recurrenceId = r.recurrenceId;
|
||||
e.reminder = r.reminder;
|
||||
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));
|
||||
added++;
|
||||
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|
||||
|| found.reminder !== r.reminder || found.recurring !== r.recurring
|
||||
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)) {
|
||||
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填)
|
||||
|| 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) {
|
||||
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
|
||||
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
|
||||
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
|
||||
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
|
||||
found.href = href;
|
||||
// 服务器上资源被改名时同步更新,否则后续 DELETE/PUT 会打到旧路径
|
||||
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
|
||||
found.remotePath = r.remotePath;
|
||||
}
|
||||
found.title = r.title;
|
||||
found.description = r.description;
|
||||
found.location = r.location;
|
||||
@@ -420,14 +759,39 @@ export class EventDb {
|
||||
found.completed = r.completed;
|
||||
found.rrule = r.rrule;
|
||||
found.exdate = r.exdate;
|
||||
found.recurrenceId = r.recurrenceId;
|
||||
found.reminder = r.reminder;
|
||||
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');
|
||||
up.equalTo('id', found.id);
|
||||
await store.update(EventDb.toBucket(found), up);
|
||||
updated++;
|
||||
} else {
|
||||
unchanged++;
|
||||
// ⭐ UNCHANGED_REMOTE_PATH etag 未变是**常态路径**(REPORT 只给 etag,
|
||||
// 未变就直接复用本地行、不 GET)。实测 742 行里 99% 走这里。
|
||||
// 若只在"更新分支"回填 remotePath,对存量数据等于无效 ——
|
||||
// 历史行里存的是旧的 uid 猜测值,而 DELETE/PUT 正是用它拼 URL,
|
||||
// 拼错就 404(服务器上删不掉)。
|
||||
// 所以这里必须一并校正 href 与 remotePath。
|
||||
const hb: relationalStore.ValuesBucket = {};
|
||||
if (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');
|
||||
hp.equalTo('id', found.id);
|
||||
await store.update(hb, hp);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 删除远端已不存在的(排除本地修改未推送的)
|
||||
@@ -435,8 +799,9 @@ export class EventDb {
|
||||
if (local.dirty) {
|
||||
continue;
|
||||
}
|
||||
const localKey: string = `${local.uid}_${local.startTime}`;
|
||||
if (!remoteKeys.includes(localKey)) {
|
||||
const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
|
||||
// ⭐ 同样从 O(m) 的数组 includes 换成 Set 查询(见上方 keyed 的说明)
|
||||
if (!seenKeys.has(localKey)) {
|
||||
const del = new relationalStore.RdbPredicates('events');
|
||||
del.equalTo('id', local.id);
|
||||
await store.delete(del);
|
||||
@@ -446,11 +811,72 @@ export class EventDb {
|
||||
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
|
||||
}
|
||||
|
||||
/** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行) */
|
||||
/**
|
||||
* 清理"同一资源"的冗余重复行(**纯本地去重,绝不触碰服务器**)。
|
||||
*
|
||||
* 起因:旧版 applyRemote 用循环前的一次性 existing 快照判重,同一个 .ics 里若含
|
||||
* 多条相同 (UID, DTSTART) 的 VEVENT,第二条仍被判为"新增"再插一行 →
|
||||
* 本地库出现 href / etag / remote_path 完全相同的重复行(2026-09-19 实测 10 组)。
|
||||
* 影响:① App 里同一日程显示两条;② 镜像到系统日历后同一 identifier 互相覆盖,
|
||||
* 出现"计划 984 条、系统只写出 983 条"的口径差。
|
||||
*
|
||||
* 口径:同 (cal_key, uid, start_time, recurrence_id) 且在**同一资源**(href 非空)下,
|
||||
* 只保留 id 最小的一行。
|
||||
* 含本地未推送修改(dirty=1)的行整个不参与,绝不会连带删掉待推送数据。
|
||||
* 被删行**不置 dirty/deleted**,因此不会向服务器推送任何删除。
|
||||
* ⚠️ 分组必须带上 recurrence_id:主事件与"覆盖该次发生的实例行"可能同 uid 同 start_time,
|
||||
* 它们是两条**合法**的不同发生,不能被当成冗余行删掉。
|
||||
* @returns 删除的冗余行数
|
||||
*/
|
||||
static async dedupeDuplicateRows(context: common.Context): Promise<number> {
|
||||
let removed: number = 0;
|
||||
try {
|
||||
const store = await EventDb.getDb(context);
|
||||
// 只处理"远端资源、已删标记为 0、且没有本地未推送修改(dirty=0)"的行。
|
||||
// 某个组里只要有一行是 dirty,它就不参与分组 → 组内只剩 1 行 → count(*)>1 不成立 → 不动。
|
||||
// 这样绝不会把"待推送的本地修改"连带删掉。
|
||||
const rs = await store.querySql(
|
||||
"select group_concat(id) ids from events " +
|
||||
"where deleted = 0 and dirty = 0 and href <> '' " +
|
||||
"group by cal_key, uid, start_time, recurrence_id having count(*) > 1");
|
||||
const groups: string[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
const v: string = rs.getString(rs.getColumnIndex('ids'));
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
groups.push(v);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
for (const g of groups) {
|
||||
const ids: number[] = g.split(',')
|
||||
.map((s: string): number => Number(s.trim()))
|
||||
.filter((n: number): boolean => !Number.isNaN(n));
|
||||
ids.sort((a: number, b: number): number => a - b);
|
||||
for (let i: number = 1; i < ids.length; i++) { // 0 号(最小 id)保留
|
||||
const del = new relationalStore.RdbPredicates('events');
|
||||
del.equalTo('id', ids[i]);
|
||||
await store.delete(del);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 去重失败不影响主流程
|
||||
return removed;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行)
|
||||
* ⭐ 固定按 id 升序:镜像/展示遇到"同 uid 多行"时谁先谁后必须稳定,
|
||||
* 否则同一 identifier 每次换一行 → 系统日历在两处之间反复翻转(实测过)。 */
|
||||
static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
|
||||
predicates.orderByAsc('id');
|
||||
const rs = await store.query(predicates);
|
||||
const list: LocalEvent[] = [];
|
||||
try {
|
||||
@@ -482,6 +908,144 @@ export class EventDb {
|
||||
await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 取同一个 UID 在同一个日历本下的**全部行**(主事件 + 覆盖实例)。
|
||||
* 用于"重复日程的单次/后续/全部"编辑与删除:重建整个系列后一次性推送。
|
||||
* 只返回未删除的行。
|
||||
*/
|
||||
static async queryByUid(context: common.Context, calKey: string, uid: string): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid);
|
||||
predicates.orderByAsc('id');
|
||||
const rs = await store.query(predicates);
|
||||
const list: LocalEvent[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
const e = EventDb.fromRow(rs);
|
||||
if (!e.deleted) {
|
||||
list.push(e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 物理删除某 uid 在某个日历本下的全部行("删除全部"用) */
|
||||
static async purgeByUid(context: common.Context, calKey: string, uid: string): Promise<number> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid);
|
||||
return await store.delete(predicates);
|
||||
}
|
||||
|
||||
/** 物理删除某 uid 在某个日历本下、开始时间 >= from 的全部行("此次及以后"删除用) */
|
||||
static async purgeByUidFrom(context: common.Context, calKey: string, uid: string,
|
||||
from: number): Promise<number> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid)
|
||||
.and().greaterThanOrEqualTo('start_time', from);
|
||||
return await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 把某个 uid 系列下、开始时间 >= from 的行搬到另一个 uid("此次及以后"改期的实现):
|
||||
* 主事件被截断(加 UNTIL)后,剩余部分作为**新的独立系列**存在,否则会被原系列覆盖。
|
||||
* 返回被搬迁的行数。
|
||||
*/
|
||||
static async reassignUidFrom(context: common.Context, calKey: string, oldUid: string,
|
||||
newUid: string, newPath: string, from: number): Promise<number> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'uid': newUid,
|
||||
'remote_path': newPath,
|
||||
'etag': '',
|
||||
'dirty': 1
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid)
|
||||
.and().greaterThanOrEqualTo('start_time', from);
|
||||
return await store.update(bucket, predicates);
|
||||
}
|
||||
|
||||
/** 给单行写入 EXDATE("仅删除这一次"→ 排除主事件的该次发生) */
|
||||
static async setExdate(context: common.Context, id: number, exdate: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const bucket: relationalStore.ValuesBucket = { 'exdate': exdate, 'dirty': 1 };
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(bucket, predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 把某 uid 系列下、**RECURRENCE-ID >= from** 的覆盖实例行搬到新 uid
|
||||
* ("此次及以后"拆分系列时用;主事件行 recurrenceId=0,天然不会被命中)。
|
||||
*/
|
||||
static async reassignOverridesFrom(context: common.Context, calKey: string, oldUid: string,
|
||||
newUid: string, newPath: string, from: number): Promise<number> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'uid': newUid,
|
||||
'remote_path': newPath,
|
||||
'etag': '',
|
||||
'dirty': 1
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid)
|
||||
.and().greaterThanOrEqualTo('recurrence_id', from);
|
||||
return await store.update(bucket, predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 给某个 uid 系列(指定日历本)的主事件写入 RRULE("此次及以后"截断/改期时用)。
|
||||
* 只作用于有 RRULE 的主事件行。
|
||||
*/
|
||||
static async setRruleForUid(context: common.Context, calKey: string, uid: string,
|
||||
rrule: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const bucket: relationalStore.ValuesBucket = { 'rrule': rrule, 'dirty': 1 };
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid)
|
||||
.and().notEqualTo('rrule', '');
|
||||
await store.update(bucket, predicates);
|
||||
}
|
||||
|
||||
/** 新增一行(供拆分系列时插入新主事件;已置 dirty) */
|
||||
static async insertRow(context: common.Context, e: LocalEvent): Promise<number> {
|
||||
return await EventDb.insertLocal(context, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 用一份原始快照**还原**某一行的内容(仅内容字段,不动 id)。
|
||||
* 用途:编辑重复日程的"仅这一次 / 此次及以后"时,`doSave` 会把当前行就地写成新值,
|
||||
* 但该行属于"原系列"(被 EXDATE 排除或被 UNTIL 截断),必须还原,
|
||||
* 否则原系列里会残留一条被改过的幽灵行。
|
||||
*/
|
||||
static async restoreRowById(context: common.Context, id: number, src: LocalEvent): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'title': src.title,
|
||||
'description': src.description,
|
||||
'location': src.location,
|
||||
'start_time': src.startTime,
|
||||
'end_time': src.endTime,
|
||||
'is_all_day': src.isAllDay ? 1 : 0,
|
||||
'rrule': src.rrule,
|
||||
'exdate': src.exdate,
|
||||
'recurrence_id': src.recurrenceId,
|
||||
'recurring': src.recurring ? 1 : 0,
|
||||
'reminder': src.reminders.length > 0 ? src.reminders[0] : src.reminder,
|
||||
'reminders': src.reminders.map((n: number): string => String(n)).join(','),
|
||||
'dirty': 1
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(bucket, predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。
|
||||
* 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。
|
||||
@@ -517,6 +1081,46 @@ export class EventDb {
|
||||
await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除某账号下、属于指定日历本集合(按 href 匹配)的全部本地日程/待办。
|
||||
*
|
||||
* 用途:日历本在服务器上被删除(同步 REPORT 返回 404)或用户取消勾选时,
|
||||
* 必须在 calKey 序号迁移**之前**先删掉这些行——否则序号收窄后,被移除日历本的
|
||||
* 旧行会与新搬到该序号下的其它日历本行混在同一个 calKey 里(2026-09-29 实测:
|
||||
* 「读书」被服务器删除后重选,其 988 行混进「轻舟」的 calKey)。
|
||||
* 行上自带 href(日历本集合 URL),按 href 删比按 calKey 删更稳——
|
||||
* 历史原因(旧版迁移缺陷)行可能挂在错误的 calKey 下,href 才是行的真实归属。
|
||||
*/
|
||||
static async pruneByHrefs(context: common.Context, accId: string, hrefs: string[]): Promise<number> {
|
||||
if (hrefs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.like('cal_key', `${accId}_%`);
|
||||
predicates.and().in('href', hrefs);
|
||||
return await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理「href 已不属于任何已选日历本」的残留行(跨账号兜底清扫)。
|
||||
* 典型:服务器删除日历本后,旧版把其残留行混进其它日历本的 calKey(见 pruneByHrefs 注释),
|
||||
* 这类行 calKey 看似有效、pruneOrphanCalKeys 抓不到,但 href 暴露了真实归属。
|
||||
* ⚠️ 跳过 dirty 行(用户未推送的本地修改绝不能被清扫掉)与 local 行。
|
||||
*/
|
||||
static async pruneStaleHrefs(context: common.Context, validHrefs: string[]): Promise<number> {
|
||||
if (validHrefs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.notEqualTo('cal_key', 'local');
|
||||
predicates.and().notEqualTo('href', '');
|
||||
predicates.and().notIn('href', validHrefs);
|
||||
predicates.and().equalTo('dirty', 0);
|
||||
return await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,57 @@
|
||||
// entry/src/main/ets/common/LogUtil.ets
|
||||
// 同步日志:写入应用 filesDir/sync.log(含时间戳),超 512KB 自动截断保留后半。
|
||||
// 设置页「同步日志」卡片可查看/复制/清空,方便真机测试时诊断同步与提醒问题。
|
||||
// 日志工具:默认**只输出到 console(hilog)**,不落盘。
|
||||
//
|
||||
// ⭐ 0.0.8 起:发行版**不再写 sync.log**(原「同步记录」/诊断落盘功能已移除)。
|
||||
// 需要真机排查同步/提醒问题时,把下面的 ENABLE_FILE_LOG 改成 true 重新编译即可恢复
|
||||
// 落盘(含 512KB 自动截断保留后半),其余调用点一行都不用动。
|
||||
// 关闭状态下会在 init 时顺手删掉历史版本遗留的 sync.log 与 mirror_diag.txt,
|
||||
// 保证升级上来的设备沙箱也是干净的。
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { fileIo as fs } from '@kit.CoreFileKit';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
export class LogUtil {
|
||||
/** ⭐ 落盘开关:发行版置 false(仅 console);真机排查时改 true 恢复文件日志 */
|
||||
private static readonly ENABLE_FILE_LOG: boolean = false;
|
||||
private static dir: string = '';
|
||||
private static path: string = '';
|
||||
private static readonly MAX_BYTES: number = 512 * 1024;
|
||||
|
||||
static init(context: common.Context): void {
|
||||
try {
|
||||
LogUtil.path = `${context.filesDir}/sync.log`;
|
||||
LogUtil.dir = context.filesDir;
|
||||
LogUtil.path = `${LogUtil.dir}/sync.log`;
|
||||
} catch (err) {
|
||||
LogUtil.dir = '';
|
||||
LogUtil.path = '';
|
||||
}
|
||||
if (!LogUtil.ENABLE_FILE_LOG && LogUtil.dir !== '') {
|
||||
LogUtil.removeLegacyDebugFiles();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除历史版本遗留的调试落盘文件(sync.log / mirror_diag.txt) */
|
||||
private static removeLegacyDebugFiles(): void {
|
||||
for (const name of ['sync.log', 'mirror_diag.txt']) {
|
||||
try {
|
||||
fs.unlinkSync(`${LogUtil.dir}/${name}`);
|
||||
} catch (err) {
|
||||
// 文件不存在属正常,忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static logFilePath(): string {
|
||||
return LogUtil.path;
|
||||
}
|
||||
|
||||
/** 写一行日志:console + 文件(文件失败静默,不影响业务) */
|
||||
/** 写一行日志:console + 文件(发行版不落盘;文件失败静默,不影响业务) */
|
||||
static write(msg: string): void {
|
||||
console.info(`SyncLog ${msg}`);
|
||||
if (!LogUtil.ENABLE_FILE_LOG) {
|
||||
return;
|
||||
}
|
||||
if (LogUtil.path === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// entry/src/main/ets/common/MirrorInbound.ets
|
||||
// 入站对账:把"用户在系统日历里做的改动"读回来,写回本地库并置 dirty,随下一次同步推送到 CalDAV。
|
||||
//
|
||||
// 这是「系统日历 ↔ 同步日历 App ↔ CalDAV 服务器」闭环的**回程**。
|
||||
// 出站(CalDAV → 系统日历)在 SystemCalendarMirror,本文件负责反向。
|
||||
//
|
||||
// ⚠️ 防回灌是这里唯一的核心难题:系统日历里既有"我们写进去的",也有"用户改的/新建的/删的",
|
||||
// 靠 `MirrorSnapshot`(上次写入值的内容指纹)区分:
|
||||
//
|
||||
// | 系统日历当前状态 | 判定 | 动作 |
|
||||
// |-----------------------------------|------------------|---------------------------|
|
||||
// | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 |
|
||||
// | 有 identifier,指纹 != 快照 | 用户改了 | 写回本地库 → 置 dirty 推送 |
|
||||
// | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) |
|
||||
// | 快照有,系统里没了 | 用户删了 | markDeleted → 推送 DELETE |
|
||||
// | identifier 不是我们的格式 | 用户在系统日历新建 | 新建 LocalEvent → 推送 |
|
||||
//
|
||||
// ⚠️ "没有快照就补快照后跳过"这条非常关键:首次启用回写时快照是空的,
|
||||
// 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。
|
||||
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { LogUtil } from './LogUtil';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { MirrorSnapshot, MirrorSnapshotRow } from './MirrorSnapshot';
|
||||
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
|
||||
/** 一次入站对账的统计 */
|
||||
export class InboundResult {
|
||||
created: number = 0; // 系统日历新建 → 加进 CalDAV
|
||||
updated: number = 0; // 系统日历修改 → 回写 CalDAV
|
||||
deleted: number = 0; // 系统日历删除 → 从 CalDAV 删
|
||||
skipped: number = 0;
|
||||
failed: number = 0;
|
||||
}
|
||||
|
||||
export class MirrorInbound {
|
||||
private static readonly DAY: number = 86400000;
|
||||
private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [
|
||||
'id', 'type', 'title', 'startTime', 'endTime', 'isAllDay',
|
||||
'description', 'location', 'identifier', 'reminderTime', 'recurrenceRule'
|
||||
];
|
||||
|
||||
/**
|
||||
* 入站对账主入口。
|
||||
* ⚠️ 必须在 `SyncEngine.syncAccount()` **之前**调用 —— 它产生的 dirty 行要借本次同步一起推送。
|
||||
*/
|
||||
static async reconcile(context: common.Context): Promise<InboundResult> {
|
||||
const res: InboundResult = new InboundResult();
|
||||
try {
|
||||
const on: boolean = await AppSettings.getMirrorInbound(context);
|
||||
if (!on) {
|
||||
return res;
|
||||
}
|
||||
if (!await SystemCalendarMirror.hasPermission()) {
|
||||
return res;
|
||||
}
|
||||
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
|
||||
// ⭐ 与出站镜像用**同一套**稳定引用(bookId 由服务器 href 派生),否则两边对不上号
|
||||
const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context);
|
||||
for (const ref of refs) {
|
||||
try {
|
||||
await MirrorInbound.reconcileBook(context, cm, ref, res);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
res.failed++;
|
||||
LogUtil.write(`入站对账失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (res.created + res.updated + res.deleted > 0) {
|
||||
LogUtil.write(
|
||||
`入站对账:新建=${res.created} 修改=${res.updated} 删除=${res.deleted} 跳过=${res.skipped}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`入站对账异常: ${e.message}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/* ==================== 单个日历本 ==================== */
|
||||
|
||||
/**
|
||||
* 取回该账户下已有日程。
|
||||
*
|
||||
* 🔴 2026-10-15 实测(DEL-AL20 / 7.0.0.109):`getEvents(undefined, 字段数组)` 在这个
|
||||
* 系统版本上**必抛** `401 type error!`。而本文件原先正是这么调的 ⇒ "入站对账"对
|
||||
* **每一个**日历本都失败(真机日志 12/12 全是 `401 type error!`),
|
||||
* 整个"把系统日历里的改动回写 CalDAV"等于死的(还白费 12 次调用)。
|
||||
* 出站镜像(SystemCalendarMirror.queryEvents)早就踩过同一个坑,改成"先无参、失败再带字段"。
|
||||
* 这里沿用同一策略:无参调用返回的 Event 里 identifier 同样有值。
|
||||
*/
|
||||
private static async queryEvents(cal: calendarManager.Calendar): Promise<calendarManager.Event[]> {
|
||||
try {
|
||||
return await cal.getEvents();
|
||||
} catch (err) {
|
||||
LogUtil.write(`入站对账 getEvents() 失败,退回带字段:${(err as BusinessError).message}`);
|
||||
return await cal.getEvents(undefined, MirrorInbound.QUERY_FIELDS);
|
||||
}
|
||||
}
|
||||
|
||||
private static async reconcileBook(
|
||||
context: common.Context,
|
||||
cm: calendarManager.CalendarManager,
|
||||
ref: BookRef,
|
||||
res: InboundResult
|
||||
): Promise<void> {
|
||||
const calKey: string = ref.calKey; // 仅用于查本地库
|
||||
const bucket: string = ref.bookId; // ⭐ 系统日历侧的一切标识都用 bookId
|
||||
const accountName: string = ref.sysAccountName;
|
||||
let cal: calendarManager.Calendar | undefined = undefined;
|
||||
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
|
||||
for (const c of calendars) {
|
||||
if (c.getAccount().name === accountName) {
|
||||
cal = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cal === undefined) {
|
||||
return; // 还没镜像过,无从对账
|
||||
}
|
||||
const href: string = ref.href;
|
||||
if (href === '') {
|
||||
LogUtil.write(`入站对账跳过 bookId=${bucket}:找不到对应的日历本集合 URL`);
|
||||
return;
|
||||
}
|
||||
|
||||
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
|
||||
const events: calendarManager.Event[] = await MirrorInbound.queryEvents(cal);
|
||||
|
||||
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
|
||||
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||||
const others: calendarManager.Event[] = [];
|
||||
for (const ev of events) {
|
||||
const id: string = ev.identifier ?? '';
|
||||
if (id !== '' && id.startsWith(`${bucket}|`)) {
|
||||
mine.set(id, ev);
|
||||
} else {
|
||||
others.push(ev);
|
||||
}
|
||||
}
|
||||
|
||||
// 本地库现有事件:uid -> LocalEvent
|
||||
const locals: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||
const byUid: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
||||
for (const e of locals) {
|
||||
if (e.uid !== '') {
|
||||
byUid.set(e.uid, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ① 修改
|
||||
for (const id of mine.keys()) {
|
||||
const ev: calendarManager.Event | undefined = mine.get(id);
|
||||
if (ev === undefined) {
|
||||
continue;
|
||||
}
|
||||
const fp: string = MirrorSnapshot.fingerprintOf(ev);
|
||||
const snap: MirrorSnapshotRow | undefined = snaps.get(id);
|
||||
if (snap === undefined) {
|
||||
// ⚠️ 没有快照:认作是我们自己写的,补一份后跳过 —— 绝不误判成"用户改了"
|
||||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||||
res.skipped++;
|
||||
continue;
|
||||
}
|
||||
snaps.delete(id); // 还活着,稍后剩下的才是"被删了"
|
||||
if (snap.fingerprint === fp) {
|
||||
res.skipped++; // 没动
|
||||
continue;
|
||||
}
|
||||
// ⚠️ 重复日程的**某一次发生**(identifier 形如 `<bookId>|<uid>@<发生时间戳>`):
|
||||
// 它是我们从一条重复日程展开出来的,不是独立事件 —— 改它没法映射回服务器上的
|
||||
// 那一条 VEVENT,直接跳过,避免把服务器数据搞乱。
|
||||
if (MirrorInbound.isOccurrence(id)) {
|
||||
res.skipped++;
|
||||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||||
continue;
|
||||
}
|
||||
const uid: string = MirrorSnapshot.uidOf(id);
|
||||
const local: LocalEvent | undefined = byUid.get(uid);
|
||||
if (local === undefined) {
|
||||
res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站镜像处理
|
||||
continue;
|
||||
}
|
||||
if (local.dirty || local.deleted) {
|
||||
res.skipped++; // 本地本来就有待推送的改动 → 不拿系统值覆盖,避免两边打架
|
||||
continue;
|
||||
}
|
||||
MirrorInbound.applyToLocal(local, ev);
|
||||
await EventDb.updateLocal(context, local);
|
||||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||||
res.updated++;
|
||||
LogUtil.write(`入站修改回写:${id}(${local.title})`);
|
||||
}
|
||||
|
||||
// ② 删除:快照里剩下的(系统日历里已经没有了)
|
||||
for (const id of snaps.keys()) {
|
||||
const snap: MirrorSnapshotRow | undefined = snaps.get(id);
|
||||
if (snap === undefined) {
|
||||
continue;
|
||||
}
|
||||
// 同上:展开出来的"某一次发生"消失(多半是滑出镜像窗口),不能当成用户删除
|
||||
if (MirrorInbound.isOccurrence(id)) {
|
||||
res.skipped++;
|
||||
await MirrorSnapshot.remove(context, id);
|
||||
continue;
|
||||
}
|
||||
const local: LocalEvent | undefined = byUid.get(snap.uid);
|
||||
if (local !== undefined && !local.deleted) {
|
||||
await EventDb.markDeleted(context, local.id);
|
||||
res.deleted++;
|
||||
LogUtil.write(`入站删除回写:${id}(${local.title})`);
|
||||
} else {
|
||||
res.skipped++;
|
||||
}
|
||||
await MirrorSnapshot.remove(context, id);
|
||||
}
|
||||
|
||||
// ③ 新建:系统日历里 identifier 不是我们格式的条目(用户在系统日历里手动加的)
|
||||
for (const ev of others) {
|
||||
if (ev.id === undefined) {
|
||||
continue;
|
||||
}
|
||||
const uid: string = `sc-${bucket}-${ev.id}`;
|
||||
if (byUid.has(uid)) {
|
||||
continue;
|
||||
}
|
||||
// ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。
|
||||
// 否则下一次出站镜像会把同一条再加一遍 → 系统日历里出现重复。
|
||||
const identifier: string = `${bucket}|${uid}`;
|
||||
const stamped: calendarManager.Event = ev;
|
||||
stamped.identifier = identifier;
|
||||
try {
|
||||
await cal.updateEvent(stamped);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
res.skipped++;
|
||||
LogUtil.write(`入站新建跳过:无法写回 identifier(${e.message}),避免重复`);
|
||||
continue;
|
||||
}
|
||||
const e: LocalEvent = new LocalEvent();
|
||||
e.uid = uid;
|
||||
e.calKey = calKey;
|
||||
e.href = href;
|
||||
e.remotePath = encodeURIComponent(uid) + '.ics';
|
||||
e.kind = 'event';
|
||||
MirrorInbound.applyToLocal(e, ev);
|
||||
await EventDb.insertLocal(context, e);
|
||||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(
|
||||
bucket, identifier, ev, MirrorSnapshot.fingerprintOf(ev)));
|
||||
res.created++;
|
||||
LogUtil.write(`入站新建回写:${identifier}(${e.title})`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 辅助 ==================== */
|
||||
|
||||
/**
|
||||
* 是不是"重复系列里的某一次发生"?identifier 形如 `<bookId>|<uid>@<发生时间戳>`。
|
||||
*
|
||||
* ⚠️ 不能用 `includes('@')` 判断:**uid 本身就常含 @**(如 `20260906T163329-d13e0cb6@172.17.0.1`),
|
||||
* 会误判。只有"最后一段是纯数字"才是我们加的发生时间戳。
|
||||
*/
|
||||
private static isOccurrence(id: string): boolean {
|
||||
const pos: number = id.lastIndexOf('@');
|
||||
if (pos < 0) {
|
||||
return false;
|
||||
}
|
||||
const tail: string = id.substring(pos + 1);
|
||||
if (tail === '') {
|
||||
return false;
|
||||
}
|
||||
for (let i: number = 0; i < tail.length; i++) {
|
||||
const c: string = tail[i];
|
||||
if (c < '0' || c > '9') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static rowOf(
|
||||
bucket: string,
|
||||
identifier: string,
|
||||
ev: calendarManager.Event,
|
||||
fp: string
|
||||
): MirrorSnapshotRow {
|
||||
const row: MirrorSnapshotRow = new MirrorSnapshotRow();
|
||||
row.identifier = identifier;
|
||||
row.calKey = bucket;
|
||||
row.uid = MirrorSnapshot.uidOf(identifier);
|
||||
row.sysId = ev.id ?? -1;
|
||||
row.fingerprint = fp;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 系统日历 Event → 本工程 LocalEvent(只覆盖可映射字段) */
|
||||
private static applyToLocal(e: LocalEvent, ev: calendarManager.Event): void {
|
||||
e.title = ev.title ?? '';
|
||||
e.description = ev.description ?? '';
|
||||
e.location = ev.location?.location ?? '';
|
||||
e.startTime = ev.startTime;
|
||||
e.isAllDay = ev.isAllDay === true;
|
||||
// 系统全天 endTime 可能是"次日 00:00",也可能是"当日 23:59:59.999"
|
||||
// → 统一成本工程约定:"排他结束日前一毫秒"
|
||||
e.endTime = e.isAllDay
|
||||
? MirrorInbound.dayStart(ev.endTime - 1) + MirrorInbound.DAY - 1
|
||||
: ev.endTime;
|
||||
e.rrule = MirrorInbound.rruleOf(ev);
|
||||
e.recurring = e.rrule !== '';
|
||||
if (ev.reminderTime !== undefined && ev.reminderTime.length > 0) {
|
||||
e.reminders = [...ev.reminderTime];
|
||||
e.reminder = ev.reminderTime[0];
|
||||
}
|
||||
}
|
||||
|
||||
private static dayStart(ts: number): number {
|
||||
const d: Date = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/** 系统 RecurrenceRule → RRULE 字符串(仅 FREQ/INTERVAL;复杂规则以服务器端为准) */
|
||||
private static rruleOf(ev: calendarManager.Event): string {
|
||||
try {
|
||||
const rr = ev.recurrenceRule;
|
||||
if (rr === undefined) {
|
||||
return '';
|
||||
}
|
||||
const freqMap: string[] = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'];
|
||||
const fi: number = rr.recurrenceFrequency as number;
|
||||
if (Number.isNaN(fi) || fi < 0 || fi >= freqMap.length) {
|
||||
return '';
|
||||
}
|
||||
let rrule: string = `FREQ=${freqMap[fi]}`;
|
||||
if (rr.interval !== undefined && rr.interval > 1) {
|
||||
rrule += `;INTERVAL=${rr.interval}`;
|
||||
}
|
||||
return rrule;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// entry/src/main/ets/common/MirrorRenamer.ets
|
||||
// ⭐ 一键重命名镜像账户的显示名(0.0.8)
|
||||
//
|
||||
// 为什么需要这个模块:HarmonyOS 的 `@ohos.calendarManager` **没有"修改账户"的 API**
|
||||
// (只有 create/delete/get/getAll;CalendarConfig 仅 enableReminder+color;
|
||||
// CalendarAccount.name 是 readonly)→ `displayName` 只能在创建时定,之后改不了。
|
||||
// ⇒ 想换显示名,只能"**删掉旧账户 → 按新名字重建**"。
|
||||
//
|
||||
// ⭐⭐ 安全顺序(不可调换):先回读 → 再删 → 重建 → 全量重写
|
||||
// ① `MirrorInbound.reconcile()`:把用户在系统日历里**手动新建/修改**的日程先收进本地库(置 dirty)
|
||||
// —— 因为 `deleteCalendar` 会删掉该账户下**所有**日程(含用户手动加的),不回读就会丢数据。
|
||||
// ② 删除旧镜像账户(按 bookId 精确定位)
|
||||
// ③ 立即 `syncNow()`:按用户所选的新格式重建账户并全量写回(含刚回读的 dirty 项)
|
||||
//
|
||||
// 为什么放在独立模块:`MirrorInbound` 已经 import 了 `SystemCalendarMirror`,
|
||||
// 若再把 `MirrorInbound` 反向 import 进 `SystemCalendarMirror` 会形成循环依赖;
|
||||
// 本模块作为"编排层"同时依赖二者,方向单一、无环。
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { SystemCalendarMirror, BookRef, MirrorResult } from './SystemCalendarMirror';
|
||||
import { MirrorInbound, InboundResult } from './MirrorInbound';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 重命名结果(供 UI 提示) */
|
||||
export class RenameResult {
|
||||
ok: boolean = false;
|
||||
readBack: number = 0; // 回读到的用户改动条数(created+updated+deleted)
|
||||
removed: number = 0; // 删除的旧账户数
|
||||
mirrored: number = 0; // 重建后镜像的日历本数
|
||||
message: string = '';
|
||||
}
|
||||
|
||||
export class MirrorRenamer {
|
||||
/**
|
||||
* 按当前(已落盘的)显示名格式,重建全部镜像账户的显示名。
|
||||
*
|
||||
* ⚠️ 前提:调用方已把用户所选格式写入 `AppSettings`(`setMirrorDisplayFormat`);
|
||||
* 本方法只负责"按现有配置重新应用"。
|
||||
* ⚠️ 需要系统日历读写权限(调用方先校验 / 申请)。
|
||||
*/
|
||||
static async reapplyDisplayNames(context: common.Context): Promise<RenameResult> {
|
||||
const res = new RenameResult();
|
||||
if (!await SystemCalendarMirror.hasPermission()) {
|
||||
res.message = '未获得系统日历读写权限';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ① 先回读:把用户在系统日历里的手动新建/修改收进本地库(置 dirty),避免删除时丢数据
|
||||
try {
|
||||
const inbound: InboundResult = await MirrorInbound.reconcile(context);
|
||||
res.readBack = inbound.created + inbound.updated + inbound.deleted;
|
||||
LogUtil.write(`重命名-回读完成:新增=${inbound.created} 修改=${inbound.updated} 删除=${inbound.deleted}`);
|
||||
} catch (err) {
|
||||
// 回读失败 → **中止重命名**(宁可不改,也不能冒着丢数据的风险删账户)
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`重命名中止:回读失败 ${e.message}`);
|
||||
res.message = '未能先同步系统日历中的改动,已取消操作(避免数据丢失)。请稍后重试。';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ② 取出当前选中的镜像本(当前配置)
|
||||
let refs: BookRef[] = [];
|
||||
try {
|
||||
refs = await SystemCalendarMirror.selectedBookRefs(context);
|
||||
} catch (err) {
|
||||
res.message = '读取镜像日历本失败';
|
||||
return res;
|
||||
}
|
||||
if (refs.length === 0) {
|
||||
res.message = '当前没有选中的镜像日历本';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ③ 删除这些本的旧镜像账户(按 bookId,与名字无关)
|
||||
const bookIds: string[] = refs.map((r: BookRef): string => r.bookId);
|
||||
res.removed = await SystemCalendarMirror.removeAccountsByBookIds(context, bookIds);
|
||||
LogUtil.write(`重命名:已删除 ${res.removed} 个旧账户,准备按新格式重建`);
|
||||
|
||||
// ④ 立即按新格式重建并全量写回(含刚回读的 dirty 项)
|
||||
try {
|
||||
const mr: MirrorResult = await SystemCalendarMirror.syncNow(context);
|
||||
res.mirrored = mr.books;
|
||||
res.ok = true;
|
||||
res.message = '已按新的显示方式重新写入系统日历';
|
||||
LogUtil.write(`重命名完成:重建 ${mr.books} 个本(新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted})`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
res.ok = false;
|
||||
res.message = `重建失败:${e.message}`;
|
||||
LogUtil.write(`重命名重建失败:${e.message}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否还有"旧格式"(`同步日历 · xxx`)的镜像账户 —— 用于设置页给出"可更换"的弱提示。
|
||||
* 只读,不做任何修改。
|
||||
*/
|
||||
static async hasLegacyNamedAccounts(context: common.Context): Promise<boolean> {
|
||||
return await SystemCalendarMirror.hasLegacyDisplayName(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// entry/src/main/ets/common/MirrorSnapshot.ets
|
||||
// 镜像快照:记录"上一次我们把什么写进了系统日历"。
|
||||
//
|
||||
// 为什么必须有它(防回灌 / 防死循环):
|
||||
// 流程是 CalDAV → 本地库 → 写进系统日历。下一轮同步如果直接读系统日历当"用户改动",
|
||||
// 就会把我们刚写进去的当成用户改的 → 回写服务器 → 服务器变了 → 又镜像回系统日历 …… 死循环。
|
||||
// 有了快照就能三方比对:
|
||||
// · 系统当前值 == 快照 → 没动,是我们自己写的,跳过
|
||||
// · 系统当前值 != 快照 → 用户在系统日历改了 → 回写 CalDAV
|
||||
// · 快照有、系统里没了 → 用户在系统日历删了 → 从 CalDAV 删
|
||||
// · 系统里有、快照没有且 identifier 不是我们的格式 → 用户在系统日历新建 → 加进 CalDAV
|
||||
//
|
||||
// ⚠️ 只存"状态指纹",**不存日程内容**(内容仍在系统日历与本地库各一份,这是阶段 2 的取舍)。
|
||||
// 日程内容只有一份的形态(阶段 3)需要配合"影子表 + 系统日历为内容源",那是另一件事。
|
||||
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { relationalStore } from '@kit.ArkData';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/**
|
||||
* 快照行。
|
||||
* ⚠️ `calKey` 这一列实际存的是**镜像桶标识 = bookId**(由服务器 href 派生的稳定标识),
|
||||
* 与系统日历侧 identifier 前缀、账户名后缀完全一致。列名沿用未改,别再传会变的 calKey。
|
||||
*/
|
||||
export class MirrorSnapshotRow {
|
||||
identifier: string = ''; // `<bookId>|<uid>`
|
||||
calKey: string = '';
|
||||
uid: string = '';
|
||||
sysId: number = -1; // 系统日历里的 event id(删除时用)
|
||||
fingerprint: string = ''; // 写入值的内容指纹
|
||||
}
|
||||
|
||||
export class MirrorSnapshot {
|
||||
private static readonly TABLE: string = 'mirror_snapshot';
|
||||
private static ready: boolean = false;
|
||||
|
||||
/* ==================== 表 ==================== */
|
||||
|
||||
private static async db(context: common.Context): Promise<relationalStore.RdbStore> {
|
||||
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(context, {
|
||||
name: 'sync_calendar.db',
|
||||
securityLevel: relationalStore.SecurityLevel.S1
|
||||
});
|
||||
if (!MirrorSnapshot.ready) {
|
||||
await store.executeSql(
|
||||
'CREATE TABLE IF NOT EXISTS mirror_snapshot (' +
|
||||
'identifier TEXT PRIMARY KEY, cal_key TEXT, uid TEXT, ' +
|
||||
'sys_id INTEGER, fingerprint TEXT, updated_at INTEGER)'
|
||||
);
|
||||
MirrorSnapshot.ready = true;
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/* ==================== 读写 ==================== */
|
||||
|
||||
/** 取某个日历本的全部快照,key = identifier。**bucket 必须是 bookId** */
|
||||
static async loadMap(context: common.Context, bucket: string): Promise<Map<string, MirrorSnapshotRow>> {
|
||||
const out: Map<string, MirrorSnapshotRow> = new Map<string, MirrorSnapshotRow>();
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
predicates.equalTo('cal_key', bucket);
|
||||
const rs = await store.query(predicates);
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
const row: MirrorSnapshotRow = new MirrorSnapshotRow();
|
||||
row.identifier = rs.getString(rs.getColumnIndex('identifier'));
|
||||
row.calKey = bucket;
|
||||
row.uid = rs.getString(rs.getColumnIndex('uid'));
|
||||
row.sysId = rs.getLong(rs.getColumnIndex('sys_id'));
|
||||
row.fingerprint = rs.getString(rs.getColumnIndex('fingerprint'));
|
||||
out.set(row.identifier, row);
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`读取镜像快照失败 bucket=${bucket}: ${e.message}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **整体重建**某个日历本的快照:先删光旧行,再写入新行。
|
||||
* 为什么不用增量:出站镜像后直接把系统日历里该账户的实际内容重新读一遍再落快照最稳,
|
||||
* 不用在写入过程中追踪 addEvent 返回的 id(批量 addEvents 还不返回 id)。
|
||||
*/
|
||||
static async replaceAll(
|
||||
context: common.Context,
|
||||
bucket: string,
|
||||
events: calendarManager.Event[]
|
||||
): Promise<number> {
|
||||
let n: number = 0;
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const del = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
del.equalTo('cal_key', bucket);
|
||||
await store.delete(del);
|
||||
const now: number = Date.now();
|
||||
for (const ev of events) {
|
||||
const id: string = ev.identifier ?? '';
|
||||
if (id === '') {
|
||||
continue; // 用户在系统日历自己加的、没有我们 identifier 的 → 不进快照(由入站对账新建)
|
||||
}
|
||||
const vb: relationalStore.ValuesBucket = {
|
||||
'identifier': id,
|
||||
'cal_key': bucket,
|
||||
'uid': MirrorSnapshot.uidOf(id),
|
||||
'sys_id': ev.id ?? -1,
|
||||
'fingerprint': MirrorSnapshot.fingerprintOf(ev),
|
||||
'updated_at': now
|
||||
};
|
||||
await store.insert(MirrorSnapshot.TABLE, vb);
|
||||
n++;
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`重建镜像快照失败 bucket=${bucket}: ${e.message}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 写入/更新单条快照(入站回写成功后同步指纹,避免下次重复判定为"用户改了") */
|
||||
static async put(context: common.Context, row: MirrorSnapshotRow): Promise<void> {
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'identifier': row.identifier,
|
||||
'cal_key': row.calKey,
|
||||
'uid': row.uid,
|
||||
'sys_id': row.sysId,
|
||||
'fingerprint': row.fingerprint,
|
||||
'updated_at': Date.now()
|
||||
};
|
||||
await store.insert(MirrorSnapshot.TABLE, bucket);
|
||||
} catch (err) {
|
||||
// 主键冲突时改成 update
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'sys_id': row.sysId,
|
||||
'fingerprint': row.fingerprint,
|
||||
'updated_at': Date.now()
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
predicates.equalTo('identifier', row.identifier);
|
||||
await store.update(bucket, predicates);
|
||||
} catch (err2) {
|
||||
const e = err2 as BusinessError;
|
||||
LogUtil.write(`写入镜像快照失败 ${row.identifier}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单条(用户删了 / 本地已不存在) */
|
||||
static async remove(context: common.Context, identifier: string): Promise<void> {
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
predicates.equalTo('identifier', identifier);
|
||||
await store.delete(predicates);
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除整个日历本的快照(取消勾选 / 关闭镜像时调用)。bucket = bookId */
|
||||
static async removeByCalKey(context: common.Context, bucket: string): Promise<void> {
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
predicates.equalTo('cal_key', bucket);
|
||||
await store.delete(predicates);
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空全部快照(关闭镜像总开关时用,顺带清掉历史遗留的 calKey 键) */
|
||||
static async clearAll(context: common.Context): Promise<void> {
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
await store.delete(predicates);
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 指纹 ==================== */
|
||||
|
||||
/**
|
||||
* 事件内容指纹。用于判断"系统日历里这条,是不是还是我们上次写进去的样子"。
|
||||
* ⚠️ 全天日程的 endTime 会被系统归一化(我们写次日 00:00,可能读回 23:59:59.999),
|
||||
* 所以全天一律折成"日"再算指纹,否则每次都判定成"用户改了"。
|
||||
*/
|
||||
static fingerprintOf(ev: calendarManager.Event): string {
|
||||
const allDay: boolean = ev.isAllDay === true;
|
||||
let start: number = ev.startTime;
|
||||
let end: number = ev.endTime;
|
||||
if (allDay) {
|
||||
start = MirrorSnapshot.dayStart(start);
|
||||
end = MirrorSnapshot.dayStart(end - 1);
|
||||
}
|
||||
const raw: string = [
|
||||
ev.title ?? '',
|
||||
`${start}`,
|
||||
`${end}`,
|
||||
allDay ? '1' : '0',
|
||||
ev.location?.location ?? '',
|
||||
ev.description ?? '',
|
||||
MirrorSnapshot.hash(ev.recurrenceRule !== undefined ? JSON.stringify(ev.recurrenceRule) : '')
|
||||
].join('\u0001');
|
||||
return `${MirrorSnapshot.hash(raw)}:${MirrorSnapshot.hash(raw, 5381)}`;
|
||||
}
|
||||
|
||||
/** identifier (`<calKey>|<uid>`) → uid */
|
||||
static uidOf(identifier: string): string {
|
||||
const pos: number = identifier.indexOf('|');
|
||||
return pos < 0 ? '' : identifier.substring(pos + 1);
|
||||
}
|
||||
|
||||
private static dayStart(ts: number): number {
|
||||
const d: Date = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/** 简单字符串哈希(djb2 变体)。两个不同种子各算一次拼起来,降低碰撞概率 */
|
||||
private static hash(s: string, seed?: number): string {
|
||||
let h: number = seed === undefined ? 0 : seed;
|
||||
for (let i: number = 0; i < s.length; i++) {
|
||||
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// entry/src/main/ets/common/NetworkSyncWatcher.ets
|
||||
// ⭐ 网络恢复补同步:从"无网"变为"有网"时立刻同步一次。
|
||||
//
|
||||
// 为什么这条最有价值(《定时同步方案调研.md》第 ④ 层):
|
||||
// 延迟任务最短 2 小时、还会随应用活跃度降级,长时间在外(飞行模式/地铁/弱网)期间
|
||||
// 基本不会跑。用户一旦回到有网环境,**立刻对齐一次**,才能做到
|
||||
// "我要看的时候数据是最新的"。这比追求"进程常驻"实际得多。
|
||||
//
|
||||
// 实现:net.connection 的 netAvailable / netLost 事件 + 去抖,避免网络抖动时连打多次。
|
||||
import { connection } from '@kit.NetworkKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BackgroundSyncRunner } from './BackgroundSyncRunner';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 网络恢复后的去抖间隔:避免弱网下反复"断开→恢复"导致连续同步 */
|
||||
const DEBOUNCE_MS: number = 20 * 1000;
|
||||
|
||||
export class NetworkSyncWatcher {
|
||||
private static netConn: connection.NetConnection | null = null;
|
||||
/** 上一次触发同步的时间,用于去抖 */
|
||||
private static lastTriggerAt: number = 0;
|
||||
/** 当前是否处于"无网"状态(只有从无网恢复才触发,避免频繁切换网络时误触发) */
|
||||
private static offline: boolean = false;
|
||||
|
||||
/** 开始监听网络变化(幂等:重复调用不会重复注册) */
|
||||
static start(context: common.UIAbilityContext): void {
|
||||
if (NetworkSyncWatcher.netConn !== null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const conn: connection.NetConnection = connection.createNetConnection();
|
||||
conn.on('netLost', (): void => {
|
||||
NetworkSyncWatcher.offline = true;
|
||||
LogUtil.write('网络断开:标记为无网状态');
|
||||
});
|
||||
conn.on('netAvailable', (): void => {
|
||||
// ⭐ 只有"从无网恢复"才补同步:单纯切换 Wi-Fi/蜂窝时不重复触发
|
||||
if (!NetworkSyncWatcher.offline) {
|
||||
return;
|
||||
}
|
||||
NetworkSyncWatcher.offline = false;
|
||||
NetworkSyncWatcher.triggerDebounced(context, 'netRecover');
|
||||
});
|
||||
conn.register((): void => {
|
||||
LogUtil.write('网络监听已注册(网络恢复补同步)');
|
||||
});
|
||||
NetworkSyncWatcher.netConn = conn;
|
||||
} catch (err) {
|
||||
LogUtil.write(`网络监听注册失败:${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止监听(一般不需要,App 存活期间持续有效) */
|
||||
static stop(): void {
|
||||
if (NetworkSyncWatcher.netConn === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
NetworkSyncWatcher.netConn.unregister((err: object): void => {
|
||||
// 忽略取消失败
|
||||
});
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
NetworkSyncWatcher.netConn = null;
|
||||
}
|
||||
|
||||
/** 去抖后触发一次同步 */
|
||||
private static triggerDebounced(context: common.UIAbilityContext, tag: string): void {
|
||||
const now: number = Date.now();
|
||||
if (now - NetworkSyncWatcher.lastTriggerAt < DEBOUNCE_MS) {
|
||||
LogUtil.write(`网络恢复补同步:距上次过快(<20s),跳过`);
|
||||
return;
|
||||
}
|
||||
NetworkSyncWatcher.lastTriggerAt = now;
|
||||
LogUtil.write('网络已恢复 → 触发补同步');
|
||||
// 不 await:事件回调里不能阻塞;BackgroundSyncRunner 内部全 try-catch,不会抛出
|
||||
BackgroundSyncRunner.runOnce(context, tag);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// entry/src/main/ets/common/PlaceSuggest.ets
|
||||
// 地点补全/选点:基于鸿蒙系统内置的 Map Kit(Site Kit + sceneMap)
|
||||
//
|
||||
// 为什么用华为 Map Kit 而不是高德:
|
||||
// 1) 系统内置、无需第三方 SDK 依赖,也不需要我们自己维护一个 API Key;
|
||||
// 2) Site Kit 的 queryAutoComplete / searchByText / reverseGeocode **不需要任何权限**
|
||||
// (SDK 声明里没有 @permission),符合本项目「权限只在用户主动点该功能时申请」的硬约束;
|
||||
// 3) HarmonyOS 5.0 及以上 Map Kit 的地图加载/POI 搜索/地理编码当前免费(官方论坛口径)。
|
||||
//
|
||||
// ⚠️ 前置条件(必须由用户在 AGC 完成,代码无法替代):
|
||||
// AGC 项目 → 开放能力管理 → 打开「地图服务」,并**重新申请 Profile**(旧的 Profile 不含地图能力)。
|
||||
// 未开通时调用会抛 1002600004(The Map permission is not enabled)。
|
||||
// 因此本类所有入口都必须**优雅降级**:失败只返回空列表,绝不影响日程本身的保存。
|
||||
import { site, sceneMap, mapCommon } from '@kit.MapKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 一条地点候选(界面直接消费) */
|
||||
export class PlaceItem {
|
||||
/** Site Kit 的地点 ID,可用于 searchById 取详情 */
|
||||
siteId: string = '';
|
||||
/** 地点名称,如「星巴克(中关村店)」 */
|
||||
name: string = '';
|
||||
/** 完整格式化地址,如「北京市海淀区中关村大街1号」 */
|
||||
address: string = '';
|
||||
/** 纬度(无法取得时为 0) */
|
||||
lat: number = 0;
|
||||
/** 经度(无法取得时为 0) */
|
||||
lng: number = 0;
|
||||
/** 是否有有效坐标 */
|
||||
hasCoord: boolean = false;
|
||||
|
||||
/** 界面上第一行显示:有名称用名称,否则用地址 */
|
||||
get title(): string {
|
||||
return this.name !== '' ? this.name : this.address;
|
||||
}
|
||||
|
||||
/** 界面上第二行显示:名称+地址重复时不显示 */
|
||||
get subtitle(): string {
|
||||
if (this.address === '') {
|
||||
return '';
|
||||
}
|
||||
if (this.name === '' || this.address === this.name) {
|
||||
return '';
|
||||
}
|
||||
return this.address;
|
||||
}
|
||||
|
||||
/** 写入日程 location 字段的文本:优先完整地址,退化为名称 */
|
||||
get locationText(): string {
|
||||
return this.address !== '' ? this.address : this.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class PlaceSuggest {
|
||||
/** 地图能力是否可用(false 时界面应显示提示而不是反复重试) */
|
||||
private static unavailable: boolean = false;
|
||||
/** 最近一次失败原因(给界面显示,便于用户/开发者判断是"没开通"还是"网络问题") */
|
||||
private static lastError: string = '';
|
||||
/** 当前在途请求序号:用于丢弃过期结果(边打字边搜时后发先至) */
|
||||
private static seq: number = 0;
|
||||
|
||||
static isUnavailable(): boolean {
|
||||
return PlaceSuggest.unavailable;
|
||||
}
|
||||
|
||||
static getLastError(): string {
|
||||
return PlaceSuggest.lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键字补全:输入几个字返回候选地点。
|
||||
* 失败/未开通一律返回空数组(不抛异常,不影响其他功能)。
|
||||
* @param keyword 用户已输入的关键字
|
||||
* @param context 可选,传了就用带 context 的重载(定位更准)
|
||||
*/
|
||||
static async query(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
|
||||
const q: string = keyword.trim();
|
||||
if (q === '') {
|
||||
return [];
|
||||
}
|
||||
if (PlaceSuggest.unavailable) {
|
||||
return [];
|
||||
}
|
||||
// 序号机制:只接受最后一次请求的结果
|
||||
PlaceSuggest.seq++;
|
||||
const mySeq: number = PlaceSuggest.seq;
|
||||
try {
|
||||
const params: site.QueryAutoCompleteParams = {
|
||||
query: q,
|
||||
language: 'zh-CN',
|
||||
isCityLimit: false
|
||||
};
|
||||
const res: site.QueryAutoCompleteResult = context !== undefined
|
||||
? await site.queryAutoComplete(context, params)
|
||||
: await site.queryAutoComplete(params);
|
||||
if (mySeq !== PlaceSuggest.seq) {
|
||||
// 已有更新的请求发出,丢弃本次结果
|
||||
return [];
|
||||
}
|
||||
return PlaceSuggest.toItems(res.sites);
|
||||
} catch (err) {
|
||||
PlaceSuggest.handleError('补全', err);
|
||||
// 补全失败时退一步用关键字搜索(部分环境 autoComplete 不可用但 searchByText 可用)
|
||||
return await PlaceSuggest.searchFallback(q, context, mySeq);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键字搜索(补全失败时的兜底,也用于用户直接点"搜索")。
|
||||
*/
|
||||
static async search(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
|
||||
const q: string = keyword.trim();
|
||||
if (q === '' || PlaceSuggest.unavailable) {
|
||||
return [];
|
||||
}
|
||||
PlaceSuggest.seq++;
|
||||
const mySeq: number = PlaceSuggest.seq;
|
||||
return await PlaceSuggest.searchFallback(q, context, mySeq);
|
||||
}
|
||||
|
||||
private static async searchFallback(keyword: string, context: common.Context | undefined,
|
||||
mySeq: number): Promise<PlaceItem[]> {
|
||||
try {
|
||||
const params: site.SearchByTextParams = {
|
||||
query: keyword,
|
||||
language: 'zh-CN'
|
||||
};
|
||||
const res: site.SearchByTextResult = context !== undefined
|
||||
? await site.searchByText(context, params)
|
||||
: await site.searchByText(params);
|
||||
if (mySeq !== PlaceSuggest.seq) {
|
||||
return [];
|
||||
}
|
||||
return PlaceSuggest.toItems(res.sites);
|
||||
} catch (err) {
|
||||
PlaceSuggest.handleError('搜索', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开系统地图选点页,返回用户选中的地点(用户取消返回 null)。
|
||||
* 这是最省事也最不容易出错的方式:地图、搜索、周边都由系统界面提供。
|
||||
*/
|
||||
static async pickOnMap(context: common.UIAbilityContext): Promise<PlaceItem | null> {
|
||||
try {
|
||||
const options: sceneMap.LocationChoosingOptions = {
|
||||
language: 'zh-CN',
|
||||
searchEnabled: true,
|
||||
showNearbyPoi: true
|
||||
};
|
||||
const r: sceneMap.LocationChoosingResult = await sceneMap.chooseLocation(context, options);
|
||||
const item: PlaceItem = new PlaceItem();
|
||||
item.siteId = r.siteId !== undefined ? r.siteId : '';
|
||||
item.name = r.name !== undefined ? r.name : '';
|
||||
item.address = r.address !== undefined ? r.address : '';
|
||||
if (r.location !== undefined) {
|
||||
item.lat = r.location.latitude;
|
||||
item.lng = r.location.longitude;
|
||||
item.hasCoord = true;
|
||||
}
|
||||
// 两者都空视为取消/无效选择
|
||||
if (item.name === '' && item.address === '') {
|
||||
return null;
|
||||
}
|
||||
return item;
|
||||
} catch (err) {
|
||||
PlaceSuggest.handleError('地图选点', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成"名称 — 地址"这种便于粘贴进备注/地点的单行文本 */
|
||||
static formatOneLine(item: PlaceItem): string {
|
||||
if (item.name !== '' && item.address !== '' && item.name !== item.address) {
|
||||
return `${item.name}|${item.address}`;
|
||||
}
|
||||
return item.locationText;
|
||||
}
|
||||
|
||||
private static toItems(sites: site.Site[] | undefined): PlaceItem[] {
|
||||
const out: PlaceItem[] = [];
|
||||
if (sites === undefined) {
|
||||
return out;
|
||||
}
|
||||
for (let i = 0; i < sites.length; i++) {
|
||||
const s: site.Site = sites[i];
|
||||
const item: PlaceItem = new PlaceItem();
|
||||
item.siteId = s.siteId !== undefined ? s.siteId : '';
|
||||
item.name = s.name !== undefined ? s.name : '';
|
||||
// formatAddress 是"省市区+街道门牌"的完整串,最适合写进 LOCATION
|
||||
item.address = s.formatAddress !== undefined ? s.formatAddress : '';
|
||||
if (item.address === '' && s.addressComponent !== undefined) {
|
||||
// 极少数数据只有结构化 addressComponent(字段仅 countryName/locality/city/streetNumber),
|
||||
// 按"国 → 城市 → 区县(locality) → 街道门牌"拼一个保底地址
|
||||
item.address = PlaceSuggest.joinAddress(s.addressComponent);
|
||||
}
|
||||
if (s.location !== undefined) {
|
||||
item.lat = s.location.latitude;
|
||||
item.lng = s.location.longitude;
|
||||
item.hasCoord = true;
|
||||
}
|
||||
if (item.name !== '' || item.address !== '') {
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 从结构化地址组件拼一条保底地址(SDK 里就这几个字段) */
|
||||
private static joinAddress(ac: site.AddressComponent): string {
|
||||
const parts: string[] = [];
|
||||
if (ac.countryName !== undefined && ac.countryName !== '') {
|
||||
parts.push(ac.countryName);
|
||||
}
|
||||
if (ac.city !== undefined && ac.city.cityName !== undefined && ac.city.cityName !== '') {
|
||||
parts.push(ac.city.cityName);
|
||||
}
|
||||
if (ac.locality !== undefined && ac.locality !== '') {
|
||||
parts.push(ac.locality);
|
||||
}
|
||||
if (ac.streetNumber !== undefined) {
|
||||
const sn: site.StreetNumber = ac.streetNumber;
|
||||
if (sn.streetName !== undefined && sn.streetName !== '') {
|
||||
parts.push(sn.streetName);
|
||||
}
|
||||
if (sn.streetNumber !== undefined && sn.streetNumber !== '') {
|
||||
parts.push(sn.streetNumber);
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一错误处理:
|
||||
* - 1002600004 地图权限未开通 → 标记不可用,界面不再重试(避免每次打字都打一遍网络)
|
||||
* - 1002600003 鉴权失败(Profile 未含地图能力)
|
||||
* - 1002600002 连不上服务器
|
||||
*/
|
||||
private static handleError(op: string, err: Object): void {
|
||||
const e = err as BusinessError;
|
||||
const code: number = e.code !== undefined ? e.code : 0;
|
||||
PlaceSuggest.lastError = `${op}失败(code=${code}): ${e.message}`;
|
||||
if (code === 1002600004 || code === 1002600003) {
|
||||
PlaceSuggest.unavailable = true;
|
||||
LogUtil.write(`[地点] ${op}不可用(code=${code}):需在 AGC 打开「地图服务」并重新申请 Profile`);
|
||||
} else {
|
||||
LogUtil.write(`[地点] ${PlaceSuggest.lastError}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { AccountStore } from './AccountStore';
|
||||
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
export class ReminderService {
|
||||
@@ -26,6 +27,39 @@ export class ReminderService {
|
||||
private static calNameCache: Map<string, string> = new Map();
|
||||
private static calNameLoadedAt: number = 0;
|
||||
|
||||
/** 已镜像到系统日历的 calKey 集合缓存(避免重复排提醒),30 秒刷新一次 */
|
||||
private static mirroredCalKeys: Set<string> = new Set();
|
||||
private static mirroredLoadedAt: number = 0;
|
||||
|
||||
/**
|
||||
* ⭐⭐ 取"已镜像到系统日历"的日历本 calKey 集合(0.0.8 修复双通道重复提醒)。
|
||||
*
|
||||
* 镜像到系统日历后,**系统日历自己会提醒**(条目的 reminderTime);
|
||||
* 若 App 再为同一个本排代理提醒 → 同一条日程提醒两次。
|
||||
* 因此这里返回"该由系统日历负责提醒"的本,App 侧**完全不再提醒**它们。
|
||||
*
|
||||
* ⚠️ 判据是"**镜像账户真实存在**"(`SystemCalendarMirror.mirroredCalKeys`),
|
||||
* 而不是"用户勾选了要镜像"—— 后者只是意图,本可能还没被真正写进系统日历;
|
||||
* 按意图跳过会导致"两边都不提醒"(漏提醒)。
|
||||
* ⚠️ 返回空集 = 不跳过任何本(保守:宁可多提醒,不可漏提醒)。
|
||||
*/
|
||||
private static async mirroredKeys(context: common.Context): Promise<Set<string>> {
|
||||
if (Date.now() - ReminderService.mirroredLoadedAt < 30000
|
||||
&& ReminderService.mirroredCalKeys.size > 0) {
|
||||
return ReminderService.mirroredCalKeys;
|
||||
}
|
||||
const out: Set<string> = await SystemCalendarMirror.mirroredCalKeys(context);
|
||||
ReminderService.mirroredCalKeys = out;
|
||||
ReminderService.mirroredLoadedAt = Date.now();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 镜像开关变化后调用,让缓存立即失效(如用户关闭镜像 → 立刻恢复 App 提醒) */
|
||||
static invalidateMirrorCache(): void {
|
||||
ReminderService.mirroredLoadedAt = 0;
|
||||
ReminderService.mirroredCalKeys = new Set();
|
||||
}
|
||||
|
||||
/** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */
|
||||
private static async calName(context: common.Context, calKey: string): Promise<string> {
|
||||
if (Date.now() - ReminderService.calNameLoadedAt > 60000
|
||||
@@ -127,14 +161,27 @@ export class ReminderService {
|
||||
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
|
||||
}
|
||||
const now: number = Date.now();
|
||||
const rows: LocalEvent[] =
|
||||
// ⭐ #38 日程 + 待办一起排提醒。两类分别查(过滤条件不同:
|
||||
// 待办要求 completed=0,日程无此概念),合并后统一处理。
|
||||
const eventRows: LocalEvent[] =
|
||||
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);
|
||||
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
|
||||
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
|
||||
if (mirrored.size > 0) {
|
||||
LogUtil.write(`提醒跳过 ${mirrored.size} 个已镜像到系统日历的日历本(交由系统日历提醒)`);
|
||||
}
|
||||
for (const e of rows) {
|
||||
if (muted.includes(e.calKey)) {
|
||||
continue;
|
||||
}
|
||||
if (mirrored.has(e.calKey)) {
|
||||
continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复
|
||||
}
|
||||
if (published >= ReminderService.MAX_REMINDERS) {
|
||||
break;
|
||||
}
|
||||
@@ -151,6 +198,8 @@ export class ReminderService {
|
||||
}
|
||||
const d = new Date(remindAt);
|
||||
const calName: string = await ReminderService.calName(context, e.calKey);
|
||||
// #38 文案按类型区分:待办是"到期",日程是"开始"
|
||||
const isTodo: boolean = e.kind === 'todo';
|
||||
const request: reminderAgentManager.ReminderRequestCalendar = {
|
||||
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
|
||||
dateTime: {
|
||||
@@ -161,10 +210,14 @@ export class ReminderService {
|
||||
minute: d.getMinutes(),
|
||||
second: 0
|
||||
},
|
||||
title: '日程提醒',
|
||||
content: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
|
||||
title: isTodo ? '待办提醒' : '日程提醒',
|
||||
content: isTodo
|
||||
? `「${calName}」中的待办「${e.title}」将在 ${rem} 分钟后到期`
|
||||
: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
|
||||
// ⚠️ 通知 id 必须唯一:日程与待办的 id 来自同一张表(自增主键),
|
||||
// 天然不冲突,所以这里不用额外加偏移,否则会与 rem 的乘加混淆。
|
||||
notificationId: (e.id * 10 + rem) % 100000,
|
||||
expiredContent: '该日程已开始',
|
||||
expiredContent: isTodo ? '该待办已到期' : '该日程已开始',
|
||||
ringDuration: 5,
|
||||
snoozeTimes: 0,
|
||||
timeInterval: 0
|
||||
@@ -211,11 +264,16 @@ export class ReminderService {
|
||||
const rows: LocalEvent[] =
|
||||
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
||||
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
||||
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次")
|
||||
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
|
||||
let fired: number = 0;
|
||||
for (const e of rows) {
|
||||
if (muted.includes(e.calKey)) {
|
||||
continue;
|
||||
}
|
||||
if (mirrored.has(e.calKey)) {
|
||||
continue; // ⭐ 已镜像 → 交由系统日历提醒
|
||||
}
|
||||
const rems: number[] = e.reminders.length > 0 ? e.reminders
|
||||
: (e.reminder > 0 ? [e.reminder] : []);
|
||||
for (const rem of rems) {
|
||||
|
||||
@@ -2,12 +2,29 @@
|
||||
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount } from './AccountStore';
|
||||
import { DavAccount, AccountStore } from './AccountStore';
|
||||
import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
|
||||
import { IcsUtil } from './IcsUtil';
|
||||
import { DavClient, DavColorEntry, RemoteItem } from './DavClient';
|
||||
import { DavClient, DavColorEntry, DavHttpError, RemoteItem } from './DavClient';
|
||||
import { LogUtil } from './LogUtil';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
|
||||
/**
|
||||
* ⭐ "已有同步任务正在进行"专用错误类型。
|
||||
*
|
||||
* 背景(用户反馈):后台同步进行中时,用户再点「手动同步」或在账号管理点「保存并同步」,
|
||||
* 会并发触发第二轮同步;互斥锁把它拦下来,但那时抛的是普通 Error → 界面统一显示
|
||||
* "同步失败:该账号正在同步中,请稍后再试"。用户看到"失败"会误以为出了问题。
|
||||
*
|
||||
* 正确做法:并发被拦下时**不是失败**,而应提示"有同步任务正在进行中,请耐心等待"。
|
||||
* 因此用这个独立类型与真正的同步故障区分开,界面据此改提示语(而非报错)。
|
||||
*/
|
||||
export class SyncBusyError extends Error {
|
||||
constructor() {
|
||||
super('SYNC_BUSY');
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncEngine {
|
||||
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
|
||||
@@ -15,6 +32,27 @@ export class SyncEngine {
|
||||
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
|
||||
private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000;
|
||||
|
||||
/** 某账号当前是否正在同步(供界面判断,不触发任何操作) */
|
||||
static isSyncing(accId: string): boolean {
|
||||
const heldSince: number | undefined = SyncEngine.activeSyncs.get(accId);
|
||||
if (heldSince === undefined) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - heldSince < SyncEngine.LOCK_STALE_MS;
|
||||
}
|
||||
|
||||
/** 当前是否有任意账号正在同步 */
|
||||
static isAnySyncing(): boolean {
|
||||
const now: number = Date.now();
|
||||
for (const id of SyncEngine.activeSyncs.keys()) {
|
||||
const since: number | undefined = SyncEngine.activeSyncs.get(id);
|
||||
if (since !== undefined && now - since < SyncEngine.LOCK_STALE_MS) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈
|
||||
*/
|
||||
@@ -37,7 +75,8 @@ export class SyncEngine {
|
||||
const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id);
|
||||
if (heldSince !== undefined) {
|
||||
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
|
||||
throw new Error('该账号正在同步中,请稍后再试');
|
||||
// ⭐ 抛专用类型:界面据此提示"同步进行中,请耐心等待"而非"失败"
|
||||
throw new SyncBusyError();
|
||||
}
|
||||
LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`);
|
||||
}
|
||||
@@ -46,6 +85,18 @@ export class SyncEngine {
|
||||
// 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位
|
||||
LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
|
||||
try {
|
||||
// ⭐ 库结构升级(recurrence_id 列):老库里覆盖实例的该列全是 0,而被丢弃的
|
||||
// RECURRENCE-ID 无法从本地任何字段反推 → 必须靠一次全量重拉(GET 每个 .ics)回填。
|
||||
// 不回填的后果:把某次发生改期的覆盖实例无法排除主事件在原时间的那次发生,
|
||||
// App 与系统日历里同一日程会显示两条(幽灵日程)。
|
||||
// ⚠️ 判据必须是**数据库里的真实状态**:内存标志会被卡片进程/进程重启吃掉
|
||||
// (2026-09-19 实测两次同步都没触发)。回填完成后计数自然归 0,不会反复重拉。
|
||||
await EventDb.getDb(context);
|
||||
const ridMissing: number = await EventDb.countOverridesMissingRecurrenceId(context);
|
||||
if (ridMissing > 0) {
|
||||
LogUtil.write(`recurrence_id 待回填 ${ridMissing} 条覆盖实例 → 重置"一次性全量重拉"`);
|
||||
await AppSettings.resetFullRefetch(context);
|
||||
}
|
||||
// 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟
|
||||
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
|
||||
const timeoutMs: number = fullRefetch ? 600000 : 300000;
|
||||
@@ -58,6 +109,10 @@ export class SyncEngine {
|
||||
LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
|
||||
return r;
|
||||
} catch (err) {
|
||||
// ⭐ "正在同步中"原样抛出(界面据此提示"请耐心等待",不当成失败)
|
||||
if (err instanceof SyncBusyError) {
|
||||
throw err;
|
||||
}
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
|
||||
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
|
||||
@@ -67,7 +122,23 @@ export class SyncEngine {
|
||||
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
|
||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||
// 0) 刷新服务器端日历本颜色(每次同步都校正)
|
||||
await SyncEngine.refreshCalendarColors(acc, auth);
|
||||
// ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
|
||||
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
|
||||
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
|
||||
if (colorChanged) {
|
||||
try {
|
||||
const all: DavAccount[] = await AccountStore.loadAll(context);
|
||||
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
|
||||
if (idx >= 0) {
|
||||
all[idx] = acc;
|
||||
await AccountStore.saveAll(context, all);
|
||||
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
|
||||
}
|
||||
}
|
||||
// 1) 推送该账号日历本下的本地修改
|
||||
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
||||
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
|
||||
@@ -79,23 +150,78 @@ export class SyncEngine {
|
||||
LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS');
|
||||
}
|
||||
let changed: number = 0;
|
||||
// 服务器上已不存在的日历本(REPORT 404):先跳过继续同步其它日历本,
|
||||
// 循环结束后统一从账号移除(见 removeDeadBooks)。
|
||||
// 2026-09-29 实测:服务器上删掉一个日历本后,本地仍勾选它 → 每次同步 404 →
|
||||
// 旧版直接抛错让整个账号同步失败,且后台同步每轮都失败(一天半没同步成功过)。
|
||||
const deadIdxs: number[] = [];
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const href: string = acc.calendarHrefs[i];
|
||||
const calKey: string = `${acc.id}_${i}`;
|
||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
|
||||
const t1: number = Date.now();
|
||||
const items: RemoteItem[] = await DavClient.reportEtags(href, auth);
|
||||
// ⭐ 纯待办本(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[] = [];
|
||||
try {
|
||||
items = await DavClient.reportEtags(href, auth);
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError && (err as DavHttpError).status === 404) {
|
||||
LogUtil.write(`日历本[${i}]「${calName}」在服务器上已不存在(404),本轮跳过,收尾时自动从账号移除`);
|
||||
deadIdxs.push(i);
|
||||
continue;
|
||||
}
|
||||
throw err as Error;
|
||||
}
|
||||
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
|
||||
// ⭐ SYNC_TODOS 同步该本里的待办(VTODO)。
|
||||
// 原来整个同步流程只处理 VEVENT,`DavClient.reportTodos` 写了却没人调用,
|
||||
// 导致待办数据从来不会从服务器拉下来 ——
|
||||
// 用户取消勾选本(触发 pruneStaleHrefs 清行)后再勾回来,待办就永久消失了。
|
||||
// 放在这里(VEVENT 处理之前)单独走一遍,不影响原有日程逻辑。
|
||||
try {
|
||||
const todoItems: RemoteItem[] = await DavClient.reportTodos(href, auth);
|
||||
if (todoItems.length > 0) {
|
||||
const todoRemote: RemoteEvent[] = [];
|
||||
for (const ti of todoItems) {
|
||||
try {
|
||||
const raw: string = await DavClient.getRawStrict(ti.href, auth);
|
||||
const parsed: RemoteEvent[] = IcsUtil.parseTodos(raw);
|
||||
for (const pr of parsed) {
|
||||
pr.etag = ti.etag;
|
||||
todoRemote.push(pr);
|
||||
}
|
||||
} catch (e) {
|
||||
LogUtil.write(`待办 GET 失败(跳过) ${ti.href}`);
|
||||
}
|
||||
}
|
||||
if (todoRemote.length > 0) {
|
||||
await EventDb.applyRemote(context, calKey, href, todoRemote, true);
|
||||
LogUtil.write(`日历本[${i}]「${calName}」待办同步:${todoRemote.length} 条`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 待办同步失败不能影响日程同步(很多服务器对 VTODO 查询返回 403/不支持)
|
||||
LogUtil.write(`日历本[${i}]「${calName}」待办同步失败(忽略):${(e as BusinessError).message ?? ''}`);
|
||||
}
|
||||
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
||||
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||
const byUid: Map<string, LocalEvent[]> = new Map();
|
||||
// ⭐ 再按 **etag** 分组,给下面"要不要 GET 这个资源"做兜底(见 §复用判定 注释)
|
||||
const byEtag: Map<string, LocalEvent[]> = new Map();
|
||||
for (const row of existingRows) {
|
||||
const arr: LocalEvent[] | undefined = byUid.get(row.uid);
|
||||
if (arr === undefined) {
|
||||
byUid.set(row.uid, [row]);
|
||||
} else {
|
||||
arr.push(row);
|
||||
SyncEngine.groupPush(byUid, row.uid, row);
|
||||
if (row.etag !== '') {
|
||||
SyncEngine.groupPush(byEtag, row.etag, row);
|
||||
}
|
||||
}
|
||||
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
||||
@@ -104,19 +230,39 @@ export class SyncEngine {
|
||||
let fetched: number = 0;
|
||||
let parseFail: number = 0;
|
||||
const needFetch: RemoteItem[] = [];
|
||||
let hitUid: number = 0; // 按"资源名 = uid"命中
|
||||
let hitEtag: number = 0; // 按 etag 内容哈希兜底命中
|
||||
for (const it of items) {
|
||||
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
||||
const group: LocalEvent[] | undefined = byUid.get(resUid);
|
||||
let group: LocalEvent[] | undefined = byUid.get(resUid);
|
||||
if (group !== undefined) {
|
||||
hitUid++;
|
||||
} else if (it.etag !== '') {
|
||||
// ⭐ 复用判定(2026-10-15 修复):**服务器不保证"资源文件名 = ICS 里的 UID"**。
|
||||
// 家书 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/', '_', $uid) 生成文件名:
|
||||
// UID `movie-188@familybook` 的资源叫 `movie-188_familybook.ics`。
|
||||
// 旧版只按资源名去 `byUid` 查 → 永远查不到 → 该资源**每轮都被判成"变了"**,
|
||||
// 于是每次都全量 GET(实测 2184 个资源 → 单次同步 200~300 秒:卡片后台同步
|
||||
// 那 9 秒窗口连一个日历本都跑不完)。
|
||||
// etag 是**内容哈希**(服务端由正文 md5 得出):相等即内容一致,
|
||||
// 与"资源叫什么名字"无关 —— 用它兜底既安全又与服务器实现解耦。
|
||||
group = byEtag.get(it.etag);
|
||||
if (group !== undefined) {
|
||||
hitEtag++;
|
||||
}
|
||||
}
|
||||
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
|
||||
&& group[0].etag === it.etag) {
|
||||
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
||||
const rp: string = it.href.substring(it.href.lastIndexOf('/') + 1);
|
||||
for (const row of group) {
|
||||
remote.push(SyncEngine.rowToRemote(row, it.etag));
|
||||
remote.push(SyncEngine.rowToRemote(row, it.etag, rp));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
needFetch.push(it);
|
||||
}
|
||||
LogUtil.write(`日历本[${i}]「${calName}」复用判定:uid 命中 ${hitUid}、etag 兜底命中 ${hitEtag}、需补拉 ${needFetch.length}(共 ${items.length})`);
|
||||
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
|
||||
const batchSize: number = 5;
|
||||
for (let b: number = 0; b < needFetch.length; b += batchSize) {
|
||||
@@ -129,6 +275,12 @@ export class SyncEngine {
|
||||
return [];
|
||||
}
|
||||
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 out: RemoteEvent[] = [];
|
||||
for (const r of parsed) {
|
||||
@@ -138,6 +290,7 @@ export class SyncEngine {
|
||||
if (r.uid === '') {
|
||||
r.uid = resUid;
|
||||
}
|
||||
r.remotePath = fileName;
|
||||
r.etag = it.etag;
|
||||
out.push(r);
|
||||
}
|
||||
@@ -164,6 +317,8 @@ export class SyncEngine {
|
||||
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
|
||||
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
|
||||
changed += remote.length;
|
||||
} // end else(非纯待办本才跑 event 通道)
|
||||
|
||||
// 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步)
|
||||
try {
|
||||
const todoItems = await DavClient.reportTodos(href, auth);
|
||||
@@ -185,14 +340,102 @@ export class SyncEngine {
|
||||
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`);
|
||||
}
|
||||
}
|
||||
// 收尾:把本轮发现的"服务器上已删除"的日历本从账号移除并清理本地数据。
|
||||
// 放在循环后(而非循环中)是因为移除会让后续序号前移,循环中途改数组会打乱 calKey。
|
||||
if (deadIdxs.length > 0) {
|
||||
await SyncEngine.removeDeadBooks(context, acc, deadIdxs);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从账号移除「服务器上已不存在(404)」的日历本:收窄配置、迁移序号、清理本地数据并持久化。
|
||||
* 只要有至少一个日历本保留就自动移除;若全部失效则不动配置(避免把账号清空),
|
||||
* 抛错交给上层提示用户去服务器/编辑账号处理。
|
||||
*/
|
||||
private static async removeDeadBooks(context: common.Context, acc: DavAccount,
|
||||
deadIdxs: number[]): Promise<void> {
|
||||
if (deadIdxs.length >= acc.calendarHrefs.length) {
|
||||
throw new Error('该账号下所有日历本在服务器上均已不存在(404),请检查服务器或重新编辑账号');
|
||||
}
|
||||
const deadSet: Set<number> = new Set<number>();
|
||||
for (const d of deadIdxs) {
|
||||
deadSet.add(d);
|
||||
}
|
||||
const removedHrefs: string[] = [];
|
||||
const newHrefs: string[] = [];
|
||||
const newNames: string[] = [];
|
||||
const newColors: string[] = [];
|
||||
const newWritable: string[] = [];
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
if (deadSet.has(i)) {
|
||||
removedHrefs.push(acc.calendarHrefs[i]);
|
||||
continue;
|
||||
}
|
||||
newHrefs.push(acc.calendarHrefs[i]);
|
||||
newNames.push(i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`);
|
||||
newColors.push(i < acc.calendarColors.length ? acc.calendarColors[i] : '');
|
||||
newWritable.push(i < acc.calendarWritable.length ? acc.calendarWritable[i] : '1');
|
||||
}
|
||||
LogUtil.write(`自动移除 ${removedHrefs.length} 个在服务器上不存在的日历本,保留 ${newHrefs.length} 个`);
|
||||
// 1) 先按 href 删掉被移除日历本的本地行——必须在序号迁移之前,
|
||||
// 否则序号收窄后这些行会混进接替该序号的其它日历本(pruneByHrefs 注释详述)
|
||||
await EventDb.pruneByHrefs(context, acc.id, removedHrefs);
|
||||
// 2) 序号迁移:保留的日历本按原顺序收紧(旧序号 → 新序号)
|
||||
const oldHrefs: string[] = acc.calendarHrefs.slice();
|
||||
const pairs: Array<[string, string]> = [];
|
||||
let ni: number = 0;
|
||||
for (let oi = 0; oi < oldHrefs.length; oi++) {
|
||||
if (deadSet.has(oi)) {
|
||||
continue;
|
||||
}
|
||||
if (ni !== oi) {
|
||||
pairs.push([`${acc.id}_${oi}`, `${acc.id}_${ni}`]);
|
||||
}
|
||||
ni++;
|
||||
}
|
||||
if (pairs.length > 0) {
|
||||
const moved: number = await EventDb.remapCalKeys(context, pairs);
|
||||
LogUtil.write(`移除失效日历本后迁移日程 ${moved} 行(${pairs.length} 个日历本序号前移)`);
|
||||
}
|
||||
// 3) 收窄账号配置并持久化
|
||||
acc.calendarHrefs = newHrefs;
|
||||
acc.calendarNames = newNames;
|
||||
acc.calendarColors = newColors;
|
||||
acc.calendarWritable = newWritable;
|
||||
const all: DavAccount[] = await AccountStore.loadAll(context);
|
||||
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
|
||||
if (idx >= 0) {
|
||||
all[idx] = acc;
|
||||
await AccountStore.saveAll(context, all);
|
||||
}
|
||||
// 4) 兜底:该账号下 calKey 不在有效列表的行全部清掉
|
||||
const validKeys: string[] = [];
|
||||
for (let i = 0; i < newHrefs.length; i++) {
|
||||
validKeys.push(`${acc.id}_${i}`);
|
||||
}
|
||||
await EventDb.pruneAccountEvents(context, acc.id, validKeys);
|
||||
// 5) 若被移除的日历本正是「系统日历 → CalDAV」的备份目标,当场判失效并关闭备份
|
||||
try {
|
||||
const backupCheck = await SystemCalendarMirror.checkBackupTarget(context);
|
||||
if (backupCheck.closed) {
|
||||
LogUtil.write('备份目标日历本已在服务器上删除,备份功能已自动关闭');
|
||||
}
|
||||
} catch (err) {
|
||||
// 检查失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
|
||||
* 失败静默(颜色不影响数据正确性)
|
||||
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
|
||||
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
|
||||
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
|
||||
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
|
||||
*/
|
||||
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> {
|
||||
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
|
||||
let changed: boolean = false;
|
||||
let entries: DavColorEntry[] = [];
|
||||
try {
|
||||
entries = await DavClient.propfindColors(acc.serverUrl, auth);
|
||||
@@ -210,9 +453,11 @@ export class SyncEngine {
|
||||
if (found !== undefined && found.color !== '') {
|
||||
while (acc.calendarColors.length <= i) {
|
||||
acc.calendarColors.push('');
|
||||
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
|
||||
}
|
||||
if (acc.calendarColors[i] !== found.color) {
|
||||
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
|
||||
changed = true;
|
||||
}
|
||||
acc.calendarColors[i] = found.color;
|
||||
} else if (found === undefined) {
|
||||
@@ -224,16 +469,32 @@ export class SyncEngine {
|
||||
acc.calendarWritable.push('1');
|
||||
}
|
||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||
let writable: string;
|
||||
if (found !== undefined && found.privilegeKnown) {
|
||||
// 服务器声明了权限:直接采用
|
||||
acc.calendarWritable[i] = found.writable ? '1' : '0';
|
||||
writable = found.writable ? '1' : '0';
|
||||
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
|
||||
} else {
|
||||
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
|
||||
const w: boolean = await DavClient.probeWritable(target, auth);
|
||||
acc.calendarWritable[i] = w ? '1' : '0';
|
||||
writable = w ? '1' : '0';
|
||||
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
|
||||
}
|
||||
if (acc.calendarWritable[i] !== writable) {
|
||||
changed = true;
|
||||
}
|
||||
acc.calendarWritable[i] = writable;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
|
||||
private static groupPush(m: Map<string, LocalEvent[]>, key: string, row: LocalEvent): void {
|
||||
const arr: LocalEvent[] | undefined = m.get(key);
|
||||
if (arr === undefined) {
|
||||
m.set(key, [row]);
|
||||
} else {
|
||||
arr.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,8 +509,7 @@ export class SyncEngine {
|
||||
}
|
||||
|
||||
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
|
||||
private static resUidFromHref(href: string): string {
|
||||
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||
private static resUidFromHref(href: string): string { const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||
if (segs.length === 0) {
|
||||
return String(Date.now());
|
||||
}
|
||||
@@ -266,10 +526,18 @@ export class SyncEngine {
|
||||
}
|
||||
|
||||
/** 本地行 → 远端事件(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();
|
||||
r.uid = e.uid;
|
||||
r.etag = etag;
|
||||
r.remotePath = remotePath !== '' ? remotePath : e.remotePath;
|
||||
r.title = e.title;
|
||||
r.description = e.description;
|
||||
r.location = e.location;
|
||||
@@ -280,6 +548,7 @@ export class SyncEngine {
|
||||
r.completed = e.completed;
|
||||
r.rrule = e.rrule;
|
||||
r.exdate = e.exdate;
|
||||
r.recurrenceId = e.recurrenceId;
|
||||
r.reminder = e.reminder;
|
||||
r.reminders = e.reminders.slice();
|
||||
return r;
|
||||
@@ -292,6 +561,9 @@ export class SyncEngine {
|
||||
const dirty: LocalEvent[] = await EventDb.getDirty(context);
|
||||
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
|
||||
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`);
|
||||
// ⭐ 同一个重复系列(uid + 日历本)在一次推送里只处理一次:整份 .ics(主事件 + 各覆盖实例)
|
||||
// 一起重建后推送,避免"先 PUT 覆盖实例、后 PUT 主事件"互相覆盖。
|
||||
const pushedSeries: Set<string> = new Set<string>();
|
||||
for (const e of mine) {
|
||||
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
|
||||
const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
|
||||
@@ -305,30 +577,146 @@ export class SyncEngine {
|
||||
await EventDb.clearDirty(context, e.id, e.etag);
|
||||
continue;
|
||||
}
|
||||
if (e.recurring && e.rrule === '') {
|
||||
// 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持
|
||||
await EventDb.clearDirty(context, e.id, e.etag);
|
||||
LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`);
|
||||
// ⭐ 重复系列(含覆盖实例):按 uid 取全系列行,重建整份 .ics 后一次性 PUT。
|
||||
// 旧版对"覆盖实例"直接跳过(不推送),导致 App 里的单次改期/删除无法上传到服务器;
|
||||
// 现在支持标准写法(同资源内多 VEVENT + RECURRENCE-ID)。
|
||||
const seriesKey: string = `${e.calKey}|${e.uid}`;
|
||||
if ((e.recurring || e.rrule !== '') && !pushedSeries.has(seriesKey)) {
|
||||
pushedSeries.add(seriesKey);
|
||||
await SyncEngine.pushSeries(context, acc, auth, e);
|
||||
continue;
|
||||
}
|
||||
if (e.recurring) {
|
||||
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送
|
||||
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
|
||||
if ((e.recurring || e.rrule !== '') && pushedSeries.has(seriesKey)) {
|
||||
continue; // 该系列已在上面整体推送过
|
||||
}
|
||||
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
||||
if (e.deleted) {
|
||||
await DavClient.deleteRemote(url, auth);
|
||||
await EventDb.purge(context, e.id);
|
||||
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
|
||||
} else {
|
||||
const ics: string = IcsUtil.build(e);
|
||||
const etag: string = await DavClient.putEvent(url, auth, ics);
|
||||
await EventDb.clearDirty(context, e.id, etag);
|
||||
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`);
|
||||
try {
|
||||
if (e.deleted) {
|
||||
// ⭐ DELETE_404_PURGE 单条路径:deleteRemote 内部已把 404 视作成功,
|
||||
// 这里再显式兜一层 410(Gone),并保证无论哪种"远端已无"都清本地。
|
||||
let gone: boolean = false;
|
||||
try {
|
||||
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);
|
||||
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
|
||||
} else {
|
||||
const ics: string = IcsUtil.build(e);
|
||||
const etag: string = await DavClient.putEvent(url, auth, ics);
|
||||
await EventDb.clearDirty(context, e.id, etag);
|
||||
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 单条推送失败不再中断整个账号(旧行为:一条 404 就让整个账号同步失败)。
|
||||
// 401(凭据失效)例外:后续每条也必然失败,直接抛出让上层提示。
|
||||
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
|
||||
throw err as Error;
|
||||
}
|
||||
const pe = err as Error;
|
||||
LogUtil.write(`推送单条失败(跳过,保留待推送):uid=${e.uid} → ${pe.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 推送一个"重复系列":取该 uid 下的主事件与全部覆盖实例,合成整份 .ics 后 PUT。
|
||||
*
|
||||
* - 主事件行若被标记删除(deleted)→ 视为"整个系列删除",DELETE 整个资源并清掉本地该 uid 全部行。
|
||||
* - 否则重建(主事件可能带上了 EXDATE / 被截断的 UNTIL,覆盖实例带 RECURRENCE-ID),
|
||||
* 一次性 PUT 覆盖服务器上的同一资源;成功后清掉本系列全部行的 dirty。
|
||||
*/
|
||||
private static async pushSeries(context: common.Context, acc: DavAccount, auth: string,
|
||||
trigger: LocalEvent): Promise<void> {
|
||||
const calKey: string = trigger.calKey;
|
||||
const uid: string = trigger.uid;
|
||||
const rows: LocalEvent[] = await EventDb.queryByUid(context, calKey, uid);
|
||||
// 主事件:优先取带 RRULE 的行;退而取最早一行
|
||||
let master: LocalEvent | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.rrule !== '') {
|
||||
master = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (master === null && rows.length > 0) {
|
||||
master = rows[0];
|
||||
}
|
||||
if (master === null) {
|
||||
return;
|
||||
}
|
||||
const href: string = master.href;
|
||||
const path: string = master.remotePath !== '' ? master.remotePath : encodeURIComponent(uid) + '.ics';
|
||||
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
|
||||
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
|
||||
if (master.deleted) {
|
||||
// ⭐ DELETE_404_PURGE 404/410 = 服务器上本就没有这个资源。
|
||||
// 必须当成"删除成功"继续往下清本地,不能 return ——
|
||||
// 否则本地行永远留着,用户看到"删不掉的日程",且每次同步都重复失败。
|
||||
// 典型诱因:remotePath 用 uid 猜错(服务器真实文件名不同)⇒ DELETE 打到不存在的路径。
|
||||
let remoteGone: boolean = false;
|
||||
try {
|
||||
await DavClient.deleteRemote(url, auth);
|
||||
remoteGone = true;
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError) {
|
||||
const st: number = (err as DavHttpError).status;
|
||||
if (st === 401) {
|
||||
throw err as Error;
|
||||
}
|
||||
if (st === 404 || st === 410) {
|
||||
remoteGone = true;
|
||||
LogUtil.write(`重复系列资源在服务器上已不存在(${st}),直接清理本地:uid=${uid}`);
|
||||
}
|
||||
}
|
||||
if (!remoteGone) {
|
||||
LogUtil.write(`推送删除重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await EventDb.purgeByUid(context, calKey, uid);
|
||||
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);
|
||||
return;
|
||||
}
|
||||
const overrides: LocalEvent[] = [];
|
||||
for (const r of rows) {
|
||||
if (r.id !== master.id && r.rrule === '' && r.recurrenceId > 0) {
|
||||
overrides.push(r);
|
||||
}
|
||||
}
|
||||
overrides.sort((a: LocalEvent, b: LocalEvent): number => a.recurrenceId - b.recurrenceId);
|
||||
const ics: string = IcsUtil.buildSeries(master, overrides);
|
||||
try {
|
||||
const etag: string = await DavClient.putEvent(url, auth, ics);
|
||||
// 清掉本系列全部行的 dirty(第一条写回 etag)
|
||||
let first: boolean = true;
|
||||
for (const r of rows) {
|
||||
await EventDb.clearDirty(context, r.id, first ? etag : '');
|
||||
first = false;
|
||||
}
|
||||
LogUtil.write(`推送重复系列(标题已脱敏)uid=${uid} → ${url}(${ics.length} 字节,覆盖实例=${overrides.length})`);
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
|
||||
throw err as Error;
|
||||
}
|
||||
LogUtil.write(`推送重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理本机虚拟日历(calKey=local)的待推送状态:
|
||||
* 本机事件不参与 DAV 同步,直接落地
|
||||
@@ -345,6 +733,16 @@ export class SyncEngine {
|
||||
await EventDb.clearDirty(context, e.id, '');
|
||||
}
|
||||
}
|
||||
// ⭐ 顺带清理"同一资源被重复插入"的冗余行(旧版 applyRemote 判重缺陷遗留的脏数据)。
|
||||
// 纯本地去重、不推服务器;放在这里是因为它是每次同步都会经过的收尾步骤。
|
||||
try {
|
||||
const dup: number = await EventDb.dedupeDuplicateRows(context);
|
||||
if (dup > 0) {
|
||||
LogUtil.write(`清理本地重复行:${dup} 条(同一资源被插了多次)`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略:去重失败不影响同步
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,6 +763,19 @@ export class SyncEngine {
|
||||
if (removed > 0) {
|
||||
LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`);
|
||||
}
|
||||
// 兜底清扫:行上 href 已不属于任何已选日历本的残留行(calKey 可能因历史迁移
|
||||
// 缺陷混在有效日历本名下,calKey 维度抓不到,href 才暴露真实归属)。
|
||||
// 典型如服务器删除日历本后残留的"幽灵"数据——不清掉的话镜像会一直把它写进系统日历。
|
||||
const validHrefs: string[] = [];
|
||||
for (const a of accounts) {
|
||||
for (const h of a.calendarHrefs) {
|
||||
validHrefs.push(h);
|
||||
}
|
||||
}
|
||||
const stale: number = await EventDb.pruneStaleHrefs(context, validHrefs);
|
||||
if (stale > 0) {
|
||||
LogUtil.write(`清理失效日历本残留行:${stale} 条(href 已不属于任何已选日历本)`);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`);
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { AccountStore, DavAccount } from './AccountStore';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { LogUtil } from './LogUtil';
|
||||
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
|
||||
export class SystemCalendarImport {
|
||||
/**
|
||||
@@ -16,6 +16,15 @@ export class SystemCalendarImport {
|
||||
* 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。
|
||||
* 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程),
|
||||
* 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。
|
||||
*
|
||||
* ⚠️ 另有两层防重复(09-18 补):
|
||||
* 1. **跳过本应用镜像出去的账户**(`synccalendar.mirror.*`)——它们的 type 也是 LOCAL,
|
||||
* 但内容是我们从 CalDAV 写进去的,再导进备份本会让同一条日程在服务器出现两份。
|
||||
* 2. 导进备份本的 uid 统一带 `syscal-` 前缀,出站镜像会跳过该前缀 → 备份本不会被镜像回系统日历。
|
||||
*
|
||||
* ⚠️ 目标本用**稳定标识 bookId** 解析(`SystemCalendarMirror.backupTargetRef`):
|
||||
* 用户「取消勾选」目标本后序号会整体前移,老版本按 `accId_序号` 定位会**导进另一个本**。
|
||||
* 现在解析不到即视为失效,并把「备份到 CalDAV」模式**自动关回仅显示**。
|
||||
*/
|
||||
static async importIfNeeded(context: common.Context): Promise<number> {
|
||||
try {
|
||||
@@ -23,30 +32,42 @@ export class SystemCalendarImport {
|
||||
if (mode !== 'backup') {
|
||||
return 0;
|
||||
}
|
||||
const calKey: string = await AppSettings.getBackupCalKey(context);
|
||||
if (calKey === '') {
|
||||
LogUtil.write('系统日历备份:未选择目标日历本,跳过导入');
|
||||
// ⭐ 目标本一律走 `backupTargetRef()`:内部用**稳定标识 bookId** 解析,
|
||||
// 并兼做「老 calKey 值 → bookId」的一次性迁移。
|
||||
// 解析不到时它会**自动把 sys_cal_mode 关回 display**(用户明确要求的行为):
|
||||
// 目标本被取消同步/账号被删后,绝不能"随便挑一个本"把系统日历导进去。
|
||||
const ref: BookRef | undefined = await SystemCalendarMirror.backupTargetRef(context);
|
||||
if (ref === undefined) {
|
||||
// 未选择目标本(或刚被判失效并关闭)→ 跳过。原因已由 backupTargetRef 写进日志。
|
||||
LogUtil.write('系统日历备份:当前无有效目标日历本,跳过导入');
|
||||
return 0;
|
||||
}
|
||||
// 定位目标 CalDAV 日历本
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
let targetHref: string = '';
|
||||
for (const acc of accounts) {
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
if (`${acc.id}_${i}` === calKey) {
|
||||
targetHref = acc.calendarHrefs[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
const calKey: string = ref.calKey; // 仅用于查本地库 / 落库
|
||||
const targetHref: string = ref.href;
|
||||
if (targetHref === '') {
|
||||
LogUtil.write(`系统日历备份:目标日历本 ${calKey} 不存在(账号可能已删除),跳过导入`);
|
||||
LogUtil.write(`系统日历备份:目标日历本 ${ref.bookId} 缺少集合 URL,跳过导入`);
|
||||
return 0;
|
||||
}
|
||||
const uiContext = context as common.UIAbilityContext;
|
||||
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
|
||||
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
|
||||
// 诊断:把 getAllCalendars() 实际能读到的账户全列出来。
|
||||
// 用于确认"是否只能读到本应用创建的 + 系统默认账户",还是能读到其他应用自建的日历账户。
|
||||
const brief: string[] = [];
|
||||
for (const cal of calendars) {
|
||||
let acc: calendarManager.CalendarAccount;
|
||||
try {
|
||||
acc = cal.getAccount();
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
const tag: string = SystemCalendarMirror.isMirrorAccountName(acc.name) ? '(我们的镜像)' : '';
|
||||
brief.push(`${acc.name}[type=${acc.type}]${tag}`);
|
||||
}
|
||||
LogUtil.write(`系统日历账户清单:共 ${calendars.length} 个 → ${brief.join(' | ')}`);
|
||||
let imported: number = 0;
|
||||
let skippedCal: number = 0;
|
||||
let skippedMirror: number = 0; // 本应用镜像出去的账户(不能导进备份本,否则重复)
|
||||
for (const cal of calendars) {
|
||||
try {
|
||||
// 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌
|
||||
@@ -55,6 +76,13 @@ export class SystemCalendarImport {
|
||||
skippedCal++;
|
||||
continue;
|
||||
}
|
||||
// ⚠️ 关键:跳过**本应用自己镜像出去的账户**(type 同样 LOCAL,但内容是我们从 CalDAV 写进去的)。
|
||||
// 若一并导入备份本,同一条日程会在服务器里出现两份(原始本一份、备份本一份)→ 数据非常乱。
|
||||
// 这些账户的改动由 MirrorInbound 负责回写到它**原本所属**的日历本,不走备份通道。
|
||||
if (SystemCalendarMirror.isMirrorAccountName(account.name)) {
|
||||
skippedMirror++;
|
||||
continue;
|
||||
}
|
||||
const events: calendarManager.Event[] = await cal.getEvents();
|
||||
for (const ev of events) {
|
||||
if (ev.id === undefined) {
|
||||
@@ -86,8 +114,9 @@ export class SystemCalendarImport {
|
||||
// 单个日历读取失败不影响其余
|
||||
}
|
||||
}
|
||||
if (imported > 0 || skippedCal > 0) {
|
||||
LogUtil.write(`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过账户日历 ${skippedCal} 个)`);
|
||||
if (imported > 0 || skippedCal > 0 || skippedMirror > 0) {
|
||||
LogUtil.write(
|
||||
`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过非本地账户 ${skippedCal} 个、镜像账户 ${skippedMirror} 个)`);
|
||||
}
|
||||
return imported;
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,8 @@ export class DayTimeline {
|
||||
allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前)
|
||||
blocks: TimelineBlock[] = []; // 时间轴色块
|
||||
hasTimed: boolean = false; // 当天是否有有时间日程
|
||||
/** 当天截止、尚未完成的待办(画在时间轴顶部、全天区之上,带勾选框) */
|
||||
todos: DisplayEvent[] = [];
|
||||
}
|
||||
|
||||
/** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */
|
||||
@@ -272,38 +274,43 @@ export class TimelineUtil {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 时间轴"基准可视区"的起止小时:8:00 ~ 18:00 **恒定显示**(与用户确认)。 */
|
||||
static readonly BASE_START_HOUR: number = 8;
|
||||
static readonly BASE_END_HOUR: number = 18;
|
||||
|
||||
/**
|
||||
* 计算"需要显示的小时区间"(视窗截断):
|
||||
* - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点)
|
||||
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见
|
||||
* 计算"需要显示的小时区间"(视窗截断)。规则(与用户确认):
|
||||
* - **8:00 ~ 18:00 恒定显示**(即使当天没有任何日程,也至少显示这一段,避免"只有一条日程
|
||||
* 就只显示那 1 小时"的奇怪观感);
|
||||
* - 若某天在 **8 点之前**有日程 → 起点向前扩到最早日程所在整点;否则 8 点之前的刻度不显示;
|
||||
* - 若某天在 **18 点之后**有日程 → 终点向后扩到最晚日程结束时刻向上取整;否则 18 点之后的刻度不显示。
|
||||
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来以保证红线可见 —— 但**仍遵守上面的
|
||||
* "无日程则不显示"约束**:只有当该侧本来就有日程(起点已 <8 / 终点已 >18)时才允许继续向外扩,
|
||||
* 否则不会因为"当前时刻"而把 8 点前 / 18 点后的空档重新显示出来。
|
||||
* 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。
|
||||
*/
|
||||
static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange {
|
||||
const rg = new ViewRange();
|
||||
let s: number = -1;
|
||||
let e: number = -1;
|
||||
// 基准:8~18 恒显示
|
||||
let s: number = TimelineUtil.BASE_START_HOUR;
|
||||
let e: number = TimelineUtil.BASE_END_HOUR;
|
||||
for (const b of blocks) {
|
||||
const bh: number = Math.floor(b.topRatio * 24);
|
||||
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
||||
if (s < 0 || bh < s) {
|
||||
if (bh < s) {
|
||||
s = bh;
|
||||
}
|
||||
if (e < 0 || eh > e) {
|
||||
if (eh > e) {
|
||||
e = eh;
|
||||
}
|
||||
}
|
||||
if (s < 0) {
|
||||
s = 8;
|
||||
}
|
||||
if (e < 0) {
|
||||
e = 20;
|
||||
}
|
||||
if (includeNowHour) {
|
||||
const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24);
|
||||
if (nh < s) {
|
||||
// 只有"该侧本来就有日程"时才允许把当前小时并进来(否则会违反"8 点前/18 点后无日程则不显示")
|
||||
if (nh < s && s < TimelineUtil.BASE_START_HOUR) {
|
||||
s = nh;
|
||||
}
|
||||
if (nh + 1 > e) {
|
||||
if (nh + 1 > e && e > TimelineUtil.BASE_END_HOUR) {
|
||||
e = nh + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration,
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
import { BackgroundSyncService } from '../common/BackgroundSyncService';
|
||||
import { NetworkSyncWatcher } from '../common/NetworkSyncWatcher';
|
||||
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
@@ -9,11 +11,14 @@ const DOMAIN = 0x0000;
|
||||
export default class EntryAbility extends UIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log
|
||||
// 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
|
||||
LogUtil.init(this.context);
|
||||
LogUtil.write('---- App 启动 ----');
|
||||
// 后台同步:注册延迟任务兜底 + 按设置恢复长时任务
|
||||
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
|
||||
// ⚠️ 不再申请"长时任务":dataTransfer 类型有 10 分钟无进度即被取消的一致性校验,
|
||||
// 日历同步不符合该语义、必被取消(详见 BackgroundSyncService 顶部注释)。
|
||||
BackgroundSyncService.initOnLaunch(this.context);
|
||||
NetworkSyncWatcher.start(this.context);
|
||||
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
|
||||
this.handleWidgetParam(want);
|
||||
// ⚠️ 这里**不再申请任何权限**(含通知权限)。
|
||||
@@ -37,10 +42,12 @@ export default class EntryAbility extends UIAbility {
|
||||
private handleWidgetParam(want: Want): void {
|
||||
const params = want.parameters;
|
||||
if (params !== undefined) {
|
||||
// ⭐ 白名单:日程卡片发 'add',待办卡片发 'addTodo'(新建待办)/ 'openTodo'(进待办页)。
|
||||
// 之前只认 'add',待办卡片点 + 毫无反应 —— 参数被静默丢弃。
|
||||
const raw = params['widgetAction'];
|
||||
if (typeof raw === 'string' && raw === 'add') {
|
||||
AppStorage.setOrCreate<string>('widgetAction', 'add');
|
||||
LogUtil.write('卡片添加入口:已设置 widgetAction=add');
|
||||
if (typeof raw === 'string' && (raw === 'add' || raw === 'addTodo' || raw === 'openTodo')) {
|
||||
AppStorage.setOrCreate<string>('widgetAction', raw);
|
||||
LogUtil.write(`卡片入口:已设置 widgetAction=${raw}`);
|
||||
}
|
||||
// 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期)
|
||||
const od = params['openDate'];
|
||||
@@ -189,9 +196,15 @@ export default class EntryAbility extends UIAbility {
|
||||
|
||||
onForeground(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||
// ⭐ 回到前台:① 归还短时任务配额 ② 立刻补同步一次(用户真正要看时数据必须最新)
|
||||
BackgroundSyncService.cancelTransientSync();
|
||||
BackgroundSyncRunner.runOnce(this.context, 'foreground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||
// ⭐ 退到后台:申请短时任务,兜住"退后台后那一次"同步能跑完
|
||||
// (必须在退后台 5 秒内申请;单次最长 3 分钟,单日配额 10 分钟)
|
||||
BackgroundSyncService.requestTransientSync('正在同步日历数据');
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,47 @@
|
||||
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
|
||||
import { Want } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import process from '@ohos.process';
|
||||
import { CardDataService, CardData } from '../common/CardDataService';
|
||||
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||
import { TodoService } from '../common/TodoService';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
export default class EntryFormAbility extends FormExtensionAbility {
|
||||
/** 组装更新数据并推送到指定卡片 */
|
||||
/** 当前进程 pid(取不到返回 -1):用于判断卡片回调是否与主 App 同进程 */
|
||||
private static pidOf(): number {
|
||||
try {
|
||||
return process.pid;
|
||||
} catch (err) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装更新数据并推送到指定卡片。
|
||||
*
|
||||
* ⭐ 一次同时构建「日程」与「待办」两份数据,合并进同一个 binding。
|
||||
* 日程卡片只读 eventsJson 等字段,待办卡片只读 todoListJson 等字段,
|
||||
* 彼此不会串味。这样无需维护 formId → 卡片类型的映射(那需要持久化,
|
||||
* 且系统重启后容易丢失),一套逻辑服务全部 8 张卡片。
|
||||
*/
|
||||
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 =
|
||||
formBindingData.createFormBindingData(data);
|
||||
formBindingData.createFormBindingData(ev);
|
||||
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
|
||||
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
|
||||
});
|
||||
LogUtil.write(`卡片 ${formId} 数据已推送`);
|
||||
LogUtil.write(`卡片 ${formId} 数据已推送(日程+待办)`);
|
||||
}).catch((err: BusinessError): void => {
|
||||
LogUtil.write(`卡片数据构建失败: ${err.message}`);
|
||||
});
|
||||
@@ -40,10 +68,30 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
||||
|
||||
onUpdateForm(formId: string): void {
|
||||
LogUtil.init(this.context);
|
||||
LogUtil.write(`系统触发卡片更新: formId=${formId}`);
|
||||
LogUtil.write(`系统触发卡片更新: formId=${formId}(进程 pid=${EntryFormAbility.pidOf()})`);
|
||||
CardDataService.registerForm(this.context, formId);
|
||||
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
|
||||
// ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
|
||||
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
|
||||
this.pushData(formId, CardDataService.getDayOffset(formId));
|
||||
// ⭐ ② 再借这次系统刷新顺带跑一次后台同步(见 runOnceThrottledByCard:带节流、永不抛错)
|
||||
// 同步成功后再推一次,让卡片显示刚同步回来的最新数据。
|
||||
//
|
||||
// ⚠️ 关键约束(依据官方 FormExtensionAbility 生命周期文档):
|
||||
// "FormExtensionAbility 进程不能常驻后台,生命周期回调中无法处理长时间的任务,
|
||||
// 生命周期调度完成后会继续存在 **10 秒**……可能需要 10 秒以上才能完成的业务逻辑,
|
||||
// 建议拉起主应用进行处理。"
|
||||
// ⇒ 本处同步**可能被进程回收中断**。这是**可接受**的,因为:
|
||||
// · 同步是幂等的(基于 etag 增量比对 + dirty 标记),中断不会损坏任何数据;
|
||||
// · 中断只是"这一次没同步成",下个 30 分钟卡片再刷新时会重试;
|
||||
// · 正常网络下 1~3 个账号的**增量**同步通常数秒内完成,落在 10 秒窗口内。
|
||||
// ⇒ 因此它是"**尽力而为地提高后台同步频率**",与 workScheduler 2 小时兜底、
|
||||
// 回前台/网络恢复补同步共同构成多层保障,**不承诺 30 分钟必然同步成功**。
|
||||
// (不采用"拉起主应用同步"是因为会每 30 分钟弹出一次界面,体验不可接受。)
|
||||
BackgroundSyncRunner.runOnceThrottledByCard(this.context).then((): void => {
|
||||
this.pushData(formId, CardDataService.getDayOffset(formId));
|
||||
}).catch((err: BusinessError): void => {
|
||||
LogUtil.write(`卡片驱动同步失败: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
onRemoveForm(formId: string): void {
|
||||
@@ -53,12 +101,35 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
||||
CardDataService.clearDayOffset(formId);
|
||||
}
|
||||
|
||||
/** 卡片上「‹ / › / 回到今天」按钮触发:message 里带 pageAction=prev|next|today */
|
||||
/**
|
||||
* 卡片事件:既处理日程翻页,也处理待办打勾。
|
||||
*
|
||||
* ⚠️ 不用 JSON.parse 解析 message —— 不同系统版本下格式可能是
|
||||
* `{params:{...}}` 也可能是直接 `{...}`,格式差异会让 JSON 解析拿到 undefined。
|
||||
* 统一用**子串匹配**,稳且不依赖具体结构。
|
||||
*/
|
||||
onFormEvent(formId: string, message: string): void {
|
||||
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);
|
||||
let next: number = cur;
|
||||
// 不用 JSON 解析:不同版本 message 的格式可能是 {params:{...}} 或直接 {...},直接子串匹配更稳
|
||||
if (message.indexOf('prev') >= 0) {
|
||||
next = cur - 1;
|
||||
} else if (message.indexOf('next') >= 0) {
|
||||
@@ -70,4 +141,63 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
||||
LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// entry/src/main/ets/pages/AccountsPage.ets
|
||||
// DAV 账号管理(由原 CalDAVSync 首页移植)
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore';
|
||||
import { SyncEngine } from '../common/SyncEngine';
|
||||
import { SyncEngine, SyncBusyError } from '../common/SyncEngine';
|
||||
import { EditNavParams } from './EditAccountPage';
|
||||
import { EventDb } from '../common/EventDb';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
import { ScreenKeeper } from '../common/ScreenKeeper';
|
||||
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@@ -90,6 +91,12 @@ struct AccountsPage {
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `「${acc.name}」同步完成` });
|
||||
} catch (err) {
|
||||
// ⭐ 已有同步在进行中 → 提示"请耐心等待",不报"失败"(避免用户误解)
|
||||
if (err instanceof SyncBusyError) {
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
|
||||
return;
|
||||
}
|
||||
const e = err as BusinessError;
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `「${acc.name}」同步失败:${e.message}` });
|
||||
@@ -142,13 +149,24 @@ struct AccountsPage {
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行删除:账号配置 + 该账号全部本地日程/待办 */
|
||||
/** 执行删除:账号配置 + 该账号全部本地日程/待办 + 该系统日历中该账号的全部镜像日历本 */
|
||||
private async confirmDeleteAccount(acc: DavAccount): Promise<void> {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// ⭐ 先清系统日历里属于**本账号**的镜像日历本(按 bookId 里的 accId 前缀匹配,
|
||||
// 绝不按显示名 → 不同账号的同名日历本不会误删)。
|
||||
// 必须在改账号配置**之前**做:一旦账号配置被删,bookId 就再也解析不出来,
|
||||
// 那些幽灵账户就永远清不掉了(这正是本次要修的 bug)。
|
||||
let mirrored: number = 0;
|
||||
try {
|
||||
mirrored = await SystemCalendarMirror.removeByAccount(context, acc.id);
|
||||
} catch (err) {
|
||||
// 系统日历清理失败不阻断账号删除(用户至少能删掉账号)
|
||||
LogUtil.write(`删除账号 ${acc.id}:清理系统日历镜像失败 ${(err as BusinessError).message}`);
|
||||
}
|
||||
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
|
||||
// force=true:删除最后一个账号时列表为空是合法场景,允许覆盖
|
||||
await AccountStore.saveAll(context, this.accounts, true);
|
||||
@@ -156,7 +174,11 @@ struct AccountsPage {
|
||||
await EventDb.deleteAccountEvents(context, acc.id);
|
||||
await AppSettings.cleanupAccountSettings(context, acc.id);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `「${acc.name}」已删除` });
|
||||
.showToast({
|
||||
message: mirrored > 0
|
||||
? `「${acc.name}」已删除,并清除系统日历中 ${mirrored} 个镜像日历本`
|
||||
: `「${acc.name}」已删除`
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
this.getUIContext().getPromptAction()
|
||||
@@ -252,23 +274,43 @@ struct AccountsPage {
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
emptyState() {
|
||||
Column({ space: 12 }) {
|
||||
Text('+')
|
||||
.fontSize(30)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
.width(72)
|
||||
.height(72)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(20)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1.5, color: $r('app.color.shadow_color') })
|
||||
// ⭐ 空状态的「+」本身可点击 → 直接进入添加账号页(此前只是个静态图标,点了没反应)
|
||||
Button() {
|
||||
Text('+')
|
||||
.fontSize(34)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
.width(84)
|
||||
.height(84)
|
||||
.borderRadius(24)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1.5, color: $r('app.color.brand') })
|
||||
.shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 })
|
||||
.onClick(() => {
|
||||
this.openAddPage();
|
||||
})
|
||||
Text('还没有任何 CalDAV 账号')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('点击右下角 + 添加 CalDAV 账号')
|
||||
Text('点击上方 + 或右下角 + 添加 CalDAV 账号')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// entry/src/main/ets/pages/AddAccountPage.ets
|
||||
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import url from '@ohos.url';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
|
||||
import { DavDiscovery, DavCapability } from '../common/DavDiscovery';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@@ -107,12 +109,30 @@ struct AddAccountPage {
|
||||
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
|
||||
}
|
||||
if (code === 401) {
|
||||
this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码';
|
||||
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||
this.statusOk = false;
|
||||
return false;
|
||||
}
|
||||
// ⭐ 0.0.7:405 是"这里不是 DAV 端点"的典型信号 —— 直接给出可操作指引,
|
||||
// 而不是笼统地报一个状态码让用户去猜。
|
||||
if (code === 405) {
|
||||
const cap: DavCapability = await DavDiscovery.optionsCapability(serverUrl, authHeader);
|
||||
if (!cap.isDav) {
|
||||
this.statusMsg = '这个地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
|
||||
+ '请填写「日历家目录」而不是 DAV 根或网页链接,例如:\n'
|
||||
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
|
||||
+ '(也可以直接填域名,下一步会自动尝试发现)';
|
||||
this.statusOk = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (code >= 200 && code < 500) {
|
||||
this.statusMsg = '服务器连接成功';
|
||||
// ⭐ 即使可连,也顺手提示"地址看起来不完整",让用户提前知道下一步会自动补全
|
||||
if (DavDiscovery.looksIncomplete(serverUrl)) {
|
||||
this.statusMsg = '服务器连接成功(该地址看起来是服务器根路径,下一步会自动尝试定位日历家目录)';
|
||||
} else {
|
||||
this.statusMsg = '服务器连接成功';
|
||||
}
|
||||
this.statusOk = true;
|
||||
return true;
|
||||
}
|
||||
@@ -186,6 +206,17 @@ struct AddAccountPage {
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
// ⭐ 0.0.7:先识别「网页分享链接」——这类地址对 PROPFIND 必定 405,
|
||||
// 提前拦下并给出人话指引,避免用户白跑一趟只看到"服务器返回 405"。
|
||||
const kind: string = DavDiscovery.classify(targetUrl);
|
||||
const shareMsg: string = DavDiscovery.shareHint(kind);
|
||||
if (shareMsg !== '') {
|
||||
this.statusMsg = shareMsg;
|
||||
this.statusOk = false;
|
||||
LogUtil.write(`添加账号:识别为网页分享链接(${kind}),已拦截`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.statusMsg = '正在连接服务器…';
|
||||
this.statusOk = false;
|
||||
@@ -229,7 +260,7 @@ struct AddAccountPage {
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 16 }) {
|
||||
this.formField('服务器地址', '例如:https://nas.example.com/caldav/', this.serverUrl,
|
||||
this.formField('服务器地址', '例如:https://nas.example.com/remote.php/dav/calendars/用户名/(也可只填域名,下一步会自动识别)', this.serverUrl,
|
||||
(value: string) => {
|
||||
this.serverUrl = value;
|
||||
}, false)
|
||||
@@ -297,6 +328,19 @@ struct AddAccountPage {
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
formField(label: string, placeholder: string, value: string,
|
||||
onChange: (value: string) => void, isPassword: boolean) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// entry/src/main/ets/pages/CalendarListPage.ets
|
||||
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
||||
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
||||
import { DavDiscovery, DiscoverResult, DavCapability } from '../common/DavDiscovery';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
/**
|
||||
@@ -98,17 +99,44 @@ struct CalendarListPage {
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
const auth: string = 'Basic ' + token;
|
||||
// ⭐ 0.0.7:先做地址自动发现/智能补全 —— 用户常只填域名或 DAV 根,
|
||||
// 而 Nextcloud 等要求填「日历家目录」。此处自动找对端点,避免"读不出日历"。
|
||||
try {
|
||||
const resolved: DiscoverResult = await DavDiscovery.resolve(
|
||||
this.serverUrl,
|
||||
this.username,
|
||||
auth,
|
||||
async (u: string): Promise<boolean> => {
|
||||
const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth);
|
||||
return d.entries !== undefined && d.entries.length > 0;
|
||||
});
|
||||
if (resolved.ok && resolved.url !== '' && resolved.url !== this.serverUrl) {
|
||||
LogUtil.write(`服务地址已自动修正:${this.serverUrl} → ${resolved.url}(来源 ${resolved.via})`);
|
||||
this.serverUrl = resolved.url;
|
||||
} else if (!resolved.ok && resolved.hint !== '') {
|
||||
// 自动发现彻底失败 → 直接把"人话原因"给用户,不再只报一个状态码
|
||||
this.isLoading = false;
|
||||
this.statusMsg = resolved.hint;
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// 发现阶段异常不阻塞:继续用原地址走既有流程(保持旧行为兜底)
|
||||
LogUtil.write(`地址发现阶段异常,回退原地址:${(err as BusinessError).message}`);
|
||||
}
|
||||
// PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑)
|
||||
try {
|
||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, 'Basic ' + token);
|
||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, auth);
|
||||
this.isLoading = false;
|
||||
if (disc.statusCode === 401) {
|
||||
this.statusMsg = '登录已失效,请返回重新连接';
|
||||
this.statusMsg = '服务器拒绝凭据(401):请返回检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
|
||||
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
|
||||
// ⭐ 0.0.7:把生硬的状态码换成人话 + 可操作指引
|
||||
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
@@ -118,7 +146,8 @@ struct CalendarListPage {
|
||||
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
|
||||
}
|
||||
if (list.length === 0) {
|
||||
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)';
|
||||
// ⭐ 0.0.7:区分"地址层级不对"与"确实没有日历",并给出正确填法示例
|
||||
this.statusMsg = DavDiscovery.emptyHint(this.serverUrl, this.username);
|
||||
this.statusOk = false;
|
||||
} else {
|
||||
this.calendarList = list;
|
||||
@@ -369,6 +398,19 @@ struct CalendarListPage {
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Component
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
// entry/src/main/ets/pages/EditAccountPage.ets
|
||||
// 编辑账号:查看/重选该账号下的日历本、修改账户名
|
||||
// 保存后清理失效日历本的本地数据,并触发一次重新同步
|
||||
import { router } from '@kit.ArkUI';
|
||||
//
|
||||
// ⚠️ 取消勾选某个日历本会改变**后面所有本的序号**(calKey = accId_序号)。因此保存时还要检查:
|
||||
// 被取消的那个本是不是「系统日历 → CalDAV」的备份目标 —— 是的话当场把该功能关闭,
|
||||
// 否则用户会以为备份还在工作,实际却可能把系统日程导进别的本(用户实测反馈)。
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore } from '../common/AccountStore';
|
||||
import { DavDiscovery, DiscoverResult } from '../common/DavDiscovery';
|
||||
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
||||
import { EventDb } from '../common/EventDb';
|
||||
import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
/**
|
||||
@@ -18,14 +24,27 @@ export class EditCalendarItem {
|
||||
href: string;
|
||||
name: string;
|
||||
color: string; // 服务器定义的颜色(calendar-color),可能为空
|
||||
/** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */
|
||||
comps: string;
|
||||
selected: boolean;
|
||||
|
||||
constructor(href: string, name: string, color: string) {
|
||||
constructor(href: string, name: string, color: string, comps: string = 'unknown') {
|
||||
this.href = href;
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.comps = comps;
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
/** 是否待办本(纯待办或混合) */
|
||||
isTodoBook(): boolean {
|
||||
return this.comps === 'todo' || this.comps === 'both';
|
||||
}
|
||||
|
||||
/** 是否日历本(纯日历、混合、或服务器未声明) */
|
||||
isEventBook(): boolean {
|
||||
return this.comps === 'event' || this.comps === 'both' || this.comps === 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,24 +155,47 @@ struct EditAccountPage {
|
||||
return;
|
||||
}
|
||||
// PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现)
|
||||
const auth: string = 'Basic ' + token;
|
||||
// ⭐ 0.0.7:地址自动发现/智能补全(老账号可能当初填的是 DAV 根或已失效的路径)
|
||||
try {
|
||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, 'Basic ' + token);
|
||||
const resolved: DiscoverResult = await DavDiscovery.resolve(
|
||||
this.acc.serverUrl,
|
||||
this.acc.username,
|
||||
auth,
|
||||
async (u: string): Promise<boolean> => {
|
||||
const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth);
|
||||
return d.entries !== undefined && d.entries.length > 0;
|
||||
});
|
||||
if (resolved.ok && resolved.url !== '' && resolved.url !== this.acc.serverUrl) {
|
||||
LogUtil.write(`编辑账号:服务地址自动修正 ${this.acc.serverUrl} → ${resolved.url}(${resolved.via})`);
|
||||
this.acc.serverUrl = resolved.url;
|
||||
} else if (!resolved.ok && resolved.hint !== '') {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = resolved.hint;
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtil.write(`编辑账号:地址发现异常,回退原地址 ${(err as BusinessError).message}`);
|
||||
}
|
||||
try {
|
||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, auth);
|
||||
this.isLoading = false;
|
||||
if (disc.statusCode === 401) {
|
||||
this.statusMsg = '登录已失效,请检查账号密码';
|
||||
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
|
||||
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
|
||||
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
|
||||
new EditCalendarItem(e.href, e.displayName, e.color));
|
||||
new EditCalendarItem(e.href, e.displayName, e.color, e.comps));
|
||||
if (list.length === 0) {
|
||||
LogUtil.write('编辑账号:未发现任何日历本');
|
||||
this.statusMsg = '该路径下未发现日历本';
|
||||
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
|
||||
this.statusOk = false;
|
||||
} else {
|
||||
// 已勾选的日历本按账号当前配置预选
|
||||
@@ -217,8 +259,42 @@ struct EditAccountPage {
|
||||
target.calendarHrefs = newHrefs;
|
||||
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
|
||||
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
|
||||
target.calendarComps = selectedItems.map((c: EditCalendarItem): string => c.comps);
|
||||
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
|
||||
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
|
||||
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
|
||||
// 序号收窄后,被取消本的旧 calKey 会被前面位移下来的本"接管",
|
||||
// 不先删的话旧行会与新主的行混在同一个 calKey 里(2026-09-29 实测:
|
||||
// 服务器删除「读书」后重选,其 988 行混进「轻舟」的 calKey,镜像一直复活幽灵数据)。
|
||||
const newHrefSet: Set<string> = new Set<string>();
|
||||
for (const c of newHrefs) {
|
||||
newHrefSet.add(c);
|
||||
}
|
||||
const removedHrefs: string[] = [];
|
||||
for (const h of oldHrefs) {
|
||||
if (!newHrefSet.has(h)) {
|
||||
removedHrefs.push(h);
|
||||
}
|
||||
}
|
||||
if (removedHrefs.length > 0) {
|
||||
const pruned: number = await EventDb.pruneByHrefs(context, target.id, removedHrefs);
|
||||
LogUtil.write(`编辑账号保存:取消勾选/已失效 ${removedHrefs.length} 个日历本,清理本地日程 ${pruned} 行`);
|
||||
}
|
||||
// ⭐ 被取消勾选的日历本,其系统日历镜像账户也要一并删掉(否则取消同步后系统日历仍有幽灵账户)。
|
||||
// 此时账号配置即将被改写、listBooks 已解析不到它,故用 href 直接算稳定 bookId(与镜像侧同算法)。
|
||||
// ⚠️ 用稳定 bookId 删 —— 不同账号下有同名日历本时,bookId 里带 accId 前缀,绝不会删错。
|
||||
for (let oi: number = 0; oi < oldHrefs.length; oi++) {
|
||||
const oh: string = oldHrefs[oi];
|
||||
if (newHrefSet.has(oh)) {
|
||||
continue;
|
||||
}
|
||||
const bookId: string = SystemCalendarMirror.bookIdOf(target.id, oh, `${target.id}_${oi}`);
|
||||
try {
|
||||
await SystemCalendarMirror.removeById(context, bookId);
|
||||
} catch (err) {
|
||||
// 镜像账户删除失败不阻断保存(下次镜像前的孤儿清理还会兜底)
|
||||
}
|
||||
}
|
||||
const remapPairs: Array<[string, string]> = [];
|
||||
for (let oi: number = 0; oi < oldHrefs.length; oi++) {
|
||||
const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]);
|
||||
@@ -243,8 +319,15 @@ struct EditAccountPage {
|
||||
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
|
||||
await EventDb.pruneAccountEvents(context, target.id, validKeys);
|
||||
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` });
|
||||
// ⭐ 若刚被取消勾选的本正是「系统日历 → CalDAV」的备份目标,**当场**判它失效并关闭备份功能。
|
||||
// 不这么做的话,要等下一次同步才会发现目标没了;这期间开关还显示"备份到 CalDAV",
|
||||
// 用户会以为备份仍在正常工作,而实际已经导不进去(或更糟:导进了别的本)。
|
||||
const backupCheck: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context);
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: backupCheck.closed
|
||||
? '原系统日历备份目标已被取消同步,备份功能已自动关闭'
|
||||
: `已保存,同步 ${selectedItems.length} 个日历本`
|
||||
});
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
@@ -439,6 +522,19 @@ struct EditAccountPage {
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Component
|
||||
@@ -448,6 +544,22 @@ struct EditCalendarRow {
|
||||
|
||||
build() {
|
||||
Row({ space: 12 }) {
|
||||
// ⭐ SVG_ICON 类型图标:放在颜色圆点**前面**,
|
||||
// 让用户先看清"这本是日历还是待办",再看它是什么颜色。
|
||||
//
|
||||
// 用 SVG 而不是字符:字符图标(▦ / ✓)依赖系统字体字形,
|
||||
// 笔画粗细与视觉重量不统一,跨设备还可能缺字形变成方框。
|
||||
// 这两个 SVG 共用同一套笔触(stroke 2、round 端点、24×24),
|
||||
// 渲染成 16vp 时重量一致。
|
||||
//
|
||||
// 混合本(both):显示待办图标(它确实能存待办),
|
||||
// 但用户看名字/颜色仍能分辨,不必塞两个图标挤在一行。
|
||||
Image(this.item.comps === 'todo' || this.item.comps === 'both'
|
||||
? $r('app.media.ic_todo_book')
|
||||
: $r('app.media.ic_event_book'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.objectFit(ImageFit.Contain)
|
||||
if (this.item.color !== '') {
|
||||
Circle()
|
||||
.width(12)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2231
-198
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,12 @@ struct Widget2x2Card {
|
||||
@LocalStorageProp('dateMd') dateMd: string = '';
|
||||
@LocalStorageProp('weekday') weekday: string = '';
|
||||
@LocalStorageProp('todayCount') todayCount: number = 0;
|
||||
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
|
||||
@LocalStorageProp('selectedDate') selectedDate: number = 0;
|
||||
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||
@Builder
|
||||
addButton() {
|
||||
Button() {
|
||||
@@ -35,12 +39,12 @@ struct Widget2x2Card {
|
||||
})
|
||||
}
|
||||
|
||||
/** 卡片"打开 App":点击日期区或今日日程数区域触发;添加按钮是 header 行的兄弟节点,其点击不会冒泡到这里 */
|
||||
/** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
|
||||
private openApp(): void {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
params: { openDate: `${this.selectedDate}` }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +65,6 @@ struct Widget2x2Card {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
|
||||
this.addButton()
|
||||
}
|
||||
@@ -92,12 +95,13 @@ struct Widget2x2Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日(原来只有日期区/条数区可点)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,13 @@ struct Widget4x2Card {
|
||||
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||
@LocalStorageProp('todayCount') todayCount: number = 0;
|
||||
@LocalStorageProp('ongoingJson') ongoingJson: string = '[]';
|
||||
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
|
||||
@LocalStorageProp('selectedDate') selectedDate: number = 0;
|
||||
|
||||
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */
|
||||
/** 右侧列表数据(可滚动;数据侧已按时间升序并限制在 15 条内) */
|
||||
private parseOngoing(): CardItem2x4[] {
|
||||
try {
|
||||
const all: CardItem2x4[] = JSON.parse(this.ongoingJson) as CardItem2x4[];
|
||||
return all.slice(0, 2);
|
||||
return JSON.parse(this.ongoingJson) as CardItem2x4[];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
@@ -57,12 +58,12 @@ struct Widget4x2Card {
|
||||
})
|
||||
}
|
||||
|
||||
/** 卡片"打开 App":点击左侧日期区、今日条数区、右侧日程区触发;添加按钮是 header 行兄弟节点,不冒泡 */
|
||||
/** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
|
||||
private openApp(): void {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
params: { openDate: `${this.selectedDate}` }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ struct Widget4x2Card {
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
|
||||
.padding({ left: 6, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor('#F5F7FA')
|
||||
}
|
||||
@@ -118,7 +119,6 @@ struct Widget4x2Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
|
||||
build() {
|
||||
@@ -141,7 +141,6 @@ struct Widget4x2Card {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
|
||||
this.addButton()
|
||||
}
|
||||
@@ -173,7 +172,6 @@ struct Widget4x2Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
@@ -184,20 +182,24 @@ struct Widget4x2Card {
|
||||
.strokeWidth(0.5)
|
||||
.color('#E5E5E5')
|
||||
|
||||
// ===== 右半:当前时间之后最近的两条日程(不滚动)=====
|
||||
// ===== 右半:未来日程列表(可滚动,最多 15 条)=====
|
||||
// ⭐ 原来固定 Column + 只取 2 条,用户想看后天的安排就看不到。
|
||||
// 改为 List:自带虚拟化与滚动惯性,条目多也不占内存。
|
||||
Column() {
|
||||
if (this.parseOngoing().length === 0) {
|
||||
this.emptyHint()
|
||||
} else {
|
||||
Column({ space: 6 }) {
|
||||
List({ space: 4 }) {
|
||||
ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => {
|
||||
this.ongoingRow(item)
|
||||
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`)
|
||||
ListItem() {
|
||||
this.ongoingRow(item)
|
||||
}
|
||||
}, (item: CardItem2x4, idx: number) => `o${idx}_${item.time}_${item.title}`)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
@@ -208,5 +210,7 @@ struct Widget4x2Card {
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
// ⭐ 整卡可点:左右空白处也能进 App 并定位到当前显示日
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,40 +110,32 @@ struct Widget4x4Card {
|
||||
return r >= 0 && Math.floor(r * 24) === h;
|
||||
}
|
||||
|
||||
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。
|
||||
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */
|
||||
/** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
|
||||
* (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
|
||||
private w4Range(): W4Range {
|
||||
const rg = new W4Range();
|
||||
const bs: TBlock[] = this.parseBlocks();
|
||||
let s: number = -1;
|
||||
let e: number = -1;
|
||||
let s: number = 8;
|
||||
let e: number = 18;
|
||||
for (const b of bs) {
|
||||
const bh: number = Math.floor(b.topRatio * 24);
|
||||
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
||||
if (s < 0 || bh < s) {
|
||||
if (bh < s) {
|
||||
s = bh;
|
||||
}
|
||||
if (e < 0 || eh > e) {
|
||||
if (eh > e) {
|
||||
e = eh;
|
||||
}
|
||||
}
|
||||
const r: number = this.w4NowR();
|
||||
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
|
||||
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程
|
||||
// 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
|
||||
const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1;
|
||||
if (s < 0) {
|
||||
s = nh >= 0 ? nh : 8;
|
||||
if (nh >= 0 && nh < s && s < 8) {
|
||||
s = nh;
|
||||
}
|
||||
if (e < 0) {
|
||||
e = nh >= 0 ? nh + 1 : 20;
|
||||
}
|
||||
if (nh >= 0) {
|
||||
if (nh < s) {
|
||||
s = nh;
|
||||
}
|
||||
if (nh + 1 > e) {
|
||||
e = nh + 1;
|
||||
}
|
||||
if (nh >= 0 && nh + 1 > e && e > 18) {
|
||||
e = nh + 1;
|
||||
}
|
||||
if (s < 0) {
|
||||
s = 0;
|
||||
@@ -395,7 +387,9 @@ struct Widget4x4Card {
|
||||
return h < 10 ? `0${h}` : `${h}`;
|
||||
}
|
||||
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡到其它点击区) */
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||
@Builder
|
||||
addButton() {
|
||||
Button() {
|
||||
@@ -450,6 +444,7 @@ struct Widget4x4Card {
|
||||
.borderRadius(11)
|
||||
.padding(0)
|
||||
.backgroundColor('#F2F3F5')
|
||||
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
|
||||
.onClick(() => this.page(act))
|
||||
}
|
||||
|
||||
@@ -470,6 +465,7 @@ struct Widget4x4Card {
|
||||
.borderRadius(11)
|
||||
.padding(0)
|
||||
.backgroundColor('#007DFF')
|
||||
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
|
||||
.onClick(() => this.page('today'))
|
||||
}
|
||||
}
|
||||
@@ -562,7 +558,6 @@ struct Widget4x4Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
} else {
|
||||
List({ space: 4 }) {
|
||||
ForEach(this.parseList(), (item: LItem4) => {
|
||||
@@ -576,7 +571,6 @@ struct Widget4x4Card {
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.cachedCount(8)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,7 +591,6 @@ struct Widget4x4Card {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
this.pageBtn('›', 'next')
|
||||
this.sideBtn()
|
||||
}
|
||||
@@ -638,7 +631,6 @@ struct Widget4x4Card {
|
||||
.padding({ left: 5, right: 5 })
|
||||
.borderRadius(5)
|
||||
.backgroundColor(a.color)
|
||||
.onClick(() => this.openApp())
|
||||
}, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`)
|
||||
if (this.w4AllDayRest() > 0) {
|
||||
Text(`还有 ${this.w4AllDayRest()} 个全天日程`)
|
||||
@@ -698,6 +690,9 @@ struct Widget4x4Card {
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
|
||||
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
|
||||
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
||||
@@ -823,7 +818,6 @@ struct Widget4x4Card {
|
||||
.width('100%')
|
||||
.height(this.w4BlockVp(b))
|
||||
.constraintSize({ minHeight: 14 })
|
||||
.onClick(() => this.openApp())
|
||||
}, (b: TBlock) => `b_${r.key}_${b.eventKey}`)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
|
||||
@@ -109,40 +109,32 @@ struct Widget6x4Card {
|
||||
return r >= 0 && Math.floor(r * 24) === h;
|
||||
}
|
||||
|
||||
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。
|
||||
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */
|
||||
/** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
|
||||
* (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
|
||||
private w6Range(): W6Range {
|
||||
const rg = new W6Range();
|
||||
const bs: TBlock6[] = this.parseBlocks();
|
||||
let s: number = -1;
|
||||
let e: number = -1;
|
||||
let s: number = 8;
|
||||
let e: number = 18;
|
||||
for (const b of bs) {
|
||||
const bh: number = Math.floor(b.topRatio * 24);
|
||||
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
||||
if (s < 0 || bh < s) {
|
||||
if (bh < s) {
|
||||
s = bh;
|
||||
}
|
||||
if (e < 0 || eh > e) {
|
||||
if (eh > e) {
|
||||
e = eh;
|
||||
}
|
||||
}
|
||||
const r: number = this.w6NowR();
|
||||
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
|
||||
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程
|
||||
// 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
|
||||
const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1;
|
||||
if (s < 0) {
|
||||
s = nh >= 0 ? nh : 8;
|
||||
if (nh >= 0 && nh < s && s < 8) {
|
||||
s = nh;
|
||||
}
|
||||
if (e < 0) {
|
||||
e = nh >= 0 ? nh + 1 : 20;
|
||||
}
|
||||
if (nh >= 0) {
|
||||
if (nh < s) {
|
||||
s = nh;
|
||||
}
|
||||
if (nh + 1 > e) {
|
||||
e = nh + 1;
|
||||
}
|
||||
if (nh >= 0 && nh + 1 > e && e > 18) {
|
||||
e = nh + 1;
|
||||
}
|
||||
if (s < 0) {
|
||||
s = 0;
|
||||
@@ -392,7 +384,9 @@ struct Widget6x4Card {
|
||||
return h < 10 ? `0${h}:00` : `${h}:00`;
|
||||
}
|
||||
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡) */
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||
@Builder
|
||||
addButton() {
|
||||
Button() {
|
||||
@@ -447,6 +441,7 @@ struct Widget6x4Card {
|
||||
.borderRadius(11)
|
||||
.padding(0)
|
||||
.backgroundColor('#F2F3F5')
|
||||
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
|
||||
.onClick(() => this.page(act))
|
||||
}
|
||||
|
||||
@@ -465,6 +460,7 @@ struct Widget6x4Card {
|
||||
.borderRadius(11)
|
||||
.padding(0)
|
||||
.backgroundColor('#007DFF')
|
||||
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
|
||||
.onClick(() => this.page('today'))
|
||||
}
|
||||
}
|
||||
@@ -557,7 +553,6 @@ struct Widget6x4Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
} else {
|
||||
List({ space: 4 }) {
|
||||
ForEach(this.parseList(), (item: LItem6) => {
|
||||
@@ -571,7 +566,6 @@ struct Widget6x4Card {
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.cachedCount(8)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +586,6 @@ struct Widget6x4Card {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
Text(`${this.dayCount} 条`)
|
||||
.fontSize(10)
|
||||
.fontColor('#B0B0B0')
|
||||
@@ -637,7 +630,6 @@ struct Widget6x4Card {
|
||||
.padding({ left: 6, right: 6 })
|
||||
.borderRadius(6)
|
||||
.backgroundColor(a.color)
|
||||
.onClick(() => this.openApp())
|
||||
}, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`)
|
||||
if (this.w6AllDayRest() > 0) {
|
||||
Text(`还有 ${this.w6AllDayRest()} 个全天日程`)
|
||||
@@ -697,6 +689,9 @@ struct Widget6x4Card {
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
|
||||
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
|
||||
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
||||
@@ -822,7 +817,6 @@ struct Widget6x4Card {
|
||||
.width('100%')
|
||||
.height(this.w6BlockVp(b))
|
||||
.constraintSize({ minHeight: 16 })
|
||||
.onClick(() => this.openApp())
|
||||
}, (b: TBlock6) => `b_${r.key}_${b.eventKey}`)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// entry/src/main/ets/syncwork/SyncWorkAbility.ets
|
||||
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步
|
||||
// 注意:必须幂等——同步本身基于 ETag 全量比对,天然幂等;执行完必须 stopWork,否则系统按超时处理
|
||||
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步。
|
||||
//
|
||||
// ⚠️ 约束(勿改):
|
||||
// - 单次回调**上限 2 分钟**,超时进程被系统终止 → 逻辑必须轻量(本类只调 Runner,不做事后处理)。
|
||||
// - 周期由 BackgroundSyncService 设为 2 小时(系统活跃分组下限),此处不再重复设置。
|
||||
// - 必须幂等:同步基于 etag 全量比对 + dirty 标记,天然幂等。
|
||||
// - 必须主动 stopWork,否则系统按超时处理。
|
||||
import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
||||
import { SyncEngine } from '../common/SyncEngine';
|
||||
import { CardDataService } from '../common/CardDataService';
|
||||
import { ReminderService } from '../common/ReminderService';
|
||||
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||
@@ -24,50 +23,12 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||
|
||||
private async doSync(workInfo: workScheduler.WorkInfo): Promise<void> {
|
||||
try {
|
||||
const context = this.context;
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
// 备份模式:先导入系统本地日历(幂等),再随同步推送
|
||||
try {
|
||||
await SystemCalendarImport.importIfNeeded(context);
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
let ok: number = 0;
|
||||
let attempted: number = 0;
|
||||
for (const acc of accounts) {
|
||||
if (acc.type !== TYPE_CALDAV) {
|
||||
continue;
|
||||
}
|
||||
attempted++;
|
||||
try {
|
||||
// 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 5 分钟),不再套外层超时
|
||||
await SyncEngine.syncAccount(context, acc);
|
||||
ok++;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`延迟任务同步 id=${acc.id} 失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// 全部账号同步成功才结束"一次性全量重拉"状态
|
||||
if (attempted > 0 && ok === attempted) {
|
||||
await AppSettings.markFullRefetchDone(context);
|
||||
}
|
||||
await SyncEngine.settleLocalEvents(context);
|
||||
await SyncEngine.pruneOrphanRows(context, accounts);
|
||||
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
|
||||
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失,
|
||||
// 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。
|
||||
await AccountStore.saveAll(context, accounts);
|
||||
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
||||
await CardDataService.pushToAllForms(context);
|
||||
await ReminderService.refreshReminders(context);
|
||||
LogUtil.write(`延迟任务同步完成:${ok} 个账号`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`延迟任务同步失败: ${e.message}`);
|
||||
// 完整的后台同步流程(导入/对账/逐账号同步/镜像/卡片/提醒)统一由 Runner 实现,
|
||||
// 与"网络恢复补同步"、"回到前台补同步"共用同一套逻辑,避免三处实现漂移。
|
||||
await BackgroundSyncRunner.runOnce(this.context, 'deferred');
|
||||
} finally {
|
||||
// 必须主动结束,否则系统按 120 秒超时处理
|
||||
workScheduler.stopWork(workInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,6 @@
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.PUBLISH_AGENT_REMINDER"
|
||||
},
|
||||
@@ -39,18 +36,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.LOCATION",
|
||||
"reason": "$string:perm_location",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "inuse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.APPROXIMATELY_LOCATION",
|
||||
"reason": "$string:perm_location_approx",
|
||||
"name": "ohos.permission.WRITE_CALENDAR",
|
||||
"reason": "$string:perm_write_calendar",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
@@ -73,9 +60,6 @@
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"orientation": "auto_rotation_restricted",
|
||||
"backgroundModes": [
|
||||
"dataTransfer"
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
{ "name": "grid_line", "value": "#14000000" },
|
||||
{ "name": "today_bg", "value": "#14007DFF" },
|
||||
{ "name": "selected_bg", "value": "#007DFF" },
|
||||
{ "name": "chip_off_bg", "value": "#0D000000" }
|
||||
{ "name": "chip_off_bg", "value": "#0D000000" },
|
||||
{ "name": "event_mark", "value": "#D93025" },
|
||||
{ "name": "soft_red", "value": "#F2938A" }
|
||||
]
|
||||
}
|
||||
@@ -18,20 +18,12 @@
|
||||
},
|
||||
{
|
||||
"name": "perm_write_calendar",
|
||||
"value": "写入系统日历,用于保存您创建的日程"
|
||||
"value": "写入系统日历,用于把您选择的 CalDAV 日历本镜像到系统日历,让小艺、桌面日历卡片和手表也能看到您的日程"
|
||||
},
|
||||
{
|
||||
"name": "perm_read_whole_calendar",
|
||||
"value": "读取所有日历账户的日程,用于在日历中统一展示"
|
||||
},
|
||||
{
|
||||
"name": "perm_location",
|
||||
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
|
||||
},
|
||||
{
|
||||
"name": "perm_location_approx",
|
||||
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
|
||||
},
|
||||
{
|
||||
"name": "card_ability_desc",
|
||||
"value": "同步日历服务卡片"
|
||||
@@ -59,6 +51,26 @@
|
||||
{
|
||||
"name": "sync_work_desc",
|
||||
"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",
|
||||
"updateDuration": 1,
|
||||
"defaultDimension": "2*2",
|
||||
"supportDimensions": ["2*2"]
|
||||
"supportDimensions": [
|
||||
"2*2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "WidgetCard2x4",
|
||||
@@ -34,7 +36,9 @@
|
||||
"scheduledUpdateTime": "07:30",
|
||||
"updateDuration": 1,
|
||||
"defaultDimension": "2*4",
|
||||
"supportDimensions": ["2*4"]
|
||||
"supportDimensions": [
|
||||
"2*4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "WidgetCard4x4",
|
||||
@@ -52,7 +56,9 @@
|
||||
"scheduledUpdateTime": "07:30",
|
||||
"updateDuration": 1,
|
||||
"defaultDimension": "4*4",
|
||||
"supportDimensions": ["4*4"]
|
||||
"supportDimensions": [
|
||||
"4*4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "WidgetCard6x4",
|
||||
@@ -70,7 +76,89 @@
|
||||
"scheduledUpdateTime": "07:30",
|
||||
"updateDuration": 1,
|
||||
"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/EditAccountPage",
|
||||
"pages/EventEditPage",
|
||||
"pages/TodoEditPage",
|
||||
"pages/SettingsPage"
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
{ "name": "grid_line", "value": "#1FFFFFFF" },
|
||||
{ "name": "today_bg", "value": "#330A84FF" },
|
||||
{ "name": "selected_bg", "value": "#0A84FF" },
|
||||
{ "name": "chip_off_bg", "value": "#1FFFFFFF" }
|
||||
{ "name": "chip_off_bg", "value": "#1FFFFFFF" },
|
||||
{ "name": "event_mark", "value": "#FF6B60" },
|
||||
{ "name": "soft_red", "value": "#C97A72" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// 验证 IcsUtil.rewriteTodoFields(改标题/备注/截止时间)
|
||||
function escape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||
}
|
||||
function fmtIcsTime(ms) {
|
||||
const pad = n => n < 10 ? '0' + n : String(n);
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function rewriteTodoFields(ics, uid, title, desc, dueMs) {
|
||||
const crlf = '\r\n';
|
||||
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
|
||||
const upperOf = s => s.toUpperCase();
|
||||
|
||||
let targetBegin = -1, targetEnd = -1, curBegin = -1, curHasUid = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { curBegin = i; curHasUid = false; continue; }
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) curHasUid = true;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) { targetBegin = curBegin; targetEnd = i; }
|
||||
curBegin = -1; curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) return ics;
|
||||
|
||||
const out = [];
|
||||
let sawSummary = false, sawDesc = false, sawDue = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i], u = upperOf(line);
|
||||
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
|
||||
if (u.startsWith('SUMMARY')) { sawSummary = true; if (title !== '') out.push('SUMMARY:' + escape(title)); continue; }
|
||||
if (u.startsWith('DESCRIPTION')) { sawDesc = true; if (desc !== '') out.push('DESCRIPTION:' + escape(desc)); continue; }
|
||||
if (u.startsWith('DUE')) { sawDue = true; if (dueMs > 0) out.push('DUE:' + fmtIcsTime(dueMs)); continue; }
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const extra = [];
|
||||
if (!sawSummary && title !== '') extra.push('SUMMARY:' + escape(title));
|
||||
if (!sawDesc && desc !== '') extra.push('DESCRIPTION:' + escape(desc));
|
||||
if (!sawDue && dueMs > 0) extra.push('DUE:' + fmtIcsTime(dueMs));
|
||||
if (extra.length === 0) { const p = out.join(crlf); return p.endsWith(crlf) ? p : p + crlf; }
|
||||
|
||||
// 重新数目标块序号
|
||||
let targetOrdinal = -1;
|
||||
{
|
||||
let n = -1, inBlk = false, has = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { n++; inBlk = true; has = false; continue; }
|
||||
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { has = true; continue; }
|
||||
if (u.startsWith('END:VTODO') && inBlk) { if (has && targetOrdinal < 0) targetOrdinal = n; inBlk = false; }
|
||||
}
|
||||
}
|
||||
const result = [];
|
||||
let vtodoIdx = -1, inserted = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (!inserted && vtodoIdx === targetOrdinal) { for (const e of extra) result.push(e); inserted = true; }
|
||||
vtodoIdx = -1; result.push(out[i]); continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
const t0 = result.join(crlf);
|
||||
const iv = t0.lastIndexOf('END:VCALENDAR');
|
||||
if (iv > 0) {
|
||||
const m = t0.substring(0, iv) + extra.join(crlf) + crlf + t0.substring(iv);
|
||||
return m.endsWith(crlf) ? m : m + crlf;
|
||||
}
|
||||
for (const e of extra) result.push(e);
|
||||
}
|
||||
const t2 = result.join(crlf);
|
||||
return t2.endsWith(crlf) ? t2 : t2 + crlf;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) { console.log(' ✅', name); pass++; }
|
||||
else { console.log(' ❌', name, extra || ''); fail++; }
|
||||
}
|
||||
function structureIntact(ics) {
|
||||
const i = ics.indexOf('END:VCALENDAR');
|
||||
if (i < 0) return false;
|
||||
return ics.substring(i + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '') === '';
|
||||
}
|
||||
function balanced(ics, tag) {
|
||||
const b = (ics.match(new RegExp('BEGIN:' + tag, 'g')) || []).length;
|
||||
const e = (ics.match(new RegExp('END:' + tag, 'g')) || []).length;
|
||||
return b === e;
|
||||
}
|
||||
|
||||
// 原始:含 PRIORITY / CATEGORIES / X-扩展 —— 这些必须原样保留(防丢数据)
|
||||
const ics1 = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:旧标题\r\nDESCRIPTION:旧备注\r\nDUE:20260105T090000Z\r\nPRIORITY:1\r\nCATEGORIES:家务,生活\r\nX-MY-EXT:hello\r\nSTATUS:NEEDS-ACTION\r\nPERCENT-COMPLETE:0\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
|
||||
const due = new Date(2026, 2, 15, 14, 30, 0).getTime();
|
||||
const r1 = rewriteTodoFields(ics1, 't1', '新标题', '新备注', due);
|
||||
|
||||
ok('标题已更新', r1.includes('SUMMARY:新标题'));
|
||||
ok('旧标题已消失', !r1.includes('SUMMARY:旧标题'));
|
||||
ok('备注已更新', r1.includes('DESCRIPTION:新备注'));
|
||||
ok('截止时间已更新', /DUE:\d{8}T\d{6}/.test(r1) && !r1.includes('DUE:20260105'));
|
||||
ok('⭐ 保留 PRIORITY', r1.includes('PRIORITY:1'));
|
||||
ok('⭐ 保留 CATEGORIES', r1.includes('CATEGORIES:家务,生活') || r1.includes('CATEGORIES:家务\\,生活'));
|
||||
ok('⭐ 保留 X-MY-EXT', r1.includes('X-MY-EXT:hello'));
|
||||
ok('⭐ 保留 STATUS', r1.includes('STATUS:NEEDS-ACTION'));
|
||||
ok('⭐ 结构完整', structureIntact(r1));
|
||||
ok('BEGIN/END VTODO 配对', balanced(r1, 'VTODO'));
|
||||
|
||||
// 清除截止时间
|
||||
const r2 = rewriteTodoFields(ics1, 't1', '旧标题', '旧备注', 0);
|
||||
ok('截止时间可清除', !/DUE:\d{8}/.test(r2));
|
||||
ok('清除后仍保留其他字段', r2.includes('PRIORITY:1'));
|
||||
|
||||
// 特殊字符转义(分号/逗号/换行)
|
||||
const r3 = rewriteTodoFields(ics1, 't1', '标题;含逗号,和换行\n第二行', '', due);
|
||||
ok('⭐ 特殊字符已转义', r3.includes('SUMMARY:标题\\;含逗号\\,和换行\\n第二行'), r3.match(/SUMMARY:[^\r]*/)?.[0]);
|
||||
|
||||
// uid 不匹配 → 原文不动
|
||||
ok('uid 不匹配 → 原文不变', rewriteTodoFields(ics1, 'nope', 'x', 'y', due) === ics1);
|
||||
|
||||
// 缺 SUMMARY/DESCRIPTION 的极简待办 → 补齐
|
||||
const ics4 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t2\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r4 = rewriteTodoFields(ics4, 't2', '补的标题', '补的备注', due);
|
||||
ok('缺字段能补齐', r4.includes('SUMMARY:补的标题') && r4.includes('DESCRIPTION:补的备注') && /DUE:/.test(r4));
|
||||
ok('⭐ 补齐在 VTODO 内', structureIntact(r4) && (() => {
|
||||
const b = r4.substring(r4.indexOf('BEGIN:VTODO'), r4.indexOf('END:VTODO'));
|
||||
return b.includes('SUMMARY:补的标题');
|
||||
})());
|
||||
|
||||
// 多 VTODO 只改目标
|
||||
const ics5 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r5 = rewriteTodoFields(ics5, 'a2', '改后', '', due);
|
||||
ok('多 VTODO:a1 未动', r5.split('UID:a1')[1].split('END:VTODO')[0].includes('SUMMARY:第一个'));
|
||||
ok('多 VTODO:a2 已改', r5.split('UID:a2')[1].split('END:VTODO')[0].includes('SUMMARY:改后'));
|
||||
ok('⭐ 多 VTODO 结构完整', structureIntact(r5));
|
||||
|
||||
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
|
||||
console.log('\n--- 测试1 输出 ---');
|
||||
console.log(r1.replace(/\r\n/g, '\n'));
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,185 @@
|
||||
// 验证待办循环:incForFreq 与 rewriteTodoRecurrence
|
||||
function fmtIcsTime(ms) {
|
||||
const pad = n => n < 10 ? '0' + n : String(n);
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
function freqOfRrule(rrule) {
|
||||
for (const part of rrule.split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i <= 0) continue;
|
||||
if (part.substring(0, i).trim().toUpperCase() === 'FREQ') {
|
||||
return part.substring(i + 1).trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
return 'DAILY';
|
||||
}
|
||||
function incForFreq(freq, fromMs) {
|
||||
const d = new Date(fromMs), f = freq.toUpperCase();
|
||||
if (f === 'DAILY') return fromMs + 86400000;
|
||||
if (f === 'WEEKLY') return fromMs + 7 * 86400000;
|
||||
if (f === 'MONTHLY') {
|
||||
const y = d.getFullYear(), m = d.getMonth() + 1, day = d.getDate();
|
||||
const last = new Date(y, m + 1, 0).getDate();
|
||||
return new Date(y, m, Math.min(day, last), d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
|
||||
}
|
||||
if (f === 'YEARLY') {
|
||||
const y = d.getFullYear() + 1, m = d.getMonth(), day = d.getDate();
|
||||
const last = new Date(y, m + 1, 0).getDate();
|
||||
return new Date(y, m, Math.min(day, last), d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
|
||||
}
|
||||
return fromMs + 86400000;
|
||||
}
|
||||
|
||||
function rewriteTodoRecurrence(ics, uid, rrule, dtstart, dueMs) {
|
||||
const crlf = '\r\n';
|
||||
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
|
||||
const upperOf = s => s.toUpperCase();
|
||||
const hasRule = rrule.trim() !== '';
|
||||
|
||||
let targetBegin = -1, targetEnd = -1, curBegin = -1, curHasUid = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { curBegin = i; curHasUid = false; continue; }
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) curHasUid = true;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) { targetBegin = curBegin; targetEnd = i; }
|
||||
curBegin = -1; curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) return ics;
|
||||
|
||||
const out = [];
|
||||
let sawRule = false, sawStart = false, sawDue = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i], u = upperOf(line);
|
||||
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
|
||||
if (u.startsWith('RRULE')) { sawRule = true; if (hasRule) out.push('RRULE:' + rrule.trim()); continue; }
|
||||
if (u.startsWith('DTSTART')) { sawStart = true; if (hasRule && dtstart > 0) out.push('DTSTART:' + fmtIcsTime(dtstart)); continue; }
|
||||
if (u.startsWith('DUE')) { sawDue = true; if (dueMs > 0) out.push('DUE:' + fmtIcsTime(dueMs)); continue; }
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const extra = [];
|
||||
if (hasRule) {
|
||||
if (!sawRule) extra.push('RRULE:' + rrule.trim());
|
||||
if (!sawStart && dtstart > 0) extra.push('DTSTART:' + fmtIcsTime(dtstart));
|
||||
if (!sawDue && dueMs > 0) extra.push('DUE:' + fmtIcsTime(dueMs));
|
||||
}
|
||||
if (extra.length === 0) { const p = out.join(crlf); return p.endsWith(crlf) ? p : p + crlf; }
|
||||
|
||||
let targetOrdinal = -1;
|
||||
{
|
||||
let n = -1, inBlk = false, has = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { n++; inBlk = true; has = false; continue; }
|
||||
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { has = true; continue; }
|
||||
if (u.startsWith('END:VTODO') && inBlk) { if (has && targetOrdinal < 0) targetOrdinal = n; inBlk = false; }
|
||||
}
|
||||
}
|
||||
const result = [];
|
||||
let vtodoIdx = -1, inserted = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (!inserted && vtodoIdx === targetOrdinal) { for (const e of extra) result.push(e); inserted = true; }
|
||||
result.push(out[i]); continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
const t0 = result.join(crlf);
|
||||
const iv = t0.lastIndexOf('END:VCALENDAR');
|
||||
if (iv > 0) {
|
||||
const m = t0.substring(0, iv) + extra.join(crlf) + crlf + t0.substring(iv);
|
||||
return m.endsWith(crlf) ? m : m + crlf;
|
||||
}
|
||||
for (const e of extra) result.push(e);
|
||||
}
|
||||
const t2 = result.join(crlf);
|
||||
return t2.endsWith(crlf) ? t2 : t2 + crlf;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) { console.log(' ✅', name); pass++; }
|
||||
else { console.log(' ❌', name, extra || ''); fail++; }
|
||||
}
|
||||
function structureIntact(ics) {
|
||||
const i = ics.indexOf('END:VCALENDAR');
|
||||
if (i < 0) return false;
|
||||
return ics.substring(i + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '') === '';
|
||||
}
|
||||
|
||||
// ===== 1. incForFreq 各种边界 =====
|
||||
const d0 = new Date(2026, 0, 31, 9, 0, 0).getTime(); // 2026-01-31 09:00
|
||||
const m1 = new Date(incForFreq('MONTHLY', d0));
|
||||
ok('1/31 + 1月 → 2/28(钳制,不跳到3月)',
|
||||
m1.getMonth() === 1 && m1.getDate() === 28, `${m1.getMonth() + 1}/${m1.getDate()}`);
|
||||
ok(' 时分保持不变(09:00)', m1.getHours() === 9 && m1.getMinutes() === 0);
|
||||
|
||||
const leap = new Date(2024, 1, 29, 9, 0, 0).getTime(); // 2024-02-29
|
||||
const y1 = new Date(incForFreq('YEARLY', leap));
|
||||
ok('2/29 + 1年 → 2/28(次年无29日)', y1.getMonth() === 1 && y1.getDate() === 28,
|
||||
`${y1.getFullYear()}-${y1.getMonth() + 1}-${y1.getDate()}`);
|
||||
|
||||
const w = new Date(incForFreq('WEEKLY', d0));
|
||||
ok('每周 = +7 天', (w.getTime() - d0) === 7 * 86400000);
|
||||
|
||||
const dd = new Date(incForFreq('DAILY', d0));
|
||||
ok('每天 = +1 天', (dd.getTime() - d0) === 86400000);
|
||||
|
||||
// ===== 2. freqOfRrule =====
|
||||
ok('freqOfRrule 取 WEEKLY', freqOfRrule('FREQ=WEEKLY;INTERVAL=2') === 'WEEKLY');
|
||||
ok('freqOfRrule 无序也能取', freqOfRrule('INTERVAL=2;FREQ=DAILY') === 'DAILY');
|
||||
ok('freqOfRrule 空规则回退 DAILY', freqOfRrule('') === 'DAILY');
|
||||
|
||||
// ===== 3. rewriteTodoRecurrence 写规则 =====
|
||||
const ics1 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:浇花\r\nDUE:20260110T090000\r\nSTATUS:NEEDS-ACTION\r\nPRIORITY:5\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const due1 = new Date(2026, 0, 10, 9, 0, 0).getTime();
|
||||
const r1 = rewriteTodoRecurrence(ics1, 't1', 'FREQ=WEEKLY', due1, due1);
|
||||
|
||||
ok('写入 RRULE', r1.includes('RRULE:FREQ=WEEKLY'));
|
||||
ok('⭐ 同时写入 DTSTART(消除客户端歧义)', /DTSTART:\d{8}T\d{6}/.test(r1));
|
||||
ok('DUE 保留', /DUE:\d{8}T\d{6}/.test(r1));
|
||||
ok('⭐ 保留 PRIORITY(不丢字段)', r1.includes('PRIORITY:5'));
|
||||
ok('⭐ 保留 STATUS', r1.includes('STATUS:NEEDS-ACTION'));
|
||||
ok('⭐ 结构完整', structureIntact(r1));
|
||||
|
||||
// ===== 4. 取消循环 =====
|
||||
const ics2 = r1;
|
||||
const r2 = rewriteTodoRecurrence(ics2, 't1', '', 0, due1);
|
||||
ok('取消循环 → RRULE 被删', !r2.includes('RRULE:'));
|
||||
ok('取消循环 → DTSTART 被删', !r2.includes('DTSTART:'));
|
||||
ok('取消循环 → DUE 保留', /DUE:\d{8}/.test(r2));
|
||||
ok('⭐ 取消后结构完整', structureIntact(r2));
|
||||
|
||||
// ===== 5. 推进到下一轮(模拟 completeRecurringRound)=====
|
||||
const base = due1;
|
||||
const nextBase = incForFreq('WEEKLY', base);
|
||||
const r3 = rewriteTodoRecurrence(ics1, 't1', 'FREQ=WEEKLY', nextBase, nextBase);
|
||||
const mDt = r3.match(/DTSTART:(\d{8}T\d{6})/);
|
||||
ok('⭐ 推进后 DTSTART 变成下一周', mDt !== null && mDt[1].startsWith('20260117'),
|
||||
mDt ? mDt[1] : 'no match');
|
||||
ok('推进后 RRULE 不变', r3.includes('RRULE:FREQ=WEEKLY'));
|
||||
|
||||
// ===== 6. 多 VTODO 只改目标 =====
|
||||
const ics6 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nDUE:20260101T090000\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nDUE:20260102T090000\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r6 = rewriteTodoRecurrence(ics6, 'a2', 'FREQ=DAILY', due1, due1);
|
||||
ok('多 VTODO:a1 未加 RRULE', !r6.split('UID:a1')[1].split('END:VTODO')[0].includes('RRULE'));
|
||||
ok('多 VTODO:a2 已加 RRULE', r6.split('UID:a2')[1].split('END:VTODO')[0].includes('RRULE'));
|
||||
ok('⭐ 多 VTODO 结构完整', structureIntact(r6));
|
||||
|
||||
// ===== 7. uid 不匹配 → 原文不动 =====
|
||||
ok('uid 不匹配 → 原文不变', rewriteTodoRecurrence(ics1, 'nope', 'FREQ=DAILY', due1, due1) === ics1);
|
||||
|
||||
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
|
||||
console.log('\n--- 写入循环后的 ICS ---');
|
||||
console.log(r1.replace(/\r\n/g, '\n'));
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,162 @@
|
||||
// 验证 IcsUtil.rewriteTodoCompletion(第二版:按结构定位插入点)
|
||||
function fmtUtcNow() {
|
||||
return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
}
|
||||
|
||||
function rewrite(ics, uid, done) {
|
||||
const crlf = '\r\n';
|
||||
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
|
||||
const upperOf = s => s.toUpperCase();
|
||||
const nowUtc = fmtUtcNow();
|
||||
|
||||
let targetBegin = -1, targetEnd = -1, targetOrdinal = -1;
|
||||
let curBegin = -1, curHasUid = false, blockIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const u = upperOf(lines[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { blockIdx++; curBegin = i; curHasUid = false; continue; }
|
||||
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||
if (lines[i].substring(4).trim() === uid) curHasUid = true;
|
||||
continue;
|
||||
}
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||
targetBegin = curBegin; targetEnd = i; targetOrdinal = blockIdx;
|
||||
}
|
||||
curBegin = -1; curHasUid = false;
|
||||
}
|
||||
}
|
||||
if (targetBegin < 0) return ics;
|
||||
|
||||
const out = [];
|
||||
let sawStatus = false, sawPercent = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i], u = upperOf(line);
|
||||
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
|
||||
if (u.startsWith('STATUS:')) { sawStatus = true; out.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION'); continue; }
|
||||
if (u.startsWith('COMPLETED')) { continue; }
|
||||
if (u.startsWith('PERCENT-COMPLETE')) { sawPercent = true; out.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0'); continue; }
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const extra = [];
|
||||
if (!sawStatus) extra.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
|
||||
if (done) extra.push('COMPLETED:' + nowUtc);
|
||||
if (!sawPercent) extra.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
|
||||
if (extra.length === 0) {
|
||||
const plain = out.join(crlf);
|
||||
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
let vtodoIdx = -1, inserted = false;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const u = upperOf(out[i]);
|
||||
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
|
||||
if (u.startsWith('END:VTODO')) {
|
||||
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||
for (const ex of extra) result.push(ex);
|
||||
inserted = true;
|
||||
}
|
||||
vtodoIdx = -1; result.push(out[i]); continue;
|
||||
}
|
||||
result.push(out[i]);
|
||||
}
|
||||
if (!inserted) {
|
||||
const tail0 = result.join(crlf);
|
||||
const idxVc = tail0.lastIndexOf('END:VCALENDAR');
|
||||
if (idxVc > 0) {
|
||||
const merged = tail0.substring(0, idxVc) + extra.join(crlf) + crlf + tail0.substring(idxVc);
|
||||
return merged.endsWith(crlf) ? merged : merged + crlf;
|
||||
}
|
||||
for (const ex of extra) result.push(ex);
|
||||
}
|
||||
const text2 = result.join(crlf);
|
||||
return text2.endsWith(crlf) ? text2 : text2 + crlf;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function assert(name, cond, detail) {
|
||||
if (cond) { console.log(' ✅', name); pass++; }
|
||||
else { console.log(' ❌', name, detail || ''); fail++; }
|
||||
}
|
||||
|
||||
/** 关键结构校验:任何字段都不能跑到 END:VCALENDAR 之后 */
|
||||
function structureIntact(ics) {
|
||||
const iEnd = ics.indexOf('END:VCALENDAR');
|
||||
if (iEnd < 0) return false;
|
||||
const after = ics.substring(iEnd + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '');
|
||||
return after === ''; // END:VCALENDAR 之后不应有任何内容
|
||||
}
|
||||
function balanced(ics, tag) {
|
||||
const b = (ics.match(new RegExp('BEGIN:' + tag, 'g')) || []).length;
|
||||
const e = (ics.match(new RegExp('END:' + tag, 'g')) || []).length;
|
||||
return b === e;
|
||||
}
|
||||
|
||||
// 1. STATUS 在 UID 之前(触发补字段的路径)
|
||||
const ics1 = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nDTSTAMP:20260101T000000Z\r\nSTATUS:NEEDS-ACTION\r\nSUMMARY:买菜\r\nUID:todo-abc-123\r\nDUE:20260105T090000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r1 = rewrite(ics1, 'todo-abc-123', true);
|
||||
assert('STATUS 在 UID 之前 → COMPLETED', r1.includes('STATUS:COMPLETED'));
|
||||
assert('旧状态消失', !r1.includes('STATUS:NEEDS-ACTION'));
|
||||
assert('有 COMPLETED 时间戳', /COMPLETED:\d{8}T\d{6}Z/.test(r1));
|
||||
assert('补 PERCENT-COMPLETE:100', r1.includes('PERCENT-COMPLETE:100'));
|
||||
assert('保留 SUMMARY', r1.includes('SUMMARY:买菜'));
|
||||
assert('保留 DUE', r1.includes('DUE:20260105T090000Z'));
|
||||
assert('⭐ 结构完整(END:VCALENDAR 后无残留)', structureIntact(r1), JSON.stringify(r1.slice(r1.indexOf('END:VCALENDAR'))));
|
||||
assert('BEGIN/END VTODO 配对', balanced(r1, 'VTODO'));
|
||||
|
||||
// 2. UID 在前 + 已有 STATUS/PERCENT(不触发补字段)
|
||||
const ics2 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:todo-xyz\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:写报告\r\nPRIORITY:1\r\nSTATUS:NEEDS-ACTION\r\nPERCENT-COMPLETE:0\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r2 = rewrite(ics2, 'todo-xyz', true);
|
||||
assert('UID 在前 → COMPLETED', r2.includes('STATUS:COMPLETED'));
|
||||
assert('PERCENT 改 100', r2.includes('PERCENT-COMPLETE:100'));
|
||||
assert('保留 SUMMARY/PRIORITY', r2.includes('SUMMARY:写报告') && r2.includes('PRIORITY:1'));
|
||||
assert('⭐ 结构完整', structureIntact(r2));
|
||||
assert('COMPLETED 时间戳在 VTODO 内部', (() => {
|
||||
const body = r2.substring(r2.indexOf('BEGIN:VTODO'), r2.indexOf('END:VTODO'));
|
||||
return /COMPLETED:\d{8}/.test(body);
|
||||
})());
|
||||
|
||||
// 3. 取消完成
|
||||
const ics3 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:todo-xyz\r\nSTATUS:COMPLETED\r\nCOMPLETED:20260101T000000Z\r\nPERCENT-COMPLETE:100\r\nSUMMARY:写报告\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r3 = rewrite(ics3, 'todo-xyz', false);
|
||||
assert('取消完成 → NEEDS-ACTION', r3.includes('STATUS:NEEDS-ACTION'));
|
||||
assert('PERCENT 回 0', r3.includes('PERCENT-COMPLETE:0'));
|
||||
assert('COMPLETED 行被移除', !/COMPLETED:\d{8}/.test(r3));
|
||||
assert('⭐ 结构完整', structureIntact(r3));
|
||||
|
||||
// 4. uid 不匹配 → 原文不动
|
||||
assert('uid 不匹配 → 原文不变', rewrite(ics1, 'nope', true) === ics1);
|
||||
|
||||
// 5. 多 VTODO 只改目标
|
||||
const ics5 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r5 = rewrite(ics5, 'a2', true);
|
||||
assert('多 VTODO:a1 未动', r5.split('UID:a1')[1].split('END:VTODO')[0].includes('STATUS:NEEDS-ACTION'));
|
||||
assert('多 VTODO:a2 已改', r5.split('UID:a2')[1].split('END:VTODO')[0].includes('STATUS:COMPLETED'));
|
||||
assert('⭐ 多 VTODO 结构完整', structureIntact(r5));
|
||||
assert('两个 VTODO 都配对', balanced(r5, 'VTODO'));
|
||||
|
||||
// 6. 目标 VTODO 缺 STATUS(走"补字段"最极端路径)
|
||||
const ics6 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:bare\r\nSUMMARY:没有状态字段\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
const r6 = rewrite(ics6, 'bare', true);
|
||||
assert('缺 STATUS 时能补上', r6.includes('STATUS:COMPLETED'));
|
||||
assert('⭐ 补在 VTODO 内而非 VCALENDAR 后', structureIntact(r6) && (() => {
|
||||
const body = r6.substring(r6.indexOf('BEGIN:VTODO'), r6.indexOf('END:VTODO'));
|
||||
return body.includes('STATUS:COMPLETED');
|
||||
})());
|
||||
|
||||
// 7. 折行保留
|
||||
const ics7 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:long\r\nSUMMARY:很长的标题\r\n 需要折行\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
|
||||
assert('折行被还原保留', rewrite(ics7, 'long', true).includes('很长的标题需要折行'));
|
||||
|
||||
// 8. 末尾多个空行也不应让结构崩
|
||||
const ics8 = ics1 + '\r\n\r\n';
|
||||
assert('⭐ 尾部空行场景结构完整', structureIntact(rewrite(ics8, 'todo-abc-123', true)));
|
||||
|
||||
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
|
||||
console.log('\n--- 测试1 输出 ---');
|
||||
console.log(r1.replace(/\r\n/g, '\n'));
|
||||
console.log('--- 测试6 输出(最极端:无 STATUS)---');
|
||||
console.log(r6.replace(/\r\n/g, '\n'));
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,65 @@
|
||||
// 验证 preferredDay 机制消除日号漂移
|
||||
function addMonths(y, m, delta) {
|
||||
let mm = m + delta, yy = y;
|
||||
while (mm < 0) { mm += 12; yy--; }
|
||||
while (mm > 11) { mm -= 12; yy++; }
|
||||
return [yy, mm];
|
||||
}
|
||||
function migrate(preferredDay, y, m, delta) {
|
||||
const nm = addMonths(y, m, delta);
|
||||
const lastDay = new Date(nm[0], nm[1] + 1, 0).getDate();
|
||||
return { ms: new Date(nm[0], nm[1], Math.min(preferredDay, lastDay)).getTime(), y: nm[0], m: nm[1] };
|
||||
}
|
||||
function fmt(ms) {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) { console.log(' ✅', name); pass++; }
|
||||
else { console.log(' ❌', name, extra); fail++; }
|
||||
}
|
||||
|
||||
// 1. 偏好 31 号:1/31 → 2/28 → 3/31(回到 31!)
|
||||
let pref = 31, y = 2026, m = 0, cur = new Date(2026, 0, 31).getTime(), log = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = migrate(pref, y, m, 1);
|
||||
cur = r.ms; y = r.y; m = r.m;
|
||||
log.push(fmt(cur));
|
||||
}
|
||||
console.log(' 偏好31号连续翻月:', log.join(' → '));
|
||||
ok('1/31→2/28→3/31→4/30(月底语义保留)',
|
||||
log[0] === '2026-02-28' && log[1] === '2026-03-31' && log[2] === '2026-04-30',
|
||||
log.join(','));
|
||||
ok('第4次翻到5月回到31', log[3] === '2026-05-31', log[3]);
|
||||
|
||||
// 2. 偏好 15 号:一直稳定
|
||||
pref = 15; y = 2026; m = 0; log = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = migrate(pref, y, m, 1);
|
||||
y = r.y; m = r.m; log.push(fmt(r.ms));
|
||||
}
|
||||
ok('偏好15号稳定', log.every(s => s.endsWith('-15')), log.join(','));
|
||||
|
||||
// 3. 用户点 2/28 后偏好变 28,翻到 3 月应是 28 号
|
||||
pref = 28; y = 2026; m = 1;
|
||||
let r = migrate(pref, y, m, 1);
|
||||
ok('点过28号后翻月为3/28', fmt(r.ms) === '2026-03-28', fmt(r.ms));
|
||||
|
||||
// 4. 闰年偏好 31
|
||||
pref = 31; y = 2028; m = 0;
|
||||
r = migrate(pref, y, m, 1);
|
||||
ok('闰年 1/31 → 2/29', fmt(r.ms) === '2028-02-29', fmt(r.ms));
|
||||
r = migrate(pref, 2028, 1, 1);
|
||||
ok('闰年 2/29 → 3/31', fmt(r.ms) === '2028-03-31', fmt(r.ms));
|
||||
|
||||
// 5. 往前翻同样保留
|
||||
pref = 31; y = 2026; m = 2; // 3月
|
||||
r = migrate(pref, y, m, -1);
|
||||
ok('3/31 上月 → 2/28', fmt(r.ms) === '2026-02-28', fmt(r.ms));
|
||||
r = migrate(pref, 2026, 1, -1);
|
||||
ok('2/28 再上月 → 1/31', fmt(r.ms) === '2026-01-31', fmt(r.ms));
|
||||
|
||||
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user