Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37062cc75b | ||
|
|
c39a48e95f | ||
|
|
ffaffff9d6 | ||
|
|
24cd51970c | ||
|
|
09cb0f9664 | ||
|
|
24e33ed5c9 | ||
|
|
6305b4a7f0 | ||
|
|
d8ee2aa2be | ||
|
|
ddaf41fee8 | ||
|
|
2cc3b1ba42 | ||
|
|
22db069bee | ||
|
|
4f19c9482d | ||
|
|
b41eaabc41 | ||
|
|
e92daf5297 | ||
|
|
8ca1eb76e0 | ||
|
|
d49b041a2c | ||
|
|
fa3dbc6157 | ||
|
|
ff62cf7f00 | ||
|
|
788171d1b2 | ||
|
|
9066a12bbc | ||
|
|
98c4b6a96f | ||
|
|
1cc35f147d | ||
|
|
199b6c177f | ||
|
|
63b1a75b03 | ||
|
|
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 | ||
|
|
165740fe9e | ||
|
|
69a4a86a04 | ||
|
|
d7c49db304 | ||
|
|
353473127a |
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"bundleName": "synccalendar.yangyq.net",
|
"bundleName": "synccalendar.yangyq.net",
|
||||||
"vendor": "yangyq",
|
"vendor": "yangyq",
|
||||||
"versionCode": 100,
|
"versionCode": 10200,
|
||||||
"versionName": "0.0.1",
|
"versionName": "0.2.0",
|
||||||
"buildVersion": "1",
|
"buildVersion": "1",
|
||||||
"icon": "$media:layered_image",
|
"icon": "$media:layered_image",
|
||||||
"label": "$string:app_name"
|
"label": "$string:app_name"
|
||||||
|
|||||||
@@ -1,48 +1,350 @@
|
|||||||
# SyncCalendar(同步日历)
|
# SyncCalendar(同步日历)
|
||||||
|
|
||||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||||
|
|
||||||
|
- 应用市场送审版本:**0.1.0** | 仓库当前:**0.1.0**(versionCode 10000)
|
||||||
|
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://feedlite.yangyq.net/?sw=synccalendar>
|
||||||
|
- 开源协议:Apache License 2.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
### CalDAV 同步
|
### CalDAV 同步
|
||||||
- 支持 CalDAV 协议的完整同步:PROPFIND 发现日历本、REPORT 全量拉取、ETag 增量比对、PUT/DELETE 双向推送
|
|
||||||
- 一个账号可挂载多个日历本,按需勾选同步范围,支持在线编辑账号、重选日历本、删除账号
|
|
||||||
- 全链路同步引擎:拉取 → 解析(VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID)→ 本地库落库 → 推送变更
|
|
||||||
|
|
||||||
### 日历视图
|
- 完整协议链路:**PROPFIND** 发现日历本 → **REPORT** 全量拉取 → **ETag** 增量比对 → **PUT / DELETE** 双向推送
|
||||||
- **月 / 周 / 日 / 列表 / 待办** 五种视图自由切换
|
- 一个账号可挂载多个日历本,按需勾选同步范围;支持在线编辑账号、重选日历本、删除账号
|
||||||
- 月视图支持左右滑动翻月、农历显示(初一显示月名)、点击查看当日日程
|
- 解析 **VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID**,重复日程的**单次覆盖实例**也能正确还原
|
||||||
- 重复日程(RRULE)按规则完整展开,支持 DAILY / WEEKLY / MONTHLY / YEARLY 及 INTERVAL、BYDAY、UNTIL、EXDATE 等
|
- 支持 VTODO 待办同步(只读展示,不参与推送)
|
||||||
- 列表视图按天分组、今日置顶,配合 List 虚拟化保证流畅滚动
|
- 「重建日程数据」可强制整表重拉,用于修复提醒丢失等本地数据问题
|
||||||
- VTODO 待办独立展示,未完成 / 已完成分组,勾选框颜色跟随日历本
|
|
||||||
|
### 日历视图与交互
|
||||||
|
|
||||||
|
- **月 / 周 / 列表** 三种视图(顶部标签切换,也支持左右滑动手势);默认视图可在设置里指定
|
||||||
|
- 月视图三页 Swiper 左右翻月、显示**农历**(初一显示月名),点击查看当日日程
|
||||||
|
- 周视图支持按天左右滑动切换日期,跨月自动重载
|
||||||
|
- 列表视图按天分组、今日置顶,配合 List 虚拟化保证长列表流畅
|
||||||
|
- 日程显示方式两种:**竖向时间轴**(按时间刻度看一天的占用与冲突,带当前时间红线)/**列表**(快速扫读,适合长标题)
|
||||||
|
- 重复日程(RRULE)按规则完整展开:DAILY / WEEKLY / MONTHLY / YEARLY 及 INTERVAL、BYDAY、UNTIL、EXDATE 等
|
||||||
- 可选**混合显示系统日历**,并与 DAV 数据自动去重
|
- 可选**混合显示系统日历**,并与 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`),不做单帧直切、左右平移或上下位移
|
||||||
|
|
||||||
|
### 设置项一览
|
||||||
|
|
||||||
|
系统日历处理方式(仅显示 / 备份到 CalDAV)· 镜像到系统日历 · 选择要镜像的日历本 · 回写开关 · 默认视图 · 日程显示方式 · 自动同步间隔 · 后台自动同步 · 通知权限 · 混合显示系统日历 · 日历本只读 / 静音 · 重建日程数据 · 重置账号库 · 软件特性 · 使用帮助 · 隐私政策与用户协议
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 隐私
|
||||||
|
|
||||||
|
我们遵循"**数据自持、最小必要**"原则:
|
||||||
|
|
||||||
|
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机,且以 **AES-256-GCM** 加密存储,密钥由系统 HUKS/TEE 保管、不可导出)
|
||||||
|
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
|
||||||
|
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
|
||||||
|
- **不申请定位权限**:本应用从不读取设备实时位置。「一键导航」所需的坐标由系统地理编码服务按**你填写的地址文本**换算,故 `module.json5` 中不含 `LOCATION` / `APPROXIMATELY_LOCATION`
|
||||||
|
- 地点关键词检索由 HarmonyOS 系统能力**华为地图服务**(Map Kit / Site Kit)提供,仅用于检索,不用于广告、画像或统计分析
|
||||||
|
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
||||||
|
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🛠 技术要点
|
## 🛠 技术要点
|
||||||
|
|
||||||
- **架构**:SyncEngine 同步引擎 + DavClient(HTTP/WebDAV)+ EventDb(relationalStore 本地库)+ CalendarDataService(多源合并展示)
|
- **架构**:`SyncEngine` 同步引擎 + `DavClient`(HTTP / WebDAV)+ `EventDb`(relationalStore 本地库)+ `CalendarDataService`(多源合并展示)
|
||||||
- **服务卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动卡片渲染
|
- **镜像**:`SystemCalendarMirror`(出站写入)+ `MirrorInbound`(入站回写)+ `MirrorSnapshot`(变更快照)
|
||||||
- **农历算法**:1901–2099 压缩表推算
|
- **后台调度**:`BackgroundSyncRunner`(后台同步唯一执行体)+ `BackgroundSyncService`(延迟任务/短时任务)+ `NetworkSyncWatcher`(网络恢复补同步)
|
||||||
- **权限**:INTERNET / READ_CALENDAR / WRITE_CALENDAR / PUBLISH_AGENT_REMINDER
|
- **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
|
||||||
|
- **农历**:`LunarUtil`,1901–2099 压缩表推算
|
||||||
|
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
|
||||||
|
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER / LOCATION(仅地点导航时)
|
||||||
|
|
||||||
## 📦 快速开始
|
### 目录结构
|
||||||
|
|
||||||
1. 使用 DevEco Studio 打开项目,连接鸿蒙真机或模拟器
|
```
|
||||||
2. 编译安装后进入「≡ → 账号管理」,添加你的 CalDAV 服务器地址、用户名和密码
|
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. 选择要同步的日历本,等待首次同步完成
|
3. 选择要同步的日历本,等待首次同步完成
|
||||||
4. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
|
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[] = [];
|
calendarColors: string[] = [];
|
||||||
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
|
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
|
||||||
calendarWritable: string[] = [];
|
calendarWritable: string[] = [];
|
||||||
|
/**
|
||||||
|
* ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应:
|
||||||
|
* 'event' 纯日历本(只有 VEVENT)
|
||||||
|
* 'todo' 纯待办本(只有 VTODO)
|
||||||
|
* 'both' 混合本
|
||||||
|
* 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容)
|
||||||
|
*/
|
||||||
|
calendarComps: string[] = [];
|
||||||
itemCount: number = 0;
|
itemCount: number = 0;
|
||||||
lastSyncTime: string = '';
|
lastSyncTime: string = '';
|
||||||
}
|
}
|
||||||
@@ -474,6 +482,9 @@ export class AccountStore {
|
|||||||
if (parts.length >= 12) {
|
if (parts.length >= 12) {
|
||||||
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
|
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
|
||||||
}
|
}
|
||||||
|
if (parts.length >= 13) {
|
||||||
|
acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';');
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +576,7 @@ export class AccountStore {
|
|||||||
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
|
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
|
||||||
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
|
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
|
||||||
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : [];
|
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : [];
|
||||||
|
a.calendarComps = o['calendarComps'] !== undefined ? o['calendarComps'] as string[] : [];
|
||||||
a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
|
a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
|
||||||
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
|
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
|
||||||
if (a.id === '') {
|
if (a.id === '') {
|
||||||
@@ -699,4 +711,45 @@ export class AccountStore {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。
|
||||||
|
*
|
||||||
|
* 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`,
|
||||||
|
* 于是调用方无法从返回值区分两种情况:
|
||||||
|
* ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**);
|
||||||
|
* ② 密文存在但解不开,或读取过程抛错(临时故障)。
|
||||||
|
*
|
||||||
|
* 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理,
|
||||||
|
* 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。
|
||||||
|
*
|
||||||
|
* 判定口径(三者同时成立才算"确认空白"):
|
||||||
|
* - 偏好里**没有**账号密文(`data_b64` 为空);
|
||||||
|
* - 旧版明文存储也没有账号;
|
||||||
|
* - 读取过程没有抛异常。
|
||||||
|
*
|
||||||
|
* @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过
|
||||||
|
*/
|
||||||
|
static async isEmptyVaultConfirmed(context: common.Context): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences = await Vault.prefs(context);
|
||||||
|
const data: string = await store.get(VAULT_DATA, '') as string;
|
||||||
|
if (data !== '') {
|
||||||
|
// 有密文:只有能解出"0 个账号"才算确认空白;解不开一律视为拿不准
|
||||||
|
const plain: string | null = await Vault.decrypt(context, data);
|
||||||
|
if (plain === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const arr: Array<Record<string, Object>> = JSON.parse(plain) as Array<Record<string, Object>>;
|
||||||
|
return arr.length === 0;
|
||||||
|
}
|
||||||
|
// 无密文:再看旧版明文存储是否还有残留账号
|
||||||
|
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
|
||||||
|
return legacy === null || legacy.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
console.error(`[AccountStore] isEmptyVaultConfirmed 判定失败(按"拿不准"处理): ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,32 @@ export class AppSettings {
|
|||||||
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
|
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
|
||||||
private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync';
|
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_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_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
|
||||||
private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey
|
private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey
|
||||||
private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳
|
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_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
|
||||||
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
|
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_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'; // 是否已同意隐私政策与用户协议
|
private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议
|
||||||
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
|
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
|
||||||
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**——
|
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**——
|
||||||
@@ -26,8 +45,13 @@ export class AppSettings {
|
|||||||
|
|
||||||
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
|
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
|
||||||
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
|
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
|
||||||
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / LOCATION / APPROXIMATELY_LOCATION(均前台),
|
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(均前台)。
|
||||||
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)与"添加/修改/删除日历活动"(WRITE_CALENDAR)。
|
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)。
|
||||||
|
// ⚠️ 0.1.0 起已**移除 LOCATION / APPROXIMATELY_LOCATION**:实测本 SDK(API 26)的
|
||||||
|
// `geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,
|
||||||
|
// 地理编码不需要定位权限;且应用从不调用 getCurrentLocation,本就不读实时位置。
|
||||||
|
// ⇒ 隐私政策/隐私标签里都**不得**再声称"读取设备精确位置或大致位置"。
|
||||||
|
// ⇒ 若将来真要用实时定位,必须同时改 module.json5、本注释、upa.html 与 AGC 隐私标签。
|
||||||
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
|
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
|
||||||
/** 隐私政策网址 */
|
/** 隐私政策网址 */
|
||||||
static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html';
|
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 FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html';
|
||||||
/** 使用帮助网址 */
|
/** 使用帮助网址 */
|
||||||
static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.html';
|
static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.html';
|
||||||
|
/**
|
||||||
|
* ⭐ 问题反馈网址。
|
||||||
|
*
|
||||||
|
* 换到 FeedLite(多站点共用一套反馈平台),`?sw=` 指定本项目站点标识。
|
||||||
|
* 旧地址 synccalendar.yangyq.net/feedback/ 是自建 PHP 论坛,
|
||||||
|
* 里面还有 3 条历史反馈(含一条完整 PR 贡献),服务暂不删除。
|
||||||
|
*/
|
||||||
|
static readonly FEEDBACK_URL: string = 'https://feedlite.yangyq.net/?sw=synccalendar';
|
||||||
/** 文档标题(内置浏览器标题栏用) */
|
/** 文档标题(内置浏览器标题栏用) */
|
||||||
static readonly PRIVACY_TITLE: string = '隐私政策';
|
static readonly PRIVACY_TITLE: string = '隐私政策';
|
||||||
static readonly AGREEMENT_TITLE: string = '用户服务协议';
|
static readonly AGREEMENT_TITLE: string = '用户服务协议';
|
||||||
static readonly FEATURES_TITLE: string = '软件特性';
|
static readonly FEATURES_TITLE: string = '软件特性';
|
||||||
static readonly HELP_TITLE: string = '使用帮助';
|
static readonly HELP_TITLE: string = '使用帮助';
|
||||||
|
static readonly FEEDBACK_TITLE: string = '问题反馈';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
|
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
|
||||||
@@ -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 时期)落库的残缺数据。
|
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
|
||||||
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
|
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
|
||||||
@@ -286,9 +515,14 @@ export class AppSettings {
|
|||||||
if (muted2.length !== muted.length) {
|
if (muted2.length !== muted.length) {
|
||||||
await AppSettings.setMutedReminderKeys(context, muted2);
|
await AppSettings.setMutedReminderKeys(context, muted2);
|
||||||
}
|
}
|
||||||
const backupKey: string = await AppSettings.getBackupCalKey(context);
|
// 备份目标指向该账号名下某个本 → 目标必失效:清空目标**并关闭备份功能**
|
||||||
if (backupKey.startsWith(`${accId}_`)) {
|
// (只清空目标会让"备份到 CalDAV"开关停在开着却什么都不做的状态,状态与实际不符)
|
||||||
await AppSettings.setBackupCalKey(context, '');
|
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) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
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> {
|
static async getBackupCalKey(context: common.Context): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const store: preferences.Preferences =
|
const store: preferences.Preferences =
|
||||||
@@ -330,18 +569,49 @@ export class AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 【已废弃】写老键。新代码请用 setBackupBookId()。 */
|
||||||
static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> {
|
static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const store: preferences.Preferences =
|
const store: preferences.Preferences =
|
||||||
await preferences.getPreferences(context, AppSettings.STORE);
|
await preferences.getPreferences(context, AppSettings.STORE);
|
||||||
await store.put(AppSettings.KEY_BACKUP_KEY, calKey);
|
await store.put(AppSettings.KEY_BACKUP_KEY, calKey);
|
||||||
await store.flush();
|
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) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
console.error(`保存备份目标失败: ${e.message}`);
|
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> {
|
static async getBackgroundSync(context: common.Context): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -365,14 +635,18 @@ export class AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否混合显示系统日历日程(默认开) */
|
/**
|
||||||
|
* 是否混合显示系统日历日程。
|
||||||
|
* ⚠️ 默认**关闭**:读取系统日历需要"读取全部日程"敏感权限,按《审核指南》7.17
|
||||||
|
* 只能在用户主动打开该开关时才申请,因此默认值必须与实际授权状态一致(无授权 = 关)。
|
||||||
|
*/
|
||||||
static async getShowSystemCalendar(context: common.Context): Promise<boolean> {
|
static async getShowSystemCalendar(context: common.Context): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const store: preferences.Preferences =
|
const store: preferences.Preferences =
|
||||||
await preferences.getPreferences(context, AppSettings.STORE);
|
await preferences.getPreferences(context, AppSettings.STORE);
|
||||||
return await store.get(AppSettings.KEY_SHOW_SYSTEM, true) as boolean;
|
return await store.get(AppSettings.KEY_SHOW_SYSTEM, false) as boolean;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,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> {
|
static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const store: preferences.Preferences =
|
const store: preferences.Preferences =
|
||||||
await preferences.getPreferences(context, AppSettings.STORE);
|
await preferences.getPreferences(context, AppSettings.STORE);
|
||||||
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number;
|
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 15) as number;
|
||||||
return v > 0 ? v : 1;
|
return v > 0 ? v : 15;
|
||||||
} catch (err) {
|
} 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
|
// entry/src/main/ets/common/BackgroundSyncService.ets
|
||||||
// 后台同步管理:
|
// ⭐ 后台同步调度(2026-10-10 重构,依据《定时同步方案调研.md》)
|
||||||
// 1) 长时任务(DATA_TRANSFER):进程不被挂起,前台定时器在后台继续跑(需用户在设置中开启)
|
//
|
||||||
// 2) 延迟任务(workScheduler):App 被杀/重启后由系统按条件拉起兜底同步(最小周期 20 分钟)
|
// 为什么不再用长时任务(重要):
|
||||||
|
// 旧版申请 `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 { backgroundTaskManager } from '@kit.BackgroundTasksKit';
|
||||||
import { wantAgent, common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { workScheduler } from '@kit.BackgroundTasksKit';
|
import { workScheduler } from '@kit.BackgroundTasksKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { AppSettings } from './AppSettings';
|
import { AppSettings } from './AppSettings';
|
||||||
import { LogUtil } from './LogUtil';
|
import { LogUtil } from './LogUtil';
|
||||||
|
|
||||||
const WORK_ID: number = 1001;
|
const WORK_ID: number = 1001;
|
||||||
|
/** 延迟任务周期:2 小时(系统"活跃分组"下限,不可再短) */
|
||||||
|
const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export class BackgroundSyncService {
|
export class BackgroundSyncService {
|
||||||
/** 申请长时任务(DATA_TRANSFER):申请成功后进程在后台不被挂起,失败抛错由调用方提示用户 */
|
/** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */
|
||||||
static async startContinuousTask(context: common.UIAbilityContext): Promise<void> {
|
private static transientId: number = 0;
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册延迟任务兜底同步:无论长时任务开关与否都注册。
|
* 注册延迟任务兜底同步:周期 2 小时、重启后自动恢复(isPersisted)。
|
||||||
* 条件:有任意网络即执行;周期重复;重启后自动恢复。
|
* 条件:有任意网络即执行。
|
||||||
* 注:repeatCycleTime 各版本下限不同(20 分钟 ~ 2 小时),先用 20 分钟,
|
* ⚠️ 周期**不得低于 2 小时**(系统按"应用活跃分组"压制:活跃≥2h / 经常使用≥4h / 常用≥24h /
|
||||||
* 被系统拒绝(9700003 条件无效)时降级为 2 小时再试。
|
* 极少使用≥48h / 受限·从未使用禁止调度)。请求更短不报错但会被压制,形同虚设。
|
||||||
*/
|
*/
|
||||||
static scheduleDeferredSync(context: common.UIAbilityContext): void {
|
static scheduleDeferredSync(context: common.UIAbilityContext): void {
|
||||||
const build = (cycleMs: number): workScheduler.WorkInfo => {
|
const info: workScheduler.WorkInfo = {
|
||||||
const info: workScheduler.WorkInfo = {
|
workId: WORK_ID,
|
||||||
workId: WORK_ID,
|
bundleName: context.abilityInfo.bundleName,
|
||||||
bundleName: context.abilityInfo.bundleName,
|
abilityName: 'SyncWorkAbility',
|
||||||
abilityName: 'SyncWorkAbility',
|
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
||||||
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
isRepeat: true,
|
||||||
isRepeat: true,
|
isPersisted: true,
|
||||||
isPersisted: true,
|
repeatCycleTime: DEFERRED_CYCLE_MS
|
||||||
repeatCycleTime: cycleMs
|
|
||||||
};
|
|
||||||
return info;
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
workScheduler.startWork(build(20 * 60 * 1000));
|
workScheduler.startWork(info);
|
||||||
LogUtil.write('延迟任务已注册:20 分钟兜底同步');
|
LogUtil.write('延迟任务已注册:2 小时兜底同步(系统活跃分组下限)');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
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}`);
|
LogUtil.write(`延迟任务注册失败: ${e.code} - ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App 启动时统一入口:注册延迟任务 + 按设置恢复长时任务(恢复失败静默,不影响启动) */
|
/** 取消延迟任务(用户关闭"后台自动同步"时调用) */
|
||||||
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> {
|
static cancelDeferredSync(context: common.UIAbilityContext): void {
|
||||||
BackgroundSyncService.scheduleDeferredSync(context);
|
const info: workScheduler.WorkInfo = {
|
||||||
const enabled: boolean = await AppSettings.getBackgroundSync(context);
|
workId: WORK_ID,
|
||||||
if (enabled) {
|
bundleName: context.abilityInfo.bundleName,
|
||||||
try {
|
abilityName: 'SyncWorkAbility',
|
||||||
await BackgroundSyncService.startContinuousTask(context);
|
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
||||||
} catch (err) {
|
isRepeat: true,
|
||||||
// 启动时恢复失败不阻塞 App:下次开关切换或重启再试
|
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> {
|
* ⭐ 退到后台时申请**短时任务**:让"刚退后台的这一次同步"能跑完。
|
||||||
|
* 必须在**前台或退后台 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) {
|
if (enabled) {
|
||||||
// 先申请任务,成功才落盘设置(失败时设置页回滚开关)
|
BackgroundSyncService.scheduleDeferredSync(context);
|
||||||
await BackgroundSyncService.startContinuousTask(context);
|
|
||||||
await AppSettings.setBackgroundSync(context, true);
|
|
||||||
} else {
|
} else {
|
||||||
await AppSettings.setBackgroundSync(context, false);
|
LogUtil.write('后台自动同步未开启:不注册延迟任务');
|
||||||
try {
|
}
|
||||||
await BackgroundSyncService.stopContinuousTask(context);
|
}
|
||||||
} catch (err) {
|
|
||||||
// 停止失败(如任务本就不在运行)不影响设置保存
|
/** 设置页开关切换:控制"后台自动同步"(延迟任务注册与否) */
|
||||||
}
|
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
|
||||||
|
await AppSettings.setBackgroundSync(context, enabled);
|
||||||
|
if (enabled) {
|
||||||
|
BackgroundSyncService.scheduleDeferredSync(context);
|
||||||
|
} else {
|
||||||
|
BackgroundSyncService.cancelDeferredSync(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// entry/src/main/ets/common/CalendarDataService.ets
|
// entry/src/main/ets/common/CalendarDataService.ets
|
||||||
// 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示)
|
// 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示)
|
||||||
import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit';
|
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { calendarManager } from '@kit.CalendarKit';
|
import { calendarManager } from '@kit.CalendarKit';
|
||||||
import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore';
|
import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore';
|
||||||
@@ -25,19 +25,61 @@ export class DisplayEvent {
|
|||||||
color: string = '#007DFF';
|
color: string = '#007DFF';
|
||||||
completed: boolean = false; // 仅待办使用
|
completed: boolean = false; // 仅待办使用
|
||||||
recurring: boolean = false; // 是否为重复日程展开出的发生
|
recurring: boolean = false; // 是否为重复日程展开出的发生
|
||||||
|
/** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同:
|
||||||
|
* recurring 只说"重不重复",这里带的是"多久重复一次"。 */
|
||||||
|
recurRule: string = '';
|
||||||
|
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
|
||||||
|
dtstart: number = 0;
|
||||||
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CalendarDataService {
|
export class CalendarDataService {
|
||||||
/** 申请读取全部日历权限(混合展示系统日历需要) */
|
/** 本功能依赖的日历权限:读取本应用日历 + 读取全部日历(即"读取全部日程") */
|
||||||
static async ensureSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> {
|
private static readonly CALENDAR_PERMISSIONS: Permissions[] = [
|
||||||
|
'ohos.permission.READ_CALENDAR',
|
||||||
|
'ohos.permission.READ_WHOLE_CALENDAR'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已获得"读取全部日程"权限。
|
||||||
|
* **只检查、绝不弹窗** —— 供界面判断状态、决定是否需要引导用户主动授权。
|
||||||
|
* 注意:checkAccessToken 只反映本应用授权状态,不会触发任何系统弹窗。
|
||||||
|
*/
|
||||||
|
static async hasSystemCalendarPermission(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const atManager = abilityAccessCtrl.createAtManager();
|
const atManager = abilityAccessCtrl.createAtManager();
|
||||||
const permissions: Permissions[] = [
|
const info = bundleManager.getBundleInfoForSelfSync(
|
||||||
'ohos.permission.READ_CALENDAR',
|
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
|
||||||
'ohos.permission.READ_WHOLE_CALENDAR'
|
const tokenId: number = info.appInfo.accessTokenId;
|
||||||
];
|
for (const p of CalendarDataService.CALENDAR_PERMISSIONS) {
|
||||||
const result = await atManager.requestPermissionsFromUser(context, permissions);
|
const status = await atManager.checkAccessToken(tokenId, p);
|
||||||
|
if (status !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请"读取全部日程"权限(READ_CALENDAR + READ_WHOLE_CALENDAR)。
|
||||||
|
*
|
||||||
|
* ⚠️ 合规约束(《审核指南》7.17):**只能在用户主动点击使用对应功能时调用**,
|
||||||
|
* 例如在设置页打开"混合显示系统日历"开关、把系统日历模式切到"备份到 CalDAV 日历本"。
|
||||||
|
* 严禁在 App 启动 / 首次打开 / 未同意隐私政策等用户未主动操作的时机调用。
|
||||||
|
*
|
||||||
|
* 已授权时直接返回 true,不会再弹窗(先 checkAccessToken,避免多余弹窗)。
|
||||||
|
*/
|
||||||
|
static async requestSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> {
|
||||||
|
if (await CalendarDataService.hasSystemCalendarPermission()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const atManager = abilityAccessCtrl.createAtManager();
|
||||||
|
const result = await atManager.requestPermissionsFromUser(
|
||||||
|
context, CalendarDataService.CALENDAR_PERMISSIONS);
|
||||||
for (const r of result.authResults) {
|
for (const r of result.authResults) {
|
||||||
if (r !== 0) {
|
if (r !== 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -165,12 +207,14 @@ export class CalendarDataService {
|
|||||||
}
|
}
|
||||||
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。
|
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。
|
||||||
// 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本);
|
// 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本);
|
||||||
// ②只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
|
// ②键必须用 **recurrenceId(原始发生时刻)**——覆盖行的 startTime 是"被改到的新时间",
|
||||||
|
// 用它做键时主事件在原时间的那次发生不会被排除 → 同一日程显示两条(幽灵日程);
|
||||||
|
// ③只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
|
||||||
// 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示"
|
// 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示"
|
||||||
const overrideKeys: string[] = [];
|
const overrideKeys: string[] = [];
|
||||||
for (const e of rows) {
|
for (const e of rows) {
|
||||||
if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) {
|
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;
|
let occTotal: number = 0;
|
||||||
@@ -195,7 +239,7 @@ export class CalendarDataService {
|
|||||||
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
|
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
|
||||||
if (occs.length > 0) {
|
if (occs.length > 0) {
|
||||||
times = occs.filter((occ: number): boolean =>
|
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) {
|
for (const occ of times) {
|
||||||
@@ -302,6 +346,11 @@ export class CalendarDataService {
|
|||||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||||
return found !== undefined ? found.color : '#9AA0A6';
|
return found !== undefined ? found.color : '#9AA0A6';
|
||||||
};
|
};
|
||||||
|
// ⭐ 待办按日历本分组显示需要"本名";原来只带 calKey,分组标题无法显示
|
||||||
|
const nameOf = (key: string): string => {
|
||||||
|
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||||
|
return found !== undefined ? found.name : '';
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const rows: LocalEvent[] = await EventDb.queryTodos(context);
|
const rows: LocalEvent[] = await EventDb.queryTodos(context);
|
||||||
for (const e of rows) {
|
for (const e of rows) {
|
||||||
@@ -318,8 +367,12 @@ export class CalendarDataService {
|
|||||||
d.endTime = e.endTime;
|
d.endTime = e.endTime;
|
||||||
d.isAllDay = e.isAllDay;
|
d.isAllDay = e.isAllDay;
|
||||||
d.calKey = e.calKey;
|
d.calKey = e.calKey;
|
||||||
|
d.calName = nameOf(e.calKey);
|
||||||
d.color = colorOf(e.calKey);
|
d.color = colorOf(e.calKey);
|
||||||
d.completed = e.completed;
|
d.completed = e.completed;
|
||||||
|
d.recurRule = e.rrule;
|
||||||
|
d.dtstart = e.dtstart;
|
||||||
|
d.recurring = e.rrule !== '';
|
||||||
result.push(d);
|
result.push(d);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export class DavColorEntry {
|
|||||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||||||
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
|
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
|
||||||
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
|
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
|
||||||
|
/**
|
||||||
|
* ⭐ COMP_SET 该集合支持的组件类型(来自 supported-calendar-component-set)。
|
||||||
|
* 'event' = 纯日历本(只有 VEVENT)
|
||||||
|
* 'todo' = 纯待办本(只有 VTODO)
|
||||||
|
* 'both' = 混合本(两者都有)
|
||||||
|
* 'unknown' = 服务器未声明该属性(老实现常见)→ 按日历对待,保持兼容
|
||||||
|
*/
|
||||||
|
comps: string = 'unknown';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
|
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
|
||||||
@@ -25,6 +34,8 @@ export class DavCalendarEntry {
|
|||||||
href: string = ''; // 完整 URL(已补全域名)
|
href: string = ''; // 完整 URL(已补全域名)
|
||||||
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
|
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
|
||||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||||||
|
/** ⭐ COMP_SET 'event' 纯日历 / 'todo' 纯待办 / 'both' 混合 / 'unknown' 服务器未声明 */
|
||||||
|
comps: string = 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +47,20 @@ export class DavCalendarDiscovery {
|
|||||||
entries: DavCalendarEntry[] = [];
|
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 {
|
export class DavClient {
|
||||||
/**
|
/**
|
||||||
* PROPFIND 拉取某路径下所有集合的 calendar-color
|
* PROPFIND 拉取某路径下所有集合的 calendar-color
|
||||||
@@ -47,6 +72,8 @@ export class DavClient {
|
|||||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||||
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||||
'<d:current-user-privilege-set/>' +
|
'<d:current-user-privilege-set/>' +
|
||||||
|
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
|
||||||
|
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||||||
'</d:prop></d:propfind>';
|
'</d:prop></d:propfind>';
|
||||||
const httpRequest = http.createHttp();
|
const httpRequest = http.createHttp();
|
||||||
try {
|
try {
|
||||||
@@ -90,6 +117,7 @@ export class DavClient {
|
|||||||
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
|
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
|
||||||
}
|
}
|
||||||
entry.color = color;
|
entry.color = color;
|
||||||
|
entry.comps = DavClient.parseComps(block);
|
||||||
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
|
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
|
||||||
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
|
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
|
||||||
if (privSet !== '') {
|
if (privSet !== '') {
|
||||||
@@ -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)。
|
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
|
||||||
*
|
*
|
||||||
@@ -122,6 +177,7 @@ export class DavClient {
|
|||||||
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
||||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||||
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||||
|
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||||||
'</d:prop></d:propfind>';
|
'</d:prop></d:propfind>';
|
||||||
const httpRequest = http.createHttp();
|
const httpRequest = http.createHttp();
|
||||||
const out = new DavCalendarDiscovery();
|
const out = new DavCalendarDiscovery();
|
||||||
@@ -183,6 +239,9 @@ export class DavClient {
|
|||||||
entry.href = href.startsWith('http') ? href : origin + href;
|
entry.href = href.startsWith('http') ? href : origin + href;
|
||||||
entry.displayName = name;
|
entry.displayName = name;
|
||||||
entry.color = color;
|
entry.color = color;
|
||||||
|
entry.comps = DavClient.parseComps(block);
|
||||||
|
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
|
||||||
|
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
|
||||||
out.entries.push(entry);
|
out.entries.push(entry);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -236,6 +295,37 @@ export class DavClient {
|
|||||||
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 拉取单个资源原文,**失败抛错**(与 getRaw 相反)。
|
||||||
|
*
|
||||||
|
* 为什么需要严格版:待办写回要"先 GET 原文、改字段、再 PUT"。
|
||||||
|
* 若 GET 失败却拿到一个 `"HTTP 404"` 之类的字符串并当 ICS 内容 PUT 回去,
|
||||||
|
* 会把服务器上那条待办**覆盖成一句错误信息**(数据破坏)。
|
||||||
|
* 原 getRaw 保留原行为不动(诊断用,已有调用方)。
|
||||||
|
*/
|
||||||
|
static async getRawStrict(url: string, auth: string): Promise<string> {
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
try {
|
||||||
|
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||||
|
method: http.RequestMethod.GET,
|
||||||
|
header: {
|
||||||
|
'Authorization': auth,
|
||||||
|
'Content-Type': 'text/calendar; charset=utf-8',
|
||||||
|
'User-Agent': 'SyncCalendar/1.0'
|
||||||
|
},
|
||||||
|
connectTimeout: 10000,
|
||||||
|
readTimeout: 20000,
|
||||||
|
expectDataType: http.HttpDataType.STRING
|
||||||
|
});
|
||||||
|
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||||
|
throw new DavHttpError(resp.responseCode, `拉取待办原文失败,服务器返回 ${resp.responseCode}`);
|
||||||
|
}
|
||||||
|
return resp.result as string;
|
||||||
|
} finally {
|
||||||
|
httpRequest.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
||||||
static async getRaw(url: string, auth: string): Promise<string> {
|
static async getRaw(url: string, auth: string): Promise<string> {
|
||||||
const httpRequest = http.createHttp();
|
const httpRequest = http.createHttp();
|
||||||
@@ -346,7 +436,7 @@ export class DavClient {
|
|||||||
});
|
});
|
||||||
LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`);
|
LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`);
|
||||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
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 xml: string = resp.result as string;
|
||||||
const items: RemoteItem[] = [];
|
const items: RemoteItem[] = [];
|
||||||
@@ -403,11 +493,11 @@ export class DavClient {
|
|||||||
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
|
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
|
||||||
if (resp.responseCode === 401) {
|
if (resp.responseCode === 401) {
|
||||||
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
|
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
|
||||||
throw new Error('服务器拒绝凭据(401)');
|
throw new DavHttpError(401, '服务器拒绝凭据(401)');
|
||||||
}
|
}
|
||||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||||
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`);
|
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;
|
const xml: string = resp.result as string;
|
||||||
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
|
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
|
||||||
@@ -459,7 +549,7 @@ export class DavClient {
|
|||||||
console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
|
console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
|
||||||
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
|
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
|
||||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
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>;
|
const headers = resp.header as Record<string, string>;
|
||||||
if (headers !== undefined && headers !== null) {
|
if (headers !== undefined && headers !== null) {
|
||||||
@@ -489,7 +579,7 @@ export class DavClient {
|
|||||||
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
|
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
|
||||||
// 404 视为已删除,成功
|
// 404 视为已删除,成功
|
||||||
if ((resp.responseCode < 200 || resp.responseCode >= 300) && 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 {
|
} finally {
|
||||||
httpRequest.destroy();
|
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;
|
||||||
|
}
|
||||||
@@ -52,5 +52,7 @@ export struct DocViewer {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
.backgroundColor($r('app.color.page_bg'))
|
.backgroundColor($r('app.color.page_bg'))
|
||||||
|
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制,Web 与标题栏仍在安全区内)
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ export class LocalEvent {
|
|||||||
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
|
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
|
||||||
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
|
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
|
||||||
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
|
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
|
||||||
|
/** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。
|
||||||
|
* 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**,
|
||||||
|
* 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */
|
||||||
|
dtstart: number = 0;
|
||||||
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
|
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
|
||||||
|
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
|
||||||
|
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
|
||||||
|
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
|
||||||
|
recurrenceId: number = 0;
|
||||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
|
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
|
||||||
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
|
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
|
||||||
}
|
}
|
||||||
@@ -43,7 +51,9 @@ export class RemoteEvent {
|
|||||||
isTodo: boolean = false; // VTODO 待办
|
isTodo: boolean = false; // VTODO 待办
|
||||||
completed: boolean = false; // VTODO STATUS:COMPLETED
|
completed: boolean = false; // VTODO STATUS:COMPLETED
|
||||||
rrule: string = ''; // 原始 RRULE
|
rrule: string = ''; // 原始 RRULE
|
||||||
|
dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开
|
||||||
exdate: string = ''; // 原始 EXDATE(分号分隔)
|
exdate: string = ''; // 原始 EXDATE(分号分隔)
|
||||||
|
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
|
||||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
|
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
|
||||||
reminders: number[] = []; // 全部提醒提前分钟数(多选)
|
reminders: number[] = []; // 全部提醒提前分钟数(多选)
|
||||||
}
|
}
|
||||||
@@ -51,6 +61,36 @@ export class RemoteEvent {
|
|||||||
export class EventDb {
|
export class EventDb {
|
||||||
private static db: relationalStore.RdbStore | null = null;
|
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> {
|
static async getDb(context: common.Context): Promise<relationalStore.RdbStore> {
|
||||||
if (EventDb.db !== null) {
|
if (EventDb.db !== null) {
|
||||||
return EventDb.db;
|
return EventDb.db;
|
||||||
@@ -89,6 +129,11 @@ export class EventDb {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 列已存在
|
// 列已存在
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
|
||||||
|
} catch (err) {
|
||||||
|
// 列已存在
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
|
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -104,6 +149,12 @@ export class EventDb {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 列已存在
|
// 列已存在
|
||||||
}
|
}
|
||||||
|
// ⭐ 覆盖实例的"原始发生时刻"(RECURRENCE-ID)。老库升级后该列一律为 0,需要一次全量重拉回填
|
||||||
|
try {
|
||||||
|
await store.executeSql('ALTER TABLE events ADD COLUMN recurrence_id INTEGER DEFAULT 0');
|
||||||
|
} catch (err) {
|
||||||
|
// 列已存在
|
||||||
|
}
|
||||||
EventDb.db = store;
|
EventDb.db = store;
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
@@ -124,10 +175,23 @@ export class EventDb {
|
|||||||
return EventDb.normalizeReminders(list).join(',');
|
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 {
|
private static fromRow(rs: relationalStore.ResultSet): LocalEvent {
|
||||||
const e = new LocalEvent();
|
const e = new LocalEvent();
|
||||||
e.id = rs.getLong(rs.getColumnIndex('id'));
|
e.id = rs.getLong(rs.getColumnIndex('id'));
|
||||||
e.uid = rs.getString(rs.getColumnIndex('uid'));
|
e.uid = rs.getString(rs.getColumnIndex('uid'));
|
||||||
|
e.dtstart = rs.getLong(rs.getColumnIndex('dtstart'));
|
||||||
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
|
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
|
||||||
e.href = rs.getString(rs.getColumnIndex('href'));
|
e.href = rs.getString(rs.getColumnIndex('href'));
|
||||||
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
|
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
|
||||||
@@ -146,6 +210,7 @@ export class EventDb {
|
|||||||
e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1;
|
e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1;
|
||||||
e.rrule = rs.getString(rs.getColumnIndex('rrule'));
|
e.rrule = rs.getString(rs.getColumnIndex('rrule'));
|
||||||
e.exdate = rs.getString(rs.getColumnIndex('exdate'));
|
e.exdate = rs.getString(rs.getColumnIndex('exdate'));
|
||||||
|
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
|
||||||
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
|
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
|
||||||
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
|
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
|
||||||
const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
|
const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
|
||||||
@@ -178,6 +243,7 @@ export class EventDb {
|
|||||||
'completed': e.completed ? 1 : 0,
|
'completed': e.completed ? 1 : 0,
|
||||||
'rrule': e.rrule,
|
'rrule': e.rrule,
|
||||||
'exdate': e.exdate,
|
'exdate': e.exdate,
|
||||||
|
'recurrence_id': e.recurrenceId,
|
||||||
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
|
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
|
||||||
'reminders': e.reminders.map((n: number): string => String(n)).join(',')
|
'reminders': e.reminders.map((n: number): string => String(n)).join(',')
|
||||||
};
|
};
|
||||||
@@ -281,6 +347,90 @@ export class EventDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
|
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
|
||||||
|
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
|
||||||
|
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
|
||||||
|
/** 保存循环规则与基准(设置/取消循环后调用) */
|
||||||
|
static async updateTodoRecurrence(context: common.Context, id: number,
|
||||||
|
rrule: string, dtstart: number, etag: string): Promise<void> {
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const values: relationalStore.ValuesBucket = {
|
||||||
|
'rrule': rrule,
|
||||||
|
'dtstart': dtstart,
|
||||||
|
'recurring': rrule !== '' ? 1 : 0,
|
||||||
|
'etag': etag,
|
||||||
|
'dirty': 0
|
||||||
|
};
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('id', id);
|
||||||
|
await store.update(values, predicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 循环待办"完成一轮"后更新本地:推后的基准/截止 + 清除完成态。
|
||||||
|
*
|
||||||
|
* ⚠️ 必须同时把 completed 置 0、dirty 清掉:
|
||||||
|
* 否则界面会显示成"已完成",而服务器上其实是"下一轮未完成",两边不一致。
|
||||||
|
*/
|
||||||
|
static async updateTodoAfterRound(context: common.Context, id: number,
|
||||||
|
dtstart: number, dueMs: number, etag: string): Promise<void> {
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const values: relationalStore.ValuesBucket = {
|
||||||
|
'dtstart': dtstart,
|
||||||
|
'start_time': dueMs,
|
||||||
|
'end_time': dueMs,
|
||||||
|
'completed': 0,
|
||||||
|
'etag': etag,
|
||||||
|
'dirty': 0
|
||||||
|
};
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('id', id);
|
||||||
|
await store.update(values, predicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */
|
||||||
|
static async updateTodoFields(context: common.Context, id: number, title: string,
|
||||||
|
desc: string, dueMs: number, etag: string): Promise<void> {
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const values: relationalStore.ValuesBucket = {
|
||||||
|
'title': title,
|
||||||
|
'description': desc,
|
||||||
|
'start_time': dueMs,
|
||||||
|
'etag': etag,
|
||||||
|
'dirty': 0
|
||||||
|
};
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('id', id);
|
||||||
|
await store.update(values, predicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async setTodoCompleted(context: common.Context, id: number, completed: boolean,
|
||||||
|
etag: string): Promise<void> {
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const values: relationalStore.ValuesBucket = {
|
||||||
|
'completed': completed ? 1 : 0,
|
||||||
|
'dirty': 0,
|
||||||
|
'etag': etag
|
||||||
|
};
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('id', id);
|
||||||
|
await store.update(values, predicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async queryById(context: common.Context, id: number): Promise<LocalEvent | null> {
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('id', id);
|
||||||
|
const rs = await store.query(predicates);
|
||||||
|
try {
|
||||||
|
if (rs.goToNextRow()) {
|
||||||
|
return EventDb.fromRow(rs);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
rs.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
|
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
|
||||||
const store = await EventDb.getDb(context);
|
const store = await EventDb.getDb(context);
|
||||||
const predicates = new relationalStore.RdbPredicates('events');
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
@@ -370,13 +520,35 @@ export class EventDb {
|
|||||||
rs.close();
|
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) 查。
|
||||||
|
// 语义与 `existing.find(x => !x.dirty && x.uid===r.uid && x.startTime===r.startTime
|
||||||
|
// && x.recurrenceId===r.recurrenceId)` 完全一致:dirty 行不入索引、同键取**最先**出现的那行。
|
||||||
|
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
||||||
|
for (const e of existing) {
|
||||||
|
if (e.dirty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
|
||||||
|
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) {
|
for (const r of remote) {
|
||||||
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间
|
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间 + RECURRENCE-ID
|
||||||
const key: string = `${r.uid}_${r.startTime}`;
|
//(同一时刻可能同时存在"主事件"与"覆盖该次发生的实例行",只带上 recurrenceId 才唯一)
|
||||||
remoteKeys.push(key);
|
const key: string = `${r.uid}_${r.startTime}_${r.recurrenceId}`;
|
||||||
const found = existing.find((x: LocalEvent): boolean =>
|
if (seenKeys.has(key)) {
|
||||||
!x.dirty && x.uid === r.uid && x.startTime === r.startTime);
|
continue; // 同一资源内的重复条目 → 只保留一条
|
||||||
|
}
|
||||||
|
seenKeys.add(key);
|
||||||
|
const found: LocalEvent | undefined = keyed.get(key);
|
||||||
if (found === undefined) {
|
if (found === undefined) {
|
||||||
// 新增
|
// 新增
|
||||||
const e = new LocalEvent();
|
const e = new LocalEvent();
|
||||||
@@ -398,14 +570,20 @@ export class EventDb {
|
|||||||
e.completed = r.completed;
|
e.completed = r.completed;
|
||||||
e.rrule = r.rrule;
|
e.rrule = r.rrule;
|
||||||
e.exdate = r.exdate;
|
e.exdate = r.exdate;
|
||||||
|
e.recurrenceId = r.recurrenceId;
|
||||||
e.reminder = r.reminder;
|
e.reminder = r.reminder;
|
||||||
e.reminders = r.reminders;
|
e.reminders = r.reminders;
|
||||||
await store.insert('events', EventDb.toBucket(e));
|
await store.insert('events', EventDb.toBucket(e));
|
||||||
added++;
|
added++;
|
||||||
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|
||||||
|| found.reminder !== r.reminder || found.recurring !== r.recurring
|
|| found.reminder !== r.reminder || found.recurring !== r.recurring
|
||||||
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)) {
|
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
|
||||||
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填)
|
|| found.href !== href) {
|
||||||
|
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
|
||||||
|
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
|
||||||
|
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
|
||||||
|
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
|
||||||
|
found.href = href;
|
||||||
found.title = r.title;
|
found.title = r.title;
|
||||||
found.description = r.description;
|
found.description = r.description;
|
||||||
found.location = r.location;
|
found.location = r.location;
|
||||||
@@ -420,6 +598,7 @@ export class EventDb {
|
|||||||
found.completed = r.completed;
|
found.completed = r.completed;
|
||||||
found.rrule = r.rrule;
|
found.rrule = r.rrule;
|
||||||
found.exdate = r.exdate;
|
found.exdate = r.exdate;
|
||||||
|
found.recurrenceId = r.recurrenceId;
|
||||||
found.reminder = r.reminder;
|
found.reminder = r.reminder;
|
||||||
found.reminders = r.reminders;
|
found.reminders = r.reminders;
|
||||||
const up = new relationalStore.RdbPredicates('events');
|
const up = new relationalStore.RdbPredicates('events');
|
||||||
@@ -428,6 +607,15 @@ export class EventDb {
|
|||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
unchanged++;
|
unchanged++;
|
||||||
|
// etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中,
|
||||||
|
// 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释)
|
||||||
|
if (found.href !== href) {
|
||||||
|
found.href = href;
|
||||||
|
const hp = new relationalStore.RdbPredicates('events');
|
||||||
|
hp.equalTo('id', found.id);
|
||||||
|
const hb: relationalStore.ValuesBucket = { 'href': href };
|
||||||
|
await store.update(hb, hp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 删除远端已不存在的(排除本地修改未推送的)
|
// 删除远端已不存在的(排除本地修改未推送的)
|
||||||
@@ -435,8 +623,9 @@ export class EventDb {
|
|||||||
if (local.dirty) {
|
if (local.dirty) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const localKey: string = `${local.uid}_${local.startTime}`;
|
const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
|
||||||
if (!remoteKeys.includes(localKey)) {
|
// ⭐ 同样从 O(m) 的数组 includes 换成 Set 查询(见上方 keyed 的说明)
|
||||||
|
if (!seenKeys.has(localKey)) {
|
||||||
const del = new relationalStore.RdbPredicates('events');
|
const del = new relationalStore.RdbPredicates('events');
|
||||||
del.equalTo('id', local.id);
|
del.equalTo('id', local.id);
|
||||||
await store.delete(del);
|
await store.delete(del);
|
||||||
@@ -446,11 +635,72 @@ export class EventDb {
|
|||||||
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
|
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[]> {
|
static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise<LocalEvent[]> {
|
||||||
const store = await EventDb.getDb(context);
|
const store = await EventDb.getDb(context);
|
||||||
const predicates = new relationalStore.RdbPredicates('events');
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
|
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
|
||||||
|
predicates.orderByAsc('id');
|
||||||
const rs = await store.query(predicates);
|
const rs = await store.query(predicates);
|
||||||
const list: LocalEvent[] = [];
|
const list: LocalEvent[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -482,6 +732,144 @@ export class EventDb {
|
|||||||
await store.delete(predicates);
|
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 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。
|
||||||
* 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。
|
* 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。
|
||||||
@@ -517,6 +905,46 @@ export class EventDb {
|
|||||||
await store.delete(predicates);
|
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}_${序号}`)。
|
* 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ParsedEvent {
|
|||||||
inAlarm: boolean = false; // 是否处于 VALARM 子组件内(内部属性不参与解析)
|
inAlarm: boolean = false; // 是否处于 VALARM 子组件内(内部属性不参与解析)
|
||||||
rrule: string = ''; // 原始 RRULE 值
|
rrule: string = ''; // 原始 RRULE 值
|
||||||
exdates: string[] = []; // 原始 EXDATE 值列表
|
exdates: string[] = []; // 原始 EXDATE 值列表
|
||||||
|
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒)——本行覆盖的是主事件的哪一次发生
|
||||||
reminder: number = 0; // 主提醒提前分钟数(第一个 VALARM,0 = 不提醒)
|
reminder: number = 0; // 主提醒提前分钟数(第一个 VALARM,0 = 不提醒)
|
||||||
reminders: number[] = []; // 全部提醒提前分钟数(多 VALARM)
|
reminders: number[] = []; // 全部提醒提前分钟数(多 VALARM)
|
||||||
}
|
}
|
||||||
@@ -102,6 +103,16 @@ export class IcsUtil {
|
|||||||
current.recurring = true;
|
current.recurring = true;
|
||||||
if (propName === 'RRULE') {
|
if (propName === 'RRULE') {
|
||||||
current.rrule = value;
|
current.rrule = value;
|
||||||
|
} else {
|
||||||
|
// ⭐ 必须**保留 RECURRENCE-ID 的值**:它指出本行覆盖的是主事件哪一次发生。
|
||||||
|
// 覆盖行的 DTSTART 是"被改到的新时间",只改标题时两者才相同;
|
||||||
|
// 一旦把某次发生改期,用 startTime 排除主事件那次发生就会漏掉 →
|
||||||
|
// App 与系统日历里同一日程出现两条(2026-09-19 真机事故)。
|
||||||
|
const recDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||||
|
const rt = IcsUtil.parseTime(value, recDateOnly);
|
||||||
|
if (rt !== null) {
|
||||||
|
current.recurrenceId = rt.time;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (propName === 'EXDATE') {
|
} else if (propName === 'EXDATE') {
|
||||||
const exValues: string[] = value.split(',');
|
const exValues: string[] = value.split(',');
|
||||||
@@ -140,6 +151,7 @@ export class IcsUtil {
|
|||||||
r.recurring = p.recurring;
|
r.recurring = p.recurring;
|
||||||
r.rrule = p.rrule;
|
r.rrule = p.rrule;
|
||||||
r.exdate = p.exdates.join(';');
|
r.exdate = p.exdates.join(';');
|
||||||
|
r.recurrenceId = p.recurrenceId;
|
||||||
r.reminder = p.reminder;
|
r.reminder = p.reminder;
|
||||||
r.reminders = p.reminders;
|
r.reminders = p.reminders;
|
||||||
return r;
|
return r;
|
||||||
@@ -158,6 +170,562 @@ export class IcsUtil {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 在**服务器原文**上改写某个 VTODO 的完成状态,返回新的整份 ICS。
|
||||||
|
*
|
||||||
|
* 为什么不从本地字段重建:VTODO 上还有 SUMMARY / DESCRIPTION / DUE / PRIORITY /
|
||||||
|
* CATEGORIES / X-* 等大量字段,本地库只保存了其中少数几个。
|
||||||
|
* 若按本地字段重新拼一份 VTODO 再 PUT,等于**把没保存的字段全部删掉**(丢用户数据)。
|
||||||
|
* 因此这里只做"在原文上精确改那几行",其余内容原样保留。
|
||||||
|
*
|
||||||
|
* 改动范围(按 RFC 5545,三者要一起维护,否则各客户端显示不一致):
|
||||||
|
* - `STATUS:COMPLETED` ⇄ `STATUS:NEEDS-ACTION`
|
||||||
|
* - `COMPLETED:<UTC时间>`(完成时写入,取消完成时删除)
|
||||||
|
* - `PERCENT-COMPLETE:100` ⇄ `PERCENT-COMPLETE:0`
|
||||||
|
*
|
||||||
|
* @param ics 服务器上的原始 .ics 全文
|
||||||
|
* @param uid 目标待办的 UID(一个资源里理论上只有一个 VTODO,但仍按 uid 定位更稳)
|
||||||
|
* @param done true=标记完成;false=取消完成
|
||||||
|
* @returns 改写后的完整 ICS(调用方直接 PUT)
|
||||||
|
*/
|
||||||
|
static rewriteTodoCompletion(ics: string, uid: string, done: boolean): string {
|
||||||
|
const crlf: string = '\r\n';
|
||||||
|
// 折行还原(RFC 5545 允许续行,续行以空格/Tab 开头)
|
||||||
|
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||||
|
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
|
||||||
|
const upperOf = (s: string): string => s.toUpperCase();
|
||||||
|
const nowUtc: string = IcsUtil.fmtUtcNow();
|
||||||
|
|
||||||
|
// ---------- 第 1 遍:找出"含目标 uid 的 VTODO"的 [begin, end] 行号区间 ----------
|
||||||
|
// 必须两遍:RFC 5545 不规定字段顺序,UID 可能出现在 STATUS 之后。
|
||||||
|
// 单遍实现会漏改(实测风险:STATUS 在前时 matched 仍为 false → 用户点了没反应)。
|
||||||
|
let targetBegin: number = -1;
|
||||||
|
let targetEnd: number = -1;
|
||||||
|
let targetOrdinal: number = -1; // 目标 VTODO 是第几个块(0 基),供插入段按结构定位
|
||||||
|
let curBegin: number = -1;
|
||||||
|
let curHasUid: boolean = false;
|
||||||
|
let blockIdx: number = -1;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const u: string = upperOf(lines[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
blockIdx++;
|
||||||
|
curBegin = i;
|
||||||
|
curHasUid = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||||
|
if (lines[i].substring(4).trim() === uid) {
|
||||||
|
curHasUid = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO')) {
|
||||||
|
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||||
|
targetBegin = curBegin;
|
||||||
|
targetEnd = i;
|
||||||
|
targetOrdinal = blockIdx;
|
||||||
|
}
|
||||||
|
curBegin = -1;
|
||||||
|
curHasUid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetBegin < 0) {
|
||||||
|
// 找不到目标 uid:原样返回(调用方会 PUT 回去,内容未变,等于无操作)
|
||||||
|
return ics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 第 2 遍:在区间内改写状态字段 ----------
|
||||||
|
const out: string[] = [];
|
||||||
|
let sawStatus: boolean = false;
|
||||||
|
let sawPercent: boolean = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line: string = lines[i];
|
||||||
|
const u: string = upperOf(line);
|
||||||
|
const inTarget: boolean = i > targetBegin && i < targetEnd;
|
||||||
|
|
||||||
|
if (!inTarget) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('STATUS:')) {
|
||||||
|
sawStatus = true;
|
||||||
|
out.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('COMPLETED')) {
|
||||||
|
continue; // 丢弃旧的,完成时下面统一补
|
||||||
|
}
|
||||||
|
if (u.startsWith('PERCENT-COMPLETE')) {
|
||||||
|
sawPercent = true;
|
||||||
|
out.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缺字段则在目标 VTODO 的 END 之前补齐(有的客户端缺 STATUS 就不显示完成态)
|
||||||
|
const extra: string[] = [];
|
||||||
|
if (!sawStatus) {
|
||||||
|
extra.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
extra.push('COMPLETED:' + nowUtc);
|
||||||
|
}
|
||||||
|
if (!sawPercent) {
|
||||||
|
extra.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
|
||||||
|
}
|
||||||
|
if (extra.length === 0) {
|
||||||
|
const plain: string = out.join(crlf);
|
||||||
|
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ 插入点必须按"结构"定位,不能按行号:
|
||||||
|
// 改动可能删掉了旧的 COMPLETED 行,out 与第一遍的 lines 行号已不对齐,
|
||||||
|
// 用行号定位会把字段写到 END:VTODO 之外(实测会落到 END:VCALENDAR 之后,
|
||||||
|
// 结构非法,部分服务端直接 400)。
|
||||||
|
// 这里重新数 VTODO 块,找到"第 targetOrdinal 个"块的 END 行插进去。
|
||||||
|
const result: string[] = [];
|
||||||
|
let vtodoIdx: number = -1; // 当前处于第几个 VTODO 块内(-1=不在块内)
|
||||||
|
let inserted: boolean = false;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const u: string = upperOf(out[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
vtodoIdx++;
|
||||||
|
result.push(out[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO')) {
|
||||||
|
// 走到目标块的 END 之前先补字段
|
||||||
|
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||||
|
for (const ex of extra) {
|
||||||
|
result.push(ex);
|
||||||
|
}
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
// ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 0 重新数,
|
||||||
|
// 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。
|
||||||
|
result.push(out[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(out[i]);
|
||||||
|
}
|
||||||
|
if (!inserted) {
|
||||||
|
// 兜底(理论上不会发生):退回到"整份文本末尾 END:VCALENDAR 之前"插入
|
||||||
|
const tail0: string = result.join(crlf);
|
||||||
|
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
|
||||||
|
if (idxVc > 0) {
|
||||||
|
const head: string = tail0.substring(0, idxVc);
|
||||||
|
const tail: string = tail0.substring(idxVc);
|
||||||
|
const merged: string = head + extra.join(crlf) + crlf + tail;
|
||||||
|
return merged.endsWith(crlf) ? merged : merged + crlf;
|
||||||
|
}
|
||||||
|
for (const ex of extra) {
|
||||||
|
result.push(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text2: string = result.join(crlf);
|
||||||
|
return text2.endsWith(crlf) ? text2 : text2 + crlf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在**服务器原文**上改写 VTODO 的内容字段,返回新的整份 ICS。
|
||||||
|
*
|
||||||
|
* 只改三项,其余(PRIORITY / CATEGORIES / X-* / STATUS / COMPLETED …)原样保留:
|
||||||
|
* - SUMMARY 标题
|
||||||
|
* - DESCRIPTION 备注
|
||||||
|
* - DUE 截止时间(dueMs<=0 → 删除该行)
|
||||||
|
*
|
||||||
|
* 与 rewriteTodoCompletion 相同的两遍处理:
|
||||||
|
* ① 先定位含目标 uid 的 VTODO 块(RFC 5545 不规定字段顺序,不能边扫边改)
|
||||||
|
* ② 再在块内替换/删除目标字段,缺失的在 END:VTODO 前补齐
|
||||||
|
*/
|
||||||
|
static rewriteTodoFields(ics: string, uid: string, title: string,
|
||||||
|
desc: string, dueMs: number): string {
|
||||||
|
const crlf: string = '\r\n';
|
||||||
|
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||||
|
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
|
||||||
|
const upperOf = (s: string): string => s.toUpperCase();
|
||||||
|
|
||||||
|
// ---- 第 1 遍:定位目标 VTODO 块 ----
|
||||||
|
let targetBegin: number = -1;
|
||||||
|
let targetEnd: number = -1;
|
||||||
|
let curBegin: number = -1;
|
||||||
|
let curHasUid: boolean = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const u: string = upperOf(lines[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
curBegin = i;
|
||||||
|
curHasUid = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||||
|
if (lines[i].substring(4).trim() === uid) {
|
||||||
|
curHasUid = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO')) {
|
||||||
|
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||||
|
targetBegin = curBegin;
|
||||||
|
targetEnd = i;
|
||||||
|
}
|
||||||
|
curBegin = -1;
|
||||||
|
curHasUid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetBegin < 0) {
|
||||||
|
return ics; // 找不到目标,原样返回
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 第 2 遍:块内改写 ----
|
||||||
|
const out: string[] = [];
|
||||||
|
let sawSummary: boolean = false;
|
||||||
|
let sawDesc: boolean = false;
|
||||||
|
let sawDue: boolean = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line: string = lines[i];
|
||||||
|
const u: string = upperOf(line);
|
||||||
|
const inTarget: boolean = i > targetBegin && i < targetEnd;
|
||||||
|
if (!inTarget) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('SUMMARY')) {
|
||||||
|
sawSummary = true;
|
||||||
|
if (title !== '') {
|
||||||
|
out.push('SUMMARY:' + IcsUtil.escape(title));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('DESCRIPTION')) {
|
||||||
|
sawDesc = true;
|
||||||
|
if (desc !== '') {
|
||||||
|
out.push('DESCRIPTION:' + IcsUtil.escape(desc));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('DUE')) {
|
||||||
|
sawDue = true;
|
||||||
|
if (dueMs > 0) {
|
||||||
|
out.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缺失字段补齐
|
||||||
|
const extra: string[] = [];
|
||||||
|
if (!sawSummary && title !== '') {
|
||||||
|
extra.push('SUMMARY:' + IcsUtil.escape(title));
|
||||||
|
}
|
||||||
|
if (!sawDesc && desc !== '') {
|
||||||
|
extra.push('DESCRIPTION:' + IcsUtil.escape(desc));
|
||||||
|
}
|
||||||
|
if (!sawDue && dueMs > 0) {
|
||||||
|
extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
||||||
|
}
|
||||||
|
if (extra.length === 0) {
|
||||||
|
const plain: string = out.join(crlf);
|
||||||
|
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按**结构**定位插入点(不能按行号:上面可能删掉了 DUE 行,行号已不对齐)
|
||||||
|
const result: string[] = [];
|
||||||
|
let vtodoIdx: number = -1;
|
||||||
|
let targetOrdinal: number = -1;
|
||||||
|
// 重新数出目标的块序号
|
||||||
|
{
|
||||||
|
let n: number = -1;
|
||||||
|
let inBlk: boolean = false;
|
||||||
|
let has: boolean = false;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const u: string = upperOf(out[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
n++;
|
||||||
|
inBlk = true;
|
||||||
|
has = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) {
|
||||||
|
has = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO') && inBlk) {
|
||||||
|
if (has && targetOrdinal < 0) {
|
||||||
|
targetOrdinal = n;
|
||||||
|
}
|
||||||
|
inBlk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let inserted: boolean = false;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const u: string = upperOf(out[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
vtodoIdx++;
|
||||||
|
result.push(out[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO')) {
|
||||||
|
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||||
|
for (const ex of extra) {
|
||||||
|
result.push(ex);
|
||||||
|
}
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
// ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 0 重新数,
|
||||||
|
// 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。
|
||||||
|
result.push(out[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(out[i]);
|
||||||
|
}
|
||||||
|
if (!inserted) {
|
||||||
|
const tail0: string = result.join(crlf);
|
||||||
|
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
|
||||||
|
if (idxVc > 0) {
|
||||||
|
const merged: string = tail0.substring(0, idxVc) + extra.join(crlf) + crlf
|
||||||
|
+ tail0.substring(idxVc);
|
||||||
|
return merged.endsWith(crlf) ? merged : merged + crlf;
|
||||||
|
}
|
||||||
|
for (const ex of extra) {
|
||||||
|
result.push(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text2: string = result.join(crlf);
|
||||||
|
return text2.endsWith(crlf) ? text2 : text2 + crlf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 FREQ 把时间往后推一个周期(用于"完成一个循环任务 → 生成下一次")。
|
||||||
|
*
|
||||||
|
* ⚠️ 月份/年份推进要处理"月末溢出":
|
||||||
|
* 1 月 31 日 + 1 个月 在 JS 里会得到 3 月 3 日(2 月没有 31 日)。
|
||||||
|
* RFC 5545 规定这类"不存在的日期"应当**跳过**而非顺延,
|
||||||
|
* 但对家务类待办来说"顺延到 3 号"比"跳过整个月"更符合直觉,
|
||||||
|
* 这里采用**钳制到当月最后一天**(1/31 → 2/28),并注释说明取舍。
|
||||||
|
*
|
||||||
|
* @param freq DAILY / WEEKLY / MONTHLY / YEARLY
|
||||||
|
* @param fromMs 基准时间
|
||||||
|
* @returns 下一周期的时间(同一天内时分秒保持不变)
|
||||||
|
*/
|
||||||
|
/** 从 RRULE 原文取出 FREQ 值(DAILY / WEEKLY / MONTHLY / YEARLY);取不到返回 DAILY */
|
||||||
|
static freqOfRrule(rrule: string): string {
|
||||||
|
const parts: string[] = rrule.split(';');
|
||||||
|
for (const part of parts) {
|
||||||
|
const idx: number = part.indexOf('=');
|
||||||
|
if (idx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key: string = part.substring(0, idx).trim().toUpperCase();
|
||||||
|
if (key === 'FREQ') {
|
||||||
|
return part.substring(idx + 1).trim().toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'DAILY';
|
||||||
|
}
|
||||||
|
|
||||||
|
static incForFreq(freq: string, fromMs: number): number {
|
||||||
|
const d: Date = new Date(fromMs);
|
||||||
|
const f: string = freq.toUpperCase();
|
||||||
|
if (f === 'DAILY') {
|
||||||
|
return fromMs + 86400000;
|
||||||
|
}
|
||||||
|
if (f === 'WEEKLY') {
|
||||||
|
return fromMs + 7 * 86400000;
|
||||||
|
}
|
||||||
|
if (f === 'MONTHLY') {
|
||||||
|
const y: number = d.getFullYear();
|
||||||
|
const m: number = d.getMonth() + 1;
|
||||||
|
const day: number = d.getDate();
|
||||||
|
const lastDayOfNext: number = new Date(y, m + 1, 0).getDate();
|
||||||
|
return new Date(y, m, Math.min(day, lastDayOfNext),
|
||||||
|
d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
|
||||||
|
}
|
||||||
|
if (f === 'YEARLY') {
|
||||||
|
const y: number = d.getFullYear() + 1;
|
||||||
|
const m: number = d.getMonth();
|
||||||
|
const day: number = d.getDate();
|
||||||
|
// 2/29 → 次年 2/28(闰年才有 29)
|
||||||
|
const lastDay: number = new Date(y, m + 1, 0).getDate();
|
||||||
|
return new Date(y, m, Math.min(day, lastDay),
|
||||||
|
d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
|
||||||
|
}
|
||||||
|
return fromMs + 86400000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在**服务器原文**上写入循环规则(RRULE + DTSTART + DUE)。
|
||||||
|
*
|
||||||
|
* 只在这三处动,其余字段(SUMMARY/DESCRIPTION/PRIORITY/X-* …)原样保留。
|
||||||
|
*
|
||||||
|
* @param rrule 规则原文,如 `FREQ=WEEKLY`;空串 = 取消循环(删除 RRULE/DTSTART,保留 DUE)
|
||||||
|
* @param dtstart 重复基准(毫秒);<=0 时用 dueMs 兜底
|
||||||
|
* @param dueMs 本轮的截止时间(毫秒);<=0 表示无截止
|
||||||
|
*/
|
||||||
|
static rewriteTodoRecurrence(ics: string, uid: string, rrule: string,
|
||||||
|
dtstart: number, dueMs: number): string {
|
||||||
|
const crlf: string = '\r\n';
|
||||||
|
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||||
|
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
|
||||||
|
const upperOf = (s: string): string => s.toUpperCase();
|
||||||
|
const hasRule: boolean = rrule.trim() !== '';
|
||||||
|
|
||||||
|
// ---- 第 1 遍:定位目标 VTODO 块(RFC 5545 不规定字段顺序,必须两遍)----
|
||||||
|
let targetBegin: number = -1;
|
||||||
|
let targetEnd: number = -1;
|
||||||
|
let curBegin: number = -1;
|
||||||
|
let curHasUid: boolean = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const u: string = upperOf(lines[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
curBegin = i;
|
||||||
|
curHasUid = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('UID:') && curBegin >= 0) {
|
||||||
|
if (lines[i].substring(4).trim() === uid) {
|
||||||
|
curHasUid = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO')) {
|
||||||
|
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
|
||||||
|
targetBegin = curBegin;
|
||||||
|
targetEnd = i;
|
||||||
|
}
|
||||||
|
curBegin = -1;
|
||||||
|
curHasUid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetBegin < 0) {
|
||||||
|
return ics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 第 2 遍:块内替换/删除 RRULE、DTSTART、DUE ----
|
||||||
|
const out: string[] = [];
|
||||||
|
let sawRule: boolean = false;
|
||||||
|
let sawStart: boolean = false;
|
||||||
|
let sawDue: boolean = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line: string = lines[i];
|
||||||
|
const u: string = upperOf(line);
|
||||||
|
const inTarget: boolean = i > targetBegin && i < targetEnd;
|
||||||
|
if (!inTarget) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('RRULE')) {
|
||||||
|
sawRule = true;
|
||||||
|
if (hasRule) {
|
||||||
|
out.push('RRULE:' + rrule.trim());
|
||||||
|
}
|
||||||
|
continue; // 取消循环时整行丢掉
|
||||||
|
}
|
||||||
|
if (u.startsWith('DTSTART')) {
|
||||||
|
sawStart = true;
|
||||||
|
if (hasRule && dtstart > 0) {
|
||||||
|
out.push('DTSTART:' + IcsUtil.fmtIcsTime(dtstart, false));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('DUE')) {
|
||||||
|
sawDue = true;
|
||||||
|
if (dueMs > 0) {
|
||||||
|
out.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缺字段补齐
|
||||||
|
const extra: string[] = [];
|
||||||
|
if (hasRule) {
|
||||||
|
if (!sawRule) {
|
||||||
|
extra.push('RRULE:' + rrule.trim());
|
||||||
|
}
|
||||||
|
if (!sawStart && dtstart > 0) {
|
||||||
|
extra.push('DTSTART:' + IcsUtil.fmtIcsTime(dtstart, false));
|
||||||
|
}
|
||||||
|
if (!sawDue && dueMs > 0) {
|
||||||
|
extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extra.length === 0) {
|
||||||
|
const plain: string = out.join(crlf);
|
||||||
|
return plain.endsWith(crlf) ? plain : plain + crlf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按**结构**定位插入点(改动会删行,行号已不对齐)
|
||||||
|
let targetOrdinal: number = -1;
|
||||||
|
{
|
||||||
|
let n: number = -1;
|
||||||
|
let inBlk: boolean = false;
|
||||||
|
let has: boolean = false;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const u: string = upperOf(out[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
n++;
|
||||||
|
inBlk = true;
|
||||||
|
has = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) {
|
||||||
|
has = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO') && inBlk) {
|
||||||
|
if (has && targetOrdinal < 0) {
|
||||||
|
targetOrdinal = n;
|
||||||
|
}
|
||||||
|
inBlk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ⭐ RRULE_IDX_FIX vtodoIdx 是**全局块序号**,只在 BEGIN:VTODO 自增。
|
||||||
|
// 之前在这里的 END:VTODO 分支把它重置成 -1,导致每块都从 0 重新数,
|
||||||
|
// 多 VTODO 文件里永远匹配不到 targetOrdinal(第 2 块应为 1)⇒ RRULE 加不上。
|
||||||
|
const result: string[] = [];
|
||||||
|
let vtodoIdx: number = -1;
|
||||||
|
let inserted: boolean = false;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const u: string = upperOf(out[i]);
|
||||||
|
if (u.startsWith('BEGIN:VTODO')) {
|
||||||
|
vtodoIdx++;
|
||||||
|
result.push(out[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (u.startsWith('END:VTODO')) {
|
||||||
|
if (!inserted && vtodoIdx === targetOrdinal) {
|
||||||
|
for (const ex of extra) {
|
||||||
|
result.push(ex);
|
||||||
|
}
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
result.push(out[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(out[i]);
|
||||||
|
}
|
||||||
|
if (!inserted) {
|
||||||
|
const tail0: string = result.join(crlf);
|
||||||
|
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
|
||||||
|
if (idxVc > 0) {
|
||||||
|
const merged: string = tail0.substring(0, idxVc) + extra.join(crlf) + crlf
|
||||||
|
+ tail0.substring(idxVc);
|
||||||
|
return merged.endsWith(crlf) ? merged : merged + crlf;
|
||||||
|
}
|
||||||
|
for (const ex of extra) {
|
||||||
|
result.push(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text2: string = result.join(crlf);
|
||||||
|
return text2.endsWith(crlf) ? text2 : text2 + crlf;
|
||||||
|
}
|
||||||
|
|
||||||
/** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
|
/** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
|
||||||
static parseTodos(ics: string): RemoteEvent[] {
|
static parseTodos(ics: string): RemoteEvent[] {
|
||||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||||
@@ -198,7 +766,18 @@ export class IcsUtil {
|
|||||||
current.location = IcsUtil.unescape(value);
|
current.location = IcsUtil.unescape(value);
|
||||||
} else if (propName === 'RRULE') {
|
} else if (propName === 'RRULE') {
|
||||||
current.recurring = true;
|
current.recurring = true;
|
||||||
} else if (propName === 'DUE' || propName === 'DTSTART') {
|
// ⭐ 保存规则原文:原来只置了 recurring 标志,规则本身被丢掉,
|
||||||
|
// 导致「知道它重复,但不知道多久重复一次」。
|
||||||
|
current.rrule = value.trim();
|
||||||
|
} else if (propName === 'DTSTART') {
|
||||||
|
// ⭐ DTSTART 单独存:它是 RRULE 的重复基准,**不是**截止时间。
|
||||||
|
// 原来与 DUE 共用 startTime,谁后出现谁覆盖 ⇒ 循环任务基准错乱。
|
||||||
|
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||||
|
const t = IcsUtil.parseTime(value, isDateOnly);
|
||||||
|
if (t !== null) {
|
||||||
|
current.dtstart = t.time;
|
||||||
|
}
|
||||||
|
} else if (propName === 'DUE') {
|
||||||
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||||
const t = IcsUtil.parseTime(value, isDateOnly);
|
const t = IcsUtil.parseTime(value, isDateOnly);
|
||||||
if (t !== null) {
|
if (t !== null) {
|
||||||
@@ -217,18 +796,72 @@ export class IcsUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确)。
|
* 由本地事件构建 VCALENDAR 文本。
|
||||||
|
*
|
||||||
|
* ⚠️ 事件时间写的是**浮动本地时间**(不是 UTC),原因见 `vevent()` 里的详细说明
|
||||||
|
* (服务器会删掉 `Z`/`TZID` 且不做时移)。`DTSTAMP` 仍按 RFC 规定写 UTC+`Z`。
|
||||||
*
|
*
|
||||||
* 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。
|
* 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。
|
||||||
* 含这些字符的标题在服务器端可能被截断或串行;
|
* 含这些字符的标题在服务器端可能被截断或串行;
|
||||||
* 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。
|
* 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。
|
||||||
*/
|
*/
|
||||||
static build(e: LocalEvent): string {
|
static build(e: LocalEvent): string {
|
||||||
|
const now: string = IcsUtil.fmtUtcNow();
|
||||||
|
return 'BEGIN:VCALENDAR\r\n' +
|
||||||
|
'VERSION:2.0\r\n' +
|
||||||
|
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
|
||||||
|
'CALSCALE:GREGORIAN\r\n' +
|
||||||
|
IcsUtil.vevent(e, now) +
|
||||||
|
'END:VCALENDAR\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。
|
||||||
|
*
|
||||||
|
* 为什么必须合在一起:CalDAV 里"重复日程的某一次发生被单独修改"的标准表示法是
|
||||||
|
* 在**同一个 .ics 资源**里放第二个 VEVENT,带 `RECURRENCE-ID` 指向被覆盖的那一次。
|
||||||
|
* 早先 App 直接把每个实例当独立资源 PUT,会破坏服务器上的整个系列,所以那时干脆
|
||||||
|
* 禁止了单次修改(见旧版"重复日程的单次修改暂不支持")。现在改为重建整份系列后推送。
|
||||||
|
*
|
||||||
|
* @param master 主事件行(含 RRULE / EXDATE)
|
||||||
|
* @param overrides 覆盖实例行(rrule 为空、recurrenceId > 0),已排除 deleted
|
||||||
|
*/
|
||||||
|
static buildSeries(master: LocalEvent, overrides: LocalEvent[]): string {
|
||||||
|
const now: string = IcsUtil.fmtUtcNow();
|
||||||
|
let out: string = 'BEGIN:VCALENDAR\r\n' +
|
||||||
|
'VERSION:2.0\r\n' +
|
||||||
|
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
|
||||||
|
'CALSCALE:GREGORIAN\r\n' +
|
||||||
|
IcsUtil.vevent(master, now);
|
||||||
|
for (const ov of overrides) {
|
||||||
|
out += IcsUtil.vevent(ov, now);
|
||||||
|
}
|
||||||
|
out += 'END:VCALENDAR\r\n';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */
|
||||||
|
private static vevent(e: LocalEvent, now: string): string {
|
||||||
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||||
const fmtUtc = (ms: number): string => {
|
/**
|
||||||
|
* ⭐⭐ 事件时间一律写**浮动本地时间**(`YYYYMMDDTHHMMSS`,**不带 `Z`、不带 `TZID`**)。
|
||||||
|
*
|
||||||
|
* 为什么不写 RFC 最正统的 UTC+`Z` —— 因为服务器会把它弄坏:
|
||||||
|
* 本项目的服务器(家书日历 / family.yangyq.net)会**直接删掉** `DTSTART`/`DTEND`
|
||||||
|
* 上的 `Z` 与 `TZID` 参数,**且不对数值做任何时移**
|
||||||
|
* (2026-10-15 实测:PUT `DTSTART:20261020T060000Z` → 存回 `DTSTART:20261020T060000`;
|
||||||
|
* PUT `DTSTART;TZID=Asia/Shanghai:20261020T140000` → 存回 `20261020T140000`)。
|
||||||
|
* 于是"UTC 00:00"被当成"**本地** 00:00"。而本地 08:00 的正确 UTC 恰好就是 00:00,
|
||||||
|
* 所以症状正好是**阅读端少了 8 小时**(用户实测:"8:00 的日程同步后变成 0:00")。
|
||||||
|
*
|
||||||
|
* 该服务器上全部计时事件(1163 条)的 `DTSTART` **无一例外都是浮动本地时间**
|
||||||
|
* (带 `Z` 的数量为 **0**、带 `TZID` 为 **0**)→ 这就是它的事实约定,我们随它。
|
||||||
|
* 浮动时间在任何阅读端都按"当地墙钟"解析,本机 CST 往返完全一致。
|
||||||
|
*/
|
||||||
|
const fmtLocal = (ms: number): string => {
|
||||||
const d = new Date(ms);
|
const d = new Date(ms);
|
||||||
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
`T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||||
};
|
};
|
||||||
const fmtDate = (ms: number): string => {
|
const fmtDate = (ms: number): string => {
|
||||||
const d = new Date(ms);
|
const d = new Date(ms);
|
||||||
@@ -241,27 +874,91 @@ export class IcsUtil {
|
|||||||
// 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期
|
// 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期
|
||||||
dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000);
|
dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000);
|
||||||
} else {
|
} else {
|
||||||
dtstart = 'DTSTART:' + fmtUtc(e.startTime);
|
dtstart = 'DTSTART:' + fmtLocal(e.startTime);
|
||||||
dtend = 'DTEND:' + fmtUtc(e.endTime);
|
dtend = 'DTEND:' + fmtLocal(e.endTime);
|
||||||
}
|
}
|
||||||
const now: string = fmtUtc(Date.now());
|
// ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它
|
||||||
return 'BEGIN:VCALENDAR\r\n' +
|
// 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。
|
||||||
'VERSION:2.0\r\n' +
|
let recId: string = '';
|
||||||
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
|
if (e.rrule === '' && e.recurrenceId > 0) {
|
||||||
'CALSCALE:GREGORIAN\r\n' +
|
recId = e.isAllDay
|
||||||
'BEGIN:VEVENT\r\n' +
|
? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n`
|
||||||
|
: `RECURRENCE-ID:${fmtLocal(e.recurrenceId)}\r\n`;
|
||||||
|
}
|
||||||
|
// ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加)
|
||||||
|
let exdate: string = '';
|
||||||
|
if (e.exdate !== '') {
|
||||||
|
const vals: string[] = [];
|
||||||
|
for (const raw of e.exdate.split(';')) {
|
||||||
|
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
|
||||||
|
if (t !== null) {
|
||||||
|
vals.push(t.allDay ? fmtDate(t.time) : fmtLocal(t.time));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vals.length > 0) {
|
||||||
|
exdate = e.isAllDay
|
||||||
|
? `EXDATE;VALUE=DATE:${vals.join(',')}\r\n`
|
||||||
|
: `EXDATE:${vals.join(',')}\r\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'BEGIN:VEVENT\r\n' +
|
||||||
`UID:${e.uid}\r\n` +
|
`UID:${e.uid}\r\n` +
|
||||||
`DTSTAMP:${now}\r\n` +
|
`DTSTAMP:${now}\r\n` +
|
||||||
`${dtstart}\r\n` +
|
`${dtstart}\r\n` +
|
||||||
`${dtend}\r\n` +
|
`${dtend}\r\n` +
|
||||||
|
recId +
|
||||||
(e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') +
|
(e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') +
|
||||||
|
exdate +
|
||||||
`SUMMARY:${IcsUtil.escape(e.title)}\r\n` +
|
`SUMMARY:${IcsUtil.escape(e.title)}\r\n` +
|
||||||
(e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') +
|
(e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') +
|
||||||
(e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') +
|
(e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') +
|
||||||
IcsUtil.valarms(e.reminders.length > 0 ? e.reminders
|
IcsUtil.valarms(e.reminders.length > 0 ? e.reminders
|
||||||
: (e.reminder > 0 ? [e.reminder] : [])) +
|
: (e.reminder > 0 ? [e.reminder] : [])) +
|
||||||
'END:VEVENT\r\n' +
|
'END:VEVENT\r\n';
|
||||||
'END:VCALENDAR\r\n';
|
}
|
||||||
|
|
||||||
|
/** 当前时刻的 UTC 形式(DTSTAMP) */
|
||||||
|
private static fmtUtcNow(): string {
|
||||||
|
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||||
|
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID / UNTIL 的 ICS 时间串。
|
||||||
|
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用**浮动本地时间** `YYYYMMDDTHHMMSS`
|
||||||
|
* (不带 `Z`:必须与 `DTSTART` 的形态一致,见 `vevent()` 里的说明)
|
||||||
|
*/
|
||||||
|
static fmtIcsTime(ms: number, dateOnly: boolean): string {
|
||||||
|
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||||
|
const d = new Date(ms);
|
||||||
|
if (dateOnly) {
|
||||||
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||||
|
`T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。
|
||||||
|
* - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现)
|
||||||
|
* - UNTIL 用**浮动本地时间**(RFC 5545:`DTSTART` 是本地时间时,`UNTIL` **必须**也是本地时间;
|
||||||
|
* 而我们把所有事件时间都写成浮动本地 —— 二者必须一致,否则不同阅读端的展开会错位)
|
||||||
|
* - `untilMs` 一般是"该次发生的开始时刻 − 1 秒"
|
||||||
|
*/
|
||||||
|
static rruleWithUntil(rrule: string, untilMs: number): string {
|
||||||
|
const until: string = IcsUtil.fmtIcsTime(untilMs, false);
|
||||||
|
const kept: string[] = [];
|
||||||
|
for (const part of rrule.split(';')) {
|
||||||
|
const k: string = part.split('=')[0].trim().toUpperCase();
|
||||||
|
if (k === 'UNTIL' || k === 'COUNT' || k === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push(part);
|
||||||
|
}
|
||||||
|
kept.push(`UNTIL=${until}`);
|
||||||
|
return kept.join(';');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
|
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
|
||||||
|
|||||||
@@ -1,30 +1,57 @@
|
|||||||
// entry/src/main/ets/common/LogUtil.ets
|
// 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 { common } from '@kit.AbilityKit';
|
||||||
import { fileIo as fs } from '@kit.CoreFileKit';
|
import { fileIo as fs } from '@kit.CoreFileKit';
|
||||||
import { util } from '@kit.ArkTS';
|
import { util } from '@kit.ArkTS';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
|
||||||
export class LogUtil {
|
export class LogUtil {
|
||||||
|
/** ⭐ 落盘开关:发行版置 false(仅 console);真机排查时改 true 恢复文件日志 */
|
||||||
|
private static readonly ENABLE_FILE_LOG: boolean = false;
|
||||||
|
private static dir: string = '';
|
||||||
private static path: string = '';
|
private static path: string = '';
|
||||||
private static readonly MAX_BYTES: number = 512 * 1024;
|
private static readonly MAX_BYTES: number = 512 * 1024;
|
||||||
|
|
||||||
static init(context: common.Context): void {
|
static init(context: common.Context): void {
|
||||||
try {
|
try {
|
||||||
LogUtil.path = `${context.filesDir}/sync.log`;
|
LogUtil.dir = context.filesDir;
|
||||||
|
LogUtil.path = `${LogUtil.dir}/sync.log`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
LogUtil.dir = '';
|
||||||
LogUtil.path = '';
|
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 {
|
static logFilePath(): string {
|
||||||
return LogUtil.path;
|
return LogUtil.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写一行日志:console + 文件(文件失败静默,不影响业务) */
|
/** 写一行日志:console + 文件(发行版不落盘;文件失败静默,不影响业务) */
|
||||||
static write(msg: string): void {
|
static write(msg: string): void {
|
||||||
console.info(`SyncLog ${msg}`);
|
console.info(`SyncLog ${msg}`);
|
||||||
|
if (!LogUtil.ENABLE_FILE_LOG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (LogUtil.path === '') {
|
if (LogUtil.path === '') {
|
||||||
return;
|
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,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 { EventDb, LocalEvent } from './EventDb';
|
||||||
import { AppSettings } from './AppSettings';
|
import { AppSettings } from './AppSettings';
|
||||||
import { AccountStore } from './AccountStore';
|
import { AccountStore } from './AccountStore';
|
||||||
|
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||||
import { LogUtil } from './LogUtil';
|
import { LogUtil } from './LogUtil';
|
||||||
|
|
||||||
export class ReminderService {
|
export class ReminderService {
|
||||||
@@ -26,6 +27,39 @@ export class ReminderService {
|
|||||||
private static calNameCache: Map<string, string> = new Map();
|
private static calNameCache: Map<string, string> = new Map();
|
||||||
private static calNameLoadedAt: number = 0;
|
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' = 本机日历 */
|
/** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */
|
||||||
private static async calName(context: common.Context, calKey: string): Promise<string> {
|
private static async calName(context: common.Context, calKey: string): Promise<string> {
|
||||||
if (Date.now() - ReminderService.calNameLoadedAt > 60000
|
if (Date.now() - ReminderService.calNameLoadedAt > 60000
|
||||||
@@ -131,10 +165,18 @@ export class ReminderService {
|
|||||||
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
||||||
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
|
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
|
||||||
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
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) {
|
for (const e of rows) {
|
||||||
if (muted.includes(e.calKey)) {
|
if (muted.includes(e.calKey)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (mirrored.has(e.calKey)) {
|
||||||
|
continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复
|
||||||
|
}
|
||||||
if (published >= ReminderService.MAX_REMINDERS) {
|
if (published >= ReminderService.MAX_REMINDERS) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -211,11 +253,16 @@ export class ReminderService {
|
|||||||
const rows: LocalEvent[] =
|
const rows: LocalEvent[] =
|
||||||
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
||||||
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
||||||
|
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次")
|
||||||
|
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
|
||||||
let fired: number = 0;
|
let fired: number = 0;
|
||||||
for (const e of rows) {
|
for (const e of rows) {
|
||||||
if (muted.includes(e.calKey)) {
|
if (muted.includes(e.calKey)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (mirrored.has(e.calKey)) {
|
||||||
|
continue; // ⭐ 已镜像 → 交由系统日历提醒
|
||||||
|
}
|
||||||
const rems: number[] = e.reminders.length > 0 ? e.reminders
|
const rems: number[] = e.reminders.length > 0 ? e.reminders
|
||||||
: (e.reminder > 0 ? [e.reminder] : []);
|
: (e.reminder > 0 ? [e.reminder] : []);
|
||||||
for (const rem of rems) {
|
for (const rem of rems) {
|
||||||
|
|||||||
@@ -2,12 +2,29 @@
|
|||||||
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
|
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { DavAccount } from './AccountStore';
|
import { DavAccount, AccountStore } from './AccountStore';
|
||||||
import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
|
import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
|
||||||
import { IcsUtil } from './IcsUtil';
|
import { IcsUtil } from './IcsUtil';
|
||||||
import { DavClient, DavColorEntry, RemoteItem } from './DavClient';
|
import { DavClient, DavColorEntry, DavHttpError, RemoteItem } from './DavClient';
|
||||||
import { LogUtil } from './LogUtil';
|
import { LogUtil } from './LogUtil';
|
||||||
import { AppSettings } from './AppSettings';
|
import { AppSettings } from './AppSettings';
|
||||||
|
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ "已有同步任务正在进行"专用错误类型。
|
||||||
|
*
|
||||||
|
* 背景(用户反馈):后台同步进行中时,用户再点「手动同步」或在账号管理点「保存并同步」,
|
||||||
|
* 会并发触发第二轮同步;互斥锁把它拦下来,但那时抛的是普通 Error → 界面统一显示
|
||||||
|
* "同步失败:该账号正在同步中,请稍后再试"。用户看到"失败"会误以为出了问题。
|
||||||
|
*
|
||||||
|
* 正确做法:并发被拦下时**不是失败**,而应提示"有同步任务正在进行中,请耐心等待"。
|
||||||
|
* 因此用这个独立类型与真正的同步故障区分开,界面据此改提示语(而非报错)。
|
||||||
|
*/
|
||||||
|
export class SyncBusyError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('SYNC_BUSY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SyncEngine {
|
export class SyncEngine {
|
||||||
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
|
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
|
||||||
@@ -15,6 +32,27 @@ export class SyncEngine {
|
|||||||
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
|
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
|
||||||
private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000;
|
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);
|
const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id);
|
||||||
if (heldSince !== undefined) {
|
if (heldSince !== undefined) {
|
||||||
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
|
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
|
||||||
throw new Error('该账号正在同步中,请稍后再试');
|
// ⭐ 抛专用类型:界面据此提示"同步进行中,请耐心等待"而非"失败"
|
||||||
|
throw new SyncBusyError();
|
||||||
}
|
}
|
||||||
LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`);
|
LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`);
|
||||||
}
|
}
|
||||||
@@ -46,6 +85,18 @@ export class SyncEngine {
|
|||||||
// 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位
|
// 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位
|
||||||
LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
|
LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
|
||||||
try {
|
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 分钟
|
// 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟
|
||||||
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
|
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
|
||||||
const timeoutMs: number = fullRefetch ? 600000 : 300000;
|
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)} 秒`);
|
LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
|
||||||
return r;
|
return r;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// ⭐ "正在同步中"原样抛出(界面据此提示"请耐心等待",不当成失败)
|
||||||
|
if (err instanceof SyncBusyError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
|
LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
|
||||||
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
|
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> {
|
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
|
||||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||||
// 0) 刷新服务器端日历本颜色(每次同步都校正)
|
// 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) 推送该账号日历本下的本地修改
|
// 1) 推送该账号日历本下的本地修改
|
||||||
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
||||||
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
|
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
|
||||||
@@ -79,23 +150,68 @@ export class SyncEngine {
|
|||||||
LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS');
|
LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS');
|
||||||
}
|
}
|
||||||
let changed: number = 0;
|
let changed: number = 0;
|
||||||
|
// 服务器上已不存在的日历本(REPORT 404):先跳过继续同步其它日历本,
|
||||||
|
// 循环结束后统一从账号移除(见 removeDeadBooks)。
|
||||||
|
// 2026-09-29 实测:服务器上删掉一个日历本后,本地仍勾选它 → 每次同步 404 →
|
||||||
|
// 旧版直接抛错让整个账号同步失败,且后台同步每轮都失败(一天半没同步成功过)。
|
||||||
|
const deadIdxs: number[] = [];
|
||||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||||
const href: string = acc.calendarHrefs[i];
|
const href: string = acc.calendarHrefs[i];
|
||||||
const calKey: string = `${acc.id}_${i}`;
|
const calKey: string = `${acc.id}_${i}`;
|
||||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
|
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
|
||||||
const t1: number = Date.now();
|
const t1: number = Date.now();
|
||||||
const items: RemoteItem[] = await DavClient.reportEtags(href, auth);
|
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} 个资源`);
|
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
|
||||||
|
// ⭐ SYNC_TODOS 同步该本里的待办(VTODO)。
|
||||||
|
// 原来整个同步流程只处理 VEVENT,`DavClient.reportTodos` 写了却没人调用,
|
||||||
|
// 导致待办数据从来不会从服务器拉下来 ——
|
||||||
|
// 用户取消勾选本(触发 pruneStaleHrefs 清行)后再勾回来,待办就永久消失了。
|
||||||
|
// 放在这里(VEVENT 处理之前)单独走一遍,不影响原有日程逻辑。
|
||||||
|
try {
|
||||||
|
const todoItems: RemoteItem[] = await DavClient.reportTodos(href, auth);
|
||||||
|
if (todoItems.length > 0) {
|
||||||
|
const todoRemote: RemoteEvent[] = [];
|
||||||
|
for (const ti of todoItems) {
|
||||||
|
try {
|
||||||
|
const raw: string = await DavClient.getRawStrict(ti.href, auth);
|
||||||
|
const parsed: RemoteEvent[] = IcsUtil.parseTodos(raw);
|
||||||
|
for (const pr of parsed) {
|
||||||
|
pr.etag = ti.etag;
|
||||||
|
todoRemote.push(pr);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
LogUtil.write(`待办 GET 失败(跳过) ${ti.href}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (todoRemote.length > 0) {
|
||||||
|
await EventDb.applyRemote(context, calKey, href, todoRemote, true);
|
||||||
|
LogUtil.write(`日历本[${i}]「${calName}」待办同步:${todoRemote.length} 条`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 待办同步失败不能影响日程同步(很多服务器对 VTODO 查询返回 403/不支持)
|
||||||
|
LogUtil.write(`日历本[${i}]「${calName}」待办同步失败(忽略):${(e as BusinessError).message ?? ''}`);
|
||||||
|
}
|
||||||
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
||||||
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||||
const byUid: Map<string, LocalEvent[]> = new Map();
|
const byUid: Map<string, LocalEvent[]> = new Map();
|
||||||
|
// ⭐ 再按 **etag** 分组,给下面"要不要 GET 这个资源"做兜底(见 §复用判定 注释)
|
||||||
|
const byEtag: Map<string, LocalEvent[]> = new Map();
|
||||||
for (const row of existingRows) {
|
for (const row of existingRows) {
|
||||||
const arr: LocalEvent[] | undefined = byUid.get(row.uid);
|
SyncEngine.groupPush(byUid, row.uid, row);
|
||||||
if (arr === undefined) {
|
if (row.etag !== '') {
|
||||||
byUid.set(row.uid, [row]);
|
SyncEngine.groupPush(byEtag, row.etag, row);
|
||||||
} else {
|
|
||||||
arr.push(row);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
||||||
@@ -104,9 +220,27 @@ export class SyncEngine {
|
|||||||
let fetched: number = 0;
|
let fetched: number = 0;
|
||||||
let parseFail: number = 0;
|
let parseFail: number = 0;
|
||||||
const needFetch: RemoteItem[] = [];
|
const needFetch: RemoteItem[] = [];
|
||||||
|
let hitUid: number = 0; // 按"资源名 = uid"命中
|
||||||
|
let hitEtag: number = 0; // 按 etag 内容哈希兜底命中
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
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
|
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
|
||||||
&& group[0].etag === it.etag) {
|
&& group[0].etag === it.etag) {
|
||||||
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
||||||
@@ -117,6 +251,7 @@ export class SyncEngine {
|
|||||||
}
|
}
|
||||||
needFetch.push(it);
|
needFetch.push(it);
|
||||||
}
|
}
|
||||||
|
LogUtil.write(`日历本[${i}]「${calName}」复用判定:uid 命中 ${hitUid}、etag 兜底命中 ${hitEtag}、需补拉 ${needFetch.length}(共 ${items.length})`);
|
||||||
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
|
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
|
||||||
const batchSize: number = 5;
|
const batchSize: number = 5;
|
||||||
for (let b: number = 0; b < needFetch.length; b += batchSize) {
|
for (let b: number = 0; b < needFetch.length; b += batchSize) {
|
||||||
@@ -185,14 +320,102 @@ export class SyncEngine {
|
|||||||
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`);
|
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 收尾:把本轮发现的"服务器上已删除"的日历本从账号移除并清理本地数据。
|
||||||
|
// 放在循环后(而非循环中)是因为移除会让后续序号前移,循环中途改数组会打乱 calKey。
|
||||||
|
if (deadIdxs.length > 0) {
|
||||||
|
await SyncEngine.removeDeadBooks(context, acc, deadIdxs);
|
||||||
|
}
|
||||||
return changed;
|
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
|
* 刷新服务器端日历本颜色: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[] = [];
|
let entries: DavColorEntry[] = [];
|
||||||
try {
|
try {
|
||||||
entries = await DavClient.propfindColors(acc.serverUrl, auth);
|
entries = await DavClient.propfindColors(acc.serverUrl, auth);
|
||||||
@@ -210,9 +433,11 @@ export class SyncEngine {
|
|||||||
if (found !== undefined && found.color !== '') {
|
if (found !== undefined && found.color !== '') {
|
||||||
while (acc.calendarColors.length <= i) {
|
while (acc.calendarColors.length <= i) {
|
||||||
acc.calendarColors.push('');
|
acc.calendarColors.push('');
|
||||||
|
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
|
||||||
}
|
}
|
||||||
if (acc.calendarColors[i] !== found.color) {
|
if (acc.calendarColors[i] !== found.color) {
|
||||||
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
|
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
acc.calendarColors[i] = found.color;
|
acc.calendarColors[i] = found.color;
|
||||||
} else if (found === undefined) {
|
} else if (found === undefined) {
|
||||||
@@ -224,16 +449,32 @@ export class SyncEngine {
|
|||||||
acc.calendarWritable.push('1');
|
acc.calendarWritable.push('1');
|
||||||
}
|
}
|
||||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||||
|
let writable: string;
|
||||||
if (found !== undefined && found.privilegeKnown) {
|
if (found !== undefined && found.privilegeKnown) {
|
||||||
// 服务器声明了权限:直接采用
|
// 服务器声明了权限:直接采用
|
||||||
acc.calendarWritable[i] = found.writable ? '1' : '0';
|
writable = found.writable ? '1' : '0';
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
|
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
|
||||||
} else {
|
} else {
|
||||||
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
|
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
|
||||||
const w: boolean = await DavClient.probeWritable(target, auth);
|
const w: boolean = await DavClient.probeWritable(target, auth);
|
||||||
acc.calendarWritable[i] = w ? '1' : '0';
|
writable = w ? '1' : '0';
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
|
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 +489,7 @@ export class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
|
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
|
||||||
private static resUidFromHref(href: string): string {
|
private static resUidFromHref(href: string): string { const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||||
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
|
||||||
if (segs.length === 0) {
|
if (segs.length === 0) {
|
||||||
return String(Date.now());
|
return String(Date.now());
|
||||||
}
|
}
|
||||||
@@ -280,6 +520,7 @@ export class SyncEngine {
|
|||||||
r.completed = e.completed;
|
r.completed = e.completed;
|
||||||
r.rrule = e.rrule;
|
r.rrule = e.rrule;
|
||||||
r.exdate = e.exdate;
|
r.exdate = e.exdate;
|
||||||
|
r.recurrenceId = e.recurrenceId;
|
||||||
r.reminder = e.reminder;
|
r.reminder = e.reminder;
|
||||||
r.reminders = e.reminders.slice();
|
r.reminders = e.reminders.slice();
|
||||||
return r;
|
return r;
|
||||||
@@ -292,6 +533,9 @@ export class SyncEngine {
|
|||||||
const dirty: LocalEvent[] = await EventDb.getDirty(context);
|
const dirty: LocalEvent[] = await EventDb.getDirty(context);
|
||||||
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
|
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
|
||||||
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`);
|
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) {
|
for (const e of mine) {
|
||||||
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
|
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
|
||||||
const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
|
const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
|
||||||
@@ -305,30 +549,111 @@ export class SyncEngine {
|
|||||||
await EventDb.clearDirty(context, e.id, e.etag);
|
await EventDb.clearDirty(context, e.id, e.etag);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (e.recurring && e.rrule === '') {
|
// ⭐ 重复系列(含覆盖实例):按 uid 取全系列行,重建整份 .ics 后一次性 PUT。
|
||||||
// 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持
|
// 旧版对"覆盖实例"直接跳过(不推送),导致 App 里的单次改期/删除无法上传到服务器;
|
||||||
await EventDb.clearDirty(context, e.id, e.etag);
|
// 现在支持标准写法(同资源内多 VEVENT + RECURRENCE-ID)。
|
||||||
LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (e.recurring) {
|
if ((e.recurring || e.rrule !== '') && pushedSeries.has(seriesKey)) {
|
||||||
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送
|
continue; // 该系列已在上面整体推送过
|
||||||
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
|
|
||||||
}
|
}
|
||||||
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
||||||
if (e.deleted) {
|
try {
|
||||||
await DavClient.deleteRemote(url, auth);
|
if (e.deleted) {
|
||||||
await EventDb.purge(context, e.id);
|
await DavClient.deleteRemote(url, auth);
|
||||||
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
|
await EventDb.purge(context, e.id);
|
||||||
} else {
|
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
|
||||||
const ics: string = IcsUtil.build(e);
|
} else {
|
||||||
const etag: string = await DavClient.putEvent(url, auth, ics);
|
const ics: string = IcsUtil.build(e);
|
||||||
await EventDb.clearDirty(context, e.id, etag);
|
const etag: string = await DavClient.putEvent(url, auth, ics);
|
||||||
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`);
|
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) {
|
||||||
|
try {
|
||||||
|
await DavClient.deleteRemote(url, auth);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
|
||||||
|
throw err as Error;
|
||||||
|
}
|
||||||
|
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)的待推送状态:
|
* 清理本机虚拟日历(calKey=local)的待推送状态:
|
||||||
* 本机事件不参与 DAV 同步,直接落地
|
* 本机事件不参与 DAV 同步,直接落地
|
||||||
@@ -345,6 +670,16 @@ export class SyncEngine {
|
|||||||
await EventDb.clearDirty(context, e.id, '');
|
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 +700,19 @@ export class SyncEngine {
|
|||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`);
|
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) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`);
|
LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { calendarManager } from '@kit.CalendarKit';
|
import { calendarManager } from '@kit.CalendarKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { AccountStore, DavAccount } from './AccountStore';
|
|
||||||
import { EventDb, LocalEvent } from './EventDb';
|
import { EventDb, LocalEvent } from './EventDb';
|
||||||
import { AppSettings } from './AppSettings';
|
import { AppSettings } from './AppSettings';
|
||||||
import { LogUtil } from './LogUtil';
|
import { LogUtil } from './LogUtil';
|
||||||
|
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
|
||||||
|
|
||||||
export class SystemCalendarImport {
|
export class SystemCalendarImport {
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +16,15 @@ export class SystemCalendarImport {
|
|||||||
* 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。
|
* 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。
|
||||||
* 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程),
|
* 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程),
|
||||||
* 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。
|
* 系统 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> {
|
static async importIfNeeded(context: common.Context): Promise<number> {
|
||||||
try {
|
try {
|
||||||
@@ -23,30 +32,42 @@ export class SystemCalendarImport {
|
|||||||
if (mode !== 'backup') {
|
if (mode !== 'backup') {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const calKey: string = await AppSettings.getBackupCalKey(context);
|
// ⭐ 目标本一律走 `backupTargetRef()`:内部用**稳定标识 bookId** 解析,
|
||||||
if (calKey === '') {
|
// 并兼做「老 calKey 值 → bookId」的一次性迁移。
|
||||||
LogUtil.write('系统日历备份:未选择目标日历本,跳过导入');
|
// 解析不到时它会**自动把 sys_cal_mode 关回 display**(用户明确要求的行为):
|
||||||
|
// 目标本被取消同步/账号被删后,绝不能"随便挑一个本"把系统日历导进去。
|
||||||
|
const ref: BookRef | undefined = await SystemCalendarMirror.backupTargetRef(context);
|
||||||
|
if (ref === undefined) {
|
||||||
|
// 未选择目标本(或刚被判失效并关闭)→ 跳过。原因已由 backupTargetRef 写进日志。
|
||||||
|
LogUtil.write('系统日历备份:当前无有效目标日历本,跳过导入');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
// 定位目标 CalDAV 日历本
|
const calKey: string = ref.calKey; // 仅用于查本地库 / 落库
|
||||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
const targetHref: string = ref.href;
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (targetHref === '') {
|
if (targetHref === '') {
|
||||||
LogUtil.write(`系统日历备份:目标日历本 ${calKey} 不存在(账号可能已删除),跳过导入`);
|
LogUtil.write(`系统日历备份:目标日历本 ${ref.bookId} 缺少集合 URL,跳过导入`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const uiContext = context as common.UIAbilityContext;
|
const uiContext = context as common.UIAbilityContext;
|
||||||
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
|
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
|
||||||
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
|
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 imported: number = 0;
|
||||||
let skippedCal: number = 0;
|
let skippedCal: number = 0;
|
||||||
|
let skippedMirror: number = 0; // 本应用镜像出去的账户(不能导进备份本,否则重复)
|
||||||
for (const cal of calendars) {
|
for (const cal of calendars) {
|
||||||
try {
|
try {
|
||||||
// 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌
|
// 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌
|
||||||
@@ -55,6 +76,13 @@ export class SystemCalendarImport {
|
|||||||
skippedCal++;
|
skippedCal++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// ⚠️ 关键:跳过**本应用自己镜像出去的账户**(type 同样 LOCAL,但内容是我们从 CalDAV 写进去的)。
|
||||||
|
// 若一并导入备份本,同一条日程会在服务器里出现两份(原始本一份、备份本一份)→ 数据非常乱。
|
||||||
|
// 这些账户的改动由 MirrorInbound 负责回写到它**原本所属**的日历本,不走备份通道。
|
||||||
|
if (SystemCalendarMirror.isMirrorAccountName(account.name)) {
|
||||||
|
skippedMirror++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const events: calendarManager.Event[] = await cal.getEvents();
|
const events: calendarManager.Event[] = await cal.getEvents();
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
if (ev.id === undefined) {
|
if (ev.id === undefined) {
|
||||||
@@ -86,8 +114,9 @@ export class SystemCalendarImport {
|
|||||||
// 单个日历读取失败不影响其余
|
// 单个日历读取失败不影响其余
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (imported > 0 || skippedCal > 0) {
|
if (imported > 0 || skippedCal > 0 || skippedMirror > 0) {
|
||||||
LogUtil.write(`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过账户日历 ${skippedCal} 个)`);
|
LogUtil.write(
|
||||||
|
`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过非本地账户 ${skippedCal} 个、镜像账户 ${skippedMirror} 个)`);
|
||||||
}
|
}
|
||||||
return imported;
|
return imported;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -272,38 +272,43 @@ export class TimelineUtil {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 时间轴"基准可视区"的起止小时:8:00 ~ 18:00 **恒定显示**(与用户确认)。 */
|
||||||
|
static readonly BASE_START_HOUR: number = 8;
|
||||||
|
static readonly BASE_END_HOUR: number = 18;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算"需要显示的小时区间"(视窗截断):
|
* 计算"需要显示的小时区间"(视窗截断)。规则(与用户确认):
|
||||||
* - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点)
|
* - **8:00 ~ 18:00 恒定显示**(即使当天没有任何日程,也至少显示这一段,避免"只有一条日程
|
||||||
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见
|
* 就只显示那 1 小时"的奇怪观感);
|
||||||
|
* - 若某天在 **8 点之前**有日程 → 起点向前扩到最早日程所在整点;否则 8 点之前的刻度不显示;
|
||||||
|
* - 若某天在 **18 点之后**有日程 → 终点向后扩到最晚日程结束时刻向上取整;否则 18 点之后的刻度不显示。
|
||||||
|
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来以保证红线可见 —— 但**仍遵守上面的
|
||||||
|
* "无日程则不显示"约束**:只有当该侧本来就有日程(起点已 <8 / 终点已 >18)时才允许继续向外扩,
|
||||||
|
* 否则不会因为"当前时刻"而把 8 点前 / 18 点后的空档重新显示出来。
|
||||||
* 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。
|
* 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。
|
||||||
*/
|
*/
|
||||||
static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange {
|
static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange {
|
||||||
const rg = new ViewRange();
|
const rg = new ViewRange();
|
||||||
let s: number = -1;
|
// 基准:8~18 恒显示
|
||||||
let e: number = -1;
|
let s: number = TimelineUtil.BASE_START_HOUR;
|
||||||
|
let e: number = TimelineUtil.BASE_END_HOUR;
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
const bh: number = Math.floor(b.topRatio * 24);
|
const bh: number = Math.floor(b.topRatio * 24);
|
||||||
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
||||||
if (s < 0 || bh < s) {
|
if (bh < s) {
|
||||||
s = bh;
|
s = bh;
|
||||||
}
|
}
|
||||||
if (e < 0 || eh > e) {
|
if (eh > e) {
|
||||||
e = eh;
|
e = eh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (s < 0) {
|
|
||||||
s = 8;
|
|
||||||
}
|
|
||||||
if (e < 0) {
|
|
||||||
e = 20;
|
|
||||||
}
|
|
||||||
if (includeNowHour) {
|
if (includeNowHour) {
|
||||||
const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24);
|
const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24);
|
||||||
if (nh < s) {
|
// 只有"该侧本来就有日程"时才允许把当前小时并进来(否则会违反"8 点前/18 点后无日程则不显示")
|
||||||
|
if (nh < s && s < TimelineUtil.BASE_START_HOUR) {
|
||||||
s = nh;
|
s = nh;
|
||||||
}
|
}
|
||||||
if (nh + 1 > e) {
|
if (nh + 1 > e && e > TimelineUtil.BASE_END_HOUR) {
|
||||||
e = nh + 1;
|
e = nh + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export struct TipsPanel {
|
|||||||
{
|
{
|
||||||
icon: $r('app.media.tip_calendar'),
|
icon: $r('app.media.tip_calendar'),
|
||||||
title: '系统日历,也能一起管',
|
title: '系统日历,也能一起管',
|
||||||
desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴;还能把系统日历本通过 CalDAV 同步备份,' +
|
desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴,也能把系统日历本通过 CalDAV 同步备份,' +
|
||||||
'一处编辑、处处更新。'
|
'一处编辑、处处更新。到「设置 → 混合显示系统日历」开启并授权即可。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $r('app.media.tip_mute'),
|
icon: $r('app.media.tip_mute'),
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { DavAccount } from './AccountStore';
|
||||||
|
import { AccountStore } from './AccountStore';
|
||||||
|
import { LocalEvent } from './EventDb';
|
||||||
|
import { EventDb } from './EventDb';
|
||||||
|
import { DavClient, DavHttpError } from './DavClient';
|
||||||
|
import { IcsUtil } from './IcsUtil';
|
||||||
|
import { LogUtil } from './LogUtil';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待办(VTODO)操作。
|
||||||
|
*
|
||||||
|
* 现状:待办**只读展示**已实现(拉取/解析/入库/列表),本类补上唯一的写操作
|
||||||
|
* ——「勾选完成 / 取消完成」。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么不做"从本地字段重建 VTODO 再 PUT":
|
||||||
|
* VTODO 上有 SUMMARY / DESCRIPTION / DUE / PRIORITY / CATEGORIES / X-* 等字段,
|
||||||
|
* 本地库只存了其中几个。重建后 PUT 等于把没存的字段**全部抹掉**。
|
||||||
|
* 所以走"GET 原文 → 就地改 STATUS/COMPLETED/PERCENT-COMPLETE → PUT"。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么不能同步到手机系统待办:
|
||||||
|
* 系统未开放待办 API(@ohos.calendarManager 只有日程/VEVENT,无 Todo 对象),
|
||||||
|
* 第三方应用无法读写系统日历的「待办」页签。详见工单 #5。
|
||||||
|
*/
|
||||||
|
export class TodoService {
|
||||||
|
/**
|
||||||
|
* 设置待办完成状态并回写服务器。
|
||||||
|
*
|
||||||
|
* @param localId 本地 events 表行 id(DisplayEvent.id)
|
||||||
|
* @param done true=标记完成;false=取消完成
|
||||||
|
* @returns 成功返回 true;失败返回 false(**本地状态不变**,避免界面与服务器不一致)
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 更新待办内容(标题 / 备注 / 截止时间)。
|
||||||
|
*
|
||||||
|
* 同样走 GET 原文 → 改字段 → PUT:
|
||||||
|
* VTODO 上有本地库没保存的字段(PRIORITY / CATEGORIES / X-* 等),
|
||||||
|
* 重建整份会把这些抹掉。
|
||||||
|
*
|
||||||
|
* 只改 SUMMARY / DESCRIPTION / DUE 三项,**不动完成状态**
|
||||||
|
* (用户改的是内容,不该顺带把状态重置了)。
|
||||||
|
*
|
||||||
|
* @param dueMs 0 = 清除截止时间(删除 DUE 行)
|
||||||
|
*/
|
||||||
|
static async updateTodo(context: common.Context, localId: number, title: string,
|
||||||
|
desc: string, dueMs: number): Promise<boolean> {
|
||||||
|
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||||
|
if (row === null || row.kind !== 'todo') {
|
||||||
|
LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.href === '' || row.remotePath === '') {
|
||||||
|
LogUtil.write(`待办更新失败:缺少服务器路径 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href);
|
||||||
|
if (accResolved === null) {
|
||||||
|
LogUtil.write(`待办更新失败:找不到所属账号`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const idx: number = accResolved.calendarHrefs.indexOf(row.href);
|
||||||
|
if (idx >= 0 && accResolved.calendarWritable.length > idx
|
||||||
|
&& accResolved.calendarWritable[idx] === '0') {
|
||||||
|
LogUtil.write(`待办更新失败:该日历本只读 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auth: string = DavClient.authHeader(accResolved.username, accResolved.password);
|
||||||
|
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
|
||||||
|
: `${row.href}/${row.remotePath}`;
|
||||||
|
try {
|
||||||
|
const original: string = await DavClient.getRawStrict(url, auth);
|
||||||
|
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
|
||||||
|
LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs);
|
||||||
|
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||||
|
await EventDb.updateTodoFields(context, localId, title, desc,
|
||||||
|
dueMs > 0 ? dueMs : 0, newEtag);
|
||||||
|
LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DavHttpError) {
|
||||||
|
LogUtil.write(`待办更新失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
|
||||||
|
} else {
|
||||||
|
LogUtil.write(`待办更新失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除待办(DELETE 服务器资源 + 本地软删) */
|
||||||
|
static async deleteTodo(context: common.Context, localId: number): Promise<boolean> {
|
||||||
|
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||||
|
if (row === null || row.kind !== 'todo') {
|
||||||
|
LogUtil.write(`待办删除失败:行不存在或不是待办 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.href === '' || row.remotePath === '') {
|
||||||
|
LogUtil.write(`待办删除失败:缺少服务器路径 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href);
|
||||||
|
if (accResolved === null) {
|
||||||
|
LogUtil.write(`待办删除失败:找不到所属账号`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const idx: number = accResolved.calendarHrefs.indexOf(row.href);
|
||||||
|
if (idx >= 0 && accResolved.calendarWritable.length > idx
|
||||||
|
&& accResolved.calendarWritable[idx] === '0') {
|
||||||
|
LogUtil.write(`待办删除失败:该日历本只读 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auth: string = DavClient.authHeader(accResolved.username, accResolved.password);
|
||||||
|
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
|
||||||
|
: `${row.href}/${row.remotePath}`;
|
||||||
|
try {
|
||||||
|
await DavClient.deleteRemote(url, auth);
|
||||||
|
await EventDb.purge(context, localId);
|
||||||
|
LogUtil.write(`待办删除成功 id=${localId}(标题已脱敏)`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DavHttpError) {
|
||||||
|
LogUtil.write(`待办删除失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
|
||||||
|
} else {
|
||||||
|
LogUtil.write(`待办删除失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 href 找所属账号(updateTodo/deleteTodo 共用) */
|
||||||
|
private static async findAccount(context: common.Context, href: string): Promise<DavAccount | null> {
|
||||||
|
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (a.calendarHrefs.includes(href)) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置或取消待办的循环规则。
|
||||||
|
*
|
||||||
|
* @param rrule 规则原文(如 `FREQ=WEEKLY`);空串 = 取消循环
|
||||||
|
* @param dtstartMs 重复基准;<=0 时用当前 DUE 兜底
|
||||||
|
* @returns 成功 true
|
||||||
|
*
|
||||||
|
* ⚠️ 写回时**同时**给 DTSTART 和 DUE:
|
||||||
|
* RFC 5545 里 VTODO 的 DTSTART 是 OPTIONAL,但没有它时
|
||||||
|
* 不同客户端对 RRULE 基准的理解不一致(有的按 DUE 算),
|
||||||
|
* 显式给 DTSTART 能消除歧义。
|
||||||
|
*/
|
||||||
|
static async setRecurrence(context: common.Context, localId: number,
|
||||||
|
rrule: string, dtstartMs: number): Promise<boolean> {
|
||||||
|
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||||
|
if (row === null || row.kind !== 'todo') {
|
||||||
|
LogUtil.write(`待办循环设置失败:行不存在或不是待办 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.href === '' || row.remotePath === '') {
|
||||||
|
LogUtil.write(`待办循环设置失败:缺少服务器路径 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const acc: DavAccount | null = await TodoService.findAccount(context, row.href);
|
||||||
|
if (acc === null) {
|
||||||
|
LogUtil.write(`待办循环设置失败:找不到所属账号`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const idx: number = acc.calendarHrefs.indexOf(row.href);
|
||||||
|
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
|
||||||
|
LogUtil.write(`待办循环设置失败:该日历本只读 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||||
|
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
|
||||||
|
: `${row.href}/${row.remotePath}`;
|
||||||
|
const dueMs: number = row.startTime > 0 ? row.startTime : 0;
|
||||||
|
const base: number = dtstartMs > 0 ? dtstartMs : (dueMs > 0 ? dueMs : Date.now());
|
||||||
|
try {
|
||||||
|
const original: string = await DavClient.getRawStrict(url, auth);
|
||||||
|
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
|
||||||
|
LogUtil.write(`待办循环设置失败:服务器内容不是 VTODO id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const updated: string = IcsUtil.rewriteTodoRecurrence(
|
||||||
|
original, row.uid, rrule, base, dueMs);
|
||||||
|
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||||
|
await EventDb.updateTodoRecurrence(context, localId, rrule.trim(), base, newEtag);
|
||||||
|
LogUtil.write(`待办循环设置成功 id=${localId} rule=${rrule === '' ? '(取消)' : rrule}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DavHttpError) {
|
||||||
|
LogUtil.write(`待办循环设置失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
|
||||||
|
} else {
|
||||||
|
LogUtil.write(`待办循环设置失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 循环任务"完成一轮":把 DUE 推进到下一个周期,并保持未完成。
|
||||||
|
*
|
||||||
|
* 与 setCompleted 的区别:
|
||||||
|
* · 非循环 → 走 setCompleted(置 COMPLETED,永久结束)
|
||||||
|
* · 循环 → 走这里(DUE += 一个周期,STATUS 保持 NEEDS-ACTION)
|
||||||
|
*
|
||||||
|
* @returns 推进后的新 DUE(毫秒);失败返回 0
|
||||||
|
*/
|
||||||
|
static async completeRecurringRound(context: common.Context, localId: number): Promise<number> {
|
||||||
|
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||||
|
if (row === null || row.kind !== 'todo' || row.rrule.trim() === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (row.href === '' || row.remotePath === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const acc: DavAccount | null = await TodoService.findAccount(context, row.href);
|
||||||
|
if (acc === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const idx: number = acc.calendarHrefs.indexOf(row.href);
|
||||||
|
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 从 FREQ 取周期,推下一个基准
|
||||||
|
const freq: string = IcsUtil.freqOfRrule(row.rrule);
|
||||||
|
// 基准优先用 DTSTART(真正的重复基准),没有则用当前 DUE
|
||||||
|
const base: number = row.dtstart > 0 ? row.dtstart : row.startTime;
|
||||||
|
const nextBase: number = IcsUtil.incForFreq(freq, base);
|
||||||
|
// 保留"截止 = 基准 + N"的偏移(原任务若是 09:00 截止,下一次还是 09:00)
|
||||||
|
const offset: number = row.startTime > 0 && row.dtstart > 0 ? row.startTime - row.dtstart : 0;
|
||||||
|
const nextDue: number = nextBase + offset;
|
||||||
|
|
||||||
|
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||||
|
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
|
||||||
|
: `${row.href}/${row.remotePath}`;
|
||||||
|
try {
|
||||||
|
const original: string = await DavClient.getRawStrict(url, auth);
|
||||||
|
// 先把状态复位成未完成,再把基准与截止推到下一轮
|
||||||
|
let updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, false);
|
||||||
|
updated = IcsUtil.rewriteTodoRecurrence(updated, row.uid, row.rrule, nextBase, nextDue);
|
||||||
|
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||||
|
await EventDb.updateTodoAfterRound(context, localId, nextBase, nextDue, newEtag);
|
||||||
|
LogUtil.write(`循环待办推进到下一轮 id=${localId}(标题已脱敏)`);
|
||||||
|
return nextDue > 0 ? nextDue : nextBase;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DavHttpError) {
|
||||||
|
LogUtil.write(`循环待办推进失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
|
||||||
|
} else {
|
||||||
|
LogUtil.write(`循环待办推进失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async setCompleted(context: common.Context, localId: number, done: boolean): Promise<boolean> {
|
||||||
|
const row: LocalEvent | null = await EventDb.queryById(context, localId);
|
||||||
|
if (row === null) {
|
||||||
|
LogUtil.write(`待办完成失败:本地行不存在 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.kind !== 'todo') {
|
||||||
|
LogUtil.write(`待办完成失败:该行不是待办 id=${localId} kind=${row.kind}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.href === '' || row.remotePath === '') {
|
||||||
|
LogUtil.write(`待办完成失败:缺少服务器路径(不可写)id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.completed === done) {
|
||||||
|
return true; // 状态没变化,直接算成功(幂等)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 找到该 href 所属账号,构造鉴权
|
||||||
|
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||||
|
let acc: DavAccount | null = null;
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (a.calendarHrefs.includes(row.href)) {
|
||||||
|
acc = a;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (acc === null) {
|
||||||
|
LogUtil.write(`待办完成失败:找不到所属账号 href=${row.href}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 只读日历本:推送必然 403,提前拦下给出明确结果
|
||||||
|
const idx: number = acc.calendarHrefs.indexOf(row.href);
|
||||||
|
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
|
||||||
|
LogUtil.write(`待办完成失败:该日历本只读 id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||||
|
const url: string = row.href.endsWith('/') ? row.href + row.remotePath : `${row.href}/${row.remotePath}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2) GET 原文(保住本地没存的字段)
|
||||||
|
const original: string = await DavClient.getRawStrict(url, auth);
|
||||||
|
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
|
||||||
|
LogUtil.write(`待办完成失败:服务器返回的内容不是 VTODO id=${localId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 只改状态相关字段
|
||||||
|
const updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, done);
|
||||||
|
|
||||||
|
// 4) PUT 回去,拿新 etag
|
||||||
|
const newEtag: string = await DavClient.putEvent(url, auth, updated);
|
||||||
|
|
||||||
|
// 5) 同步本地(成功后才改,保证两边一致)
|
||||||
|
await EventDb.setTodoCompleted(context, localId, done, newEtag);
|
||||||
|
LogUtil.write(`待办${done ? '完成' : '取消完成'}成功 id=${localId}(标题已脱敏)`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DavHttpError) {
|
||||||
|
const he = err as DavHttpError;
|
||||||
|
LogUtil.write(`待办写回失败 HTTP ${he.status}:id=${localId}(本地状态未改)`);
|
||||||
|
} else {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write(`待办写回失败:id=${localId} ${e.message ?? ''}(本地状态未改)`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,31 @@
|
|||||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration, EnvironmentCallback } from '@kit.AbilityKit';
|
||||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||||
import { window } from '@kit.ArkUI';
|
import { window } from '@kit.ArkUI';
|
||||||
import { notificationManager } from '@kit.NotificationKit';
|
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
|
||||||
import { BackgroundSyncService } from '../common/BackgroundSyncService';
|
import { BackgroundSyncService } from '../common/BackgroundSyncService';
|
||||||
|
import { NetworkSyncWatcher } from '../common/NetworkSyncWatcher';
|
||||||
|
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
import { AppSettings } from '../common/AppSettings';
|
|
||||||
|
|
||||||
const DOMAIN = 0x0000;
|
const DOMAIN = 0x0000;
|
||||||
|
|
||||||
export default class EntryAbility extends UIAbility {
|
export default class EntryAbility extends UIAbility {
|
||||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||||
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log
|
// 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
|
||||||
LogUtil.init(this.context);
|
LogUtil.init(this.context);
|
||||||
LogUtil.write('---- App 启动 ----');
|
LogUtil.write('---- App 启动 ----');
|
||||||
// 后台同步:注册延迟任务兜底 + 按设置恢复长时任务
|
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
|
||||||
|
// ⚠️ 不再申请"长时任务":dataTransfer 类型有 10 分钟无进度即被取消的一致性校验,
|
||||||
|
// 日历同步不符合该语义、必被取消(详见 BackgroundSyncService 顶部注释)。
|
||||||
BackgroundSyncService.initOnLaunch(this.context);
|
BackgroundSyncService.initOnLaunch(this.context);
|
||||||
|
NetworkSyncWatcher.start(this.context);
|
||||||
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
|
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
|
||||||
this.handleWidgetParam(want);
|
this.handleWidgetParam(want);
|
||||||
// 通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框)。
|
// ⚠️ 这里**不再申请任何权限**(含通知权限)。
|
||||||
// 必须在用户同意《隐私政策》之后才申请 —— 同意前不得申请任何权限;
|
// 依据《审核指南》7.17:应用不得在用户未主动点击对应功能时提前弹窗申请权限。
|
||||||
// 首次安装此处跳过,用户点"同意并继续"后由 Index.initPermissionAndLoad() 补申请。
|
// 通知权限改为在用户主动操作时申请:
|
||||||
AppSettings.getPolicyAgreed(this.context)
|
// ① 保存带提醒的日程(EventEditPage)② 开启「后台持续同步」(SettingsPage)
|
||||||
.then((agreed: boolean): Promise<void> => {
|
// ③ 设置页「通知权限」→「去开启通知」按钮
|
||||||
if (!agreed) {
|
|
||||||
LogUtil.write('未同意隐私政策:跳过通知权限申请');
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return notificationManager.isNotificationEnabled()
|
|
||||||
.then((enabled: boolean): Promise<void> => {
|
|
||||||
if (!enabled) {
|
|
||||||
return notificationManager.requestEnableNotification(this.context).catch((): void => {});
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((): void => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(): void {
|
onDestroy(): void {
|
||||||
@@ -82,38 +71,120 @@ export default class EntryAbility extends UIAbility {
|
|||||||
|
|
||||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
|
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
|
||||||
|
// ① 先把应用固定为「跟随系统」深浅色。必须在取窗口/刷系统栏之前调用,
|
||||||
|
// 否则会读到尚未更新到深色的色彩配置(曾导致深色下状态栏仍是浅灰)。
|
||||||
|
try {
|
||||||
|
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||||
|
} catch (err) {
|
||||||
|
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 沉浸式配色:状态栏/导航条背景**透明**,透出「主题化」的窗口底色,
|
||||||
|
// 消除顶部通知栏与底部灰条;前景(时间/图标)颜色按实际底色亮度取反。
|
||||||
|
try {
|
||||||
|
const win: window.Window = windowStage.getMainWindowSync();
|
||||||
|
this.mainWindow = win;
|
||||||
|
this.applySystemBars(win);
|
||||||
|
} catch (err) {
|
||||||
|
hilog.error(DOMAIN, 'testTag', 'Failed to apply immersive bars. Cause: %{public}s', JSON.stringify(err));
|
||||||
|
}
|
||||||
|
|
||||||
windowStage.loadContent('pages/Index', (err) => {
|
windowStage.loadContent('pages/Index', (err) => {
|
||||||
if (err.code) {
|
if (err.code) {
|
||||||
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
|
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
||||||
// 沉浸式配色:状态栏/导航条与页面背景同色(app.color.page_bg = #F1F3F5),
|
|
||||||
// 消除顶部通知栏和底部的白色条
|
|
||||||
windowStage.getMainWindow((werr: BusinessError, win: window.Window) => {
|
|
||||||
if (werr.code) {
|
|
||||||
hilog.error(DOMAIN, 'testTag', 'Failed to get main window. Cause: %{public}s', JSON.stringify(werr));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
win.setWindowSystemBarProperties({
|
|
||||||
statusBarColor: '#F1F3F5',
|
|
||||||
statusBarContentColor: '#182431',
|
|
||||||
navigationBarColor: '#F1F3F5',
|
|
||||||
navigationBarContentColor: '#182431'
|
|
||||||
});
|
|
||||||
// 鸿蒙 NEXT 导航条背景跟随窗口背景色:设为页面背景灰,消除底部白条
|
|
||||||
win.setWindowBackgroundColor('#F1F3F5');
|
|
||||||
} catch (barErr) {
|
|
||||||
hilog.error(DOMAIN, 'testTag', 'Failed to set system bar properties. Cause: %{public}s',
|
|
||||||
JSON.stringify(barErr));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ③ 系统深浅色切换时重刷配色。用 applicationContext 的 environment 回调,
|
||||||
|
// 它比 UIAbility.onConfigurationUpdate 更可靠(后者在部分场景下不回调,
|
||||||
|
// 会出现"页面已变深色但状态栏/底部仍是浅灰"的现象)。
|
||||||
try {
|
try {
|
||||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
const cb: EnvironmentCallback = {
|
||||||
|
onConfigurationUpdated: (config: Configuration): void => {
|
||||||
|
if (this.mainWindow !== undefined) {
|
||||||
|
this.applySystemBars(this.mainWindow);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMemoryLevel: (level: AbilityConstant.MemoryLevel): void => {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.context.getApplicationContext().on('environment', cb);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
hilog.error(DOMAIN, 'testTag', 'Failed to watch environment. Cause: %{public}s', JSON.stringify(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主窗口引用:系统深浅色切换时需要重新刷状态栏/导航栏配色 */
|
||||||
|
private mainWindow?: window.Window;
|
||||||
|
|
||||||
|
/** 当前是否处于系统深色模式(兜底用) */
|
||||||
|
private isDarkMode(): boolean {
|
||||||
|
try {
|
||||||
|
return this.context.config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取当前主题下 app.color.page_bg 的真实取值(经 dark/ 限定符解析):
|
||||||
|
* 浅色 #F1F3F5 / 深色 #000000。以"资源系统实际解析出的颜色"为准,
|
||||||
|
* 这样才能与页面真正渲染出来的背景色一致,不受 config 更新时机影响。
|
||||||
|
*/
|
||||||
|
private themePageBg(): string {
|
||||||
|
try {
|
||||||
|
const c: number = this.context.resourceManager.getColorSync($r('app.color.page_bg').id) >>> 0;
|
||||||
|
return '#' + c.toString(16).padStart(8, '0');
|
||||||
|
} catch (err) {
|
||||||
|
return this.isDarkMode() ? '#000000' : '#F1F3F5';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 背景是深色时需要浅色前景,反之用深色前景 */
|
||||||
|
private isDarkBg(bg: string): boolean {
|
||||||
|
const s: string = bg.startsWith('#') ? bg.substring(1) : bg;
|
||||||
|
if (s.length < 6) {
|
||||||
|
return this.isDarkMode();
|
||||||
|
}
|
||||||
|
const off: number = s.length >= 8 ? 2 : 0;
|
||||||
|
const r: number = parseInt(s.substring(off, off + 2), 16);
|
||||||
|
const g: number = parseInt(s.substring(off + 2, off + 4), 16);
|
||||||
|
const b: number = parseInt(s.substring(off + 4, off + 6), 16);
|
||||||
|
if (isNaN(r) || isNaN(g) || isNaN(b)) {
|
||||||
|
return this.isDarkMode();
|
||||||
|
}
|
||||||
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前主题设置状态栏/导航条与窗口底色。
|
||||||
|
* 背景取 0x00000000(全透明)——官方推荐的背景沉浸方案:系统栏透明,
|
||||||
|
* 透传应用自身背景,因此底色天然跟随深浅色,不会再出现恒定灰条。
|
||||||
|
*/
|
||||||
|
private applySystemBars(win: window.Window): void {
|
||||||
|
const bgColor: string = this.themePageBg();
|
||||||
|
const fgColor: string = this.isDarkBg(bgColor) ? '#FFFFFF' : '#182431';
|
||||||
|
try {
|
||||||
|
win.setWindowSystemBarProperties({
|
||||||
|
statusBarColor: '#00000000',
|
||||||
|
statusBarContentColor: fgColor,
|
||||||
|
navigationBarColor: '#00000000',
|
||||||
|
navigationBarContentColor: fgColor
|
||||||
|
});
|
||||||
|
// 窗口底色=页面背景色,供透明的状态栏/导航条透出
|
||||||
|
win.setWindowBackgroundColor(bgColor);
|
||||||
|
} catch (barErr) {
|
||||||
|
hilog.error(DOMAIN, 'testTag', 'Failed to set system bar properties. Cause: %{public}s',
|
||||||
|
JSON.stringify(barErr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兜底:部分设备/场景只回调 UIAbility.onConfigurationUpdate */
|
||||||
|
onConfigurationUpdate(): void {
|
||||||
|
if (this.mainWindow !== undefined) {
|
||||||
|
this.applySystemBars(this.mainWindow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +194,15 @@ export default class EntryAbility extends UIAbility {
|
|||||||
|
|
||||||
onForeground(): void {
|
onForeground(): void {
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||||
|
// ⭐ 回到前台:① 归还短时任务配额 ② 立刻补同步一次(用户真正要看时数据必须最新)
|
||||||
|
BackgroundSyncService.cancelTransientSync();
|
||||||
|
BackgroundSyncRunner.runOnce(this.context, 'foreground');
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackground(): void {
|
onBackground(): void {
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||||
|
// ⭐ 退到后台:申请短时任务,兜住"退后台后那一次"同步能跑完
|
||||||
|
// (必须在退后台 5 秒内申请;单次最长 3 分钟,单日配额 10 分钟)
|
||||||
|
BackgroundSyncService.requestTransientSync('正在同步日历数据');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,21 @@
|
|||||||
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
|
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
|
||||||
import { Want } from '@kit.AbilityKit';
|
import { Want } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import process from '@ohos.process';
|
||||||
import { CardDataService, CardData } from '../common/CardDataService';
|
import { CardDataService, CardData } from '../common/CardDataService';
|
||||||
|
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
|
||||||
export default class EntryFormAbility extends FormExtensionAbility {
|
export default class EntryFormAbility extends FormExtensionAbility {
|
||||||
|
/** 当前进程 pid(取不到返回 -1):用于判断卡片回调是否与主 App 同进程 */
|
||||||
|
private static pidOf(): number {
|
||||||
|
try {
|
||||||
|
return process.pid;
|
||||||
|
} catch (err) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 组装更新数据并推送到指定卡片 */
|
/** 组装更新数据并推送到指定卡片 */
|
||||||
private pushData(formId: string, dayOffset: number = 0): void {
|
private pushData(formId: string, dayOffset: number = 0): void {
|
||||||
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
|
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
|
||||||
@@ -40,10 +51,30 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
|||||||
|
|
||||||
onUpdateForm(formId: string): void {
|
onUpdateForm(formId: string): void {
|
||||||
LogUtil.init(this.context);
|
LogUtil.init(this.context);
|
||||||
LogUtil.write(`系统触发卡片更新: formId=${formId}`);
|
LogUtil.write(`系统触发卡片更新: formId=${formId}(进程 pid=${EntryFormAbility.pidOf()})`);
|
||||||
CardDataService.registerForm(this.context, formId);
|
CardDataService.registerForm(this.context, formId);
|
||||||
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
|
// ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
|
||||||
|
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
|
||||||
this.pushData(formId, CardDataService.getDayOffset(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 {
|
onRemoveForm(formId: string): void {
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
// entry/src/main/ets/pages/AccountsPage.ets
|
// entry/src/main/ets/pages/AccountsPage.ets
|
||||||
// DAV 账号管理(由原 CalDAVSync 首页移植)
|
// DAV 账号管理(由原 CalDAVSync 首页移植)
|
||||||
import { router } from '@kit.ArkUI';
|
import { curves, router } from '@kit.ArkUI';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore';
|
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 { EditNavParams } from './EditAccountPage';
|
||||||
import { EventDb } from '../common/EventDb';
|
import { EventDb } from '../common/EventDb';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
import { AppSettings } from '../common/AppSettings';
|
import { AppSettings } from '../common/AppSettings';
|
||||||
import { ScreenKeeper } from '../common/ScreenKeeper';
|
import { ScreenKeeper } from '../common/ScreenKeeper';
|
||||||
|
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||||
|
|
||||||
@Entry
|
@Entry
|
||||||
@Component
|
@Component
|
||||||
@@ -90,6 +91,12 @@ struct AccountsPage {
|
|||||||
this.getUIContext().getPromptAction()
|
this.getUIContext().getPromptAction()
|
||||||
.showToast({ message: `「${acc.name}」同步完成` });
|
.showToast({ message: `「${acc.name}」同步完成` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// ⭐ 已有同步在进行中 → 提示"请耐心等待",不报"失败"(避免用户误解)
|
||||||
|
if (err instanceof SyncBusyError) {
|
||||||
|
this.getUIContext().getPromptAction()
|
||||||
|
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
this.getUIContext().getPromptAction()
|
this.getUIContext().getPromptAction()
|
||||||
.showToast({ message: `「${acc.name}」同步失败:${e.message}` });
|
.showToast({ message: `「${acc.name}」同步失败:${e.message}` });
|
||||||
@@ -142,13 +149,24 @@ struct AccountsPage {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行删除:账号配置 + 该账号全部本地日程/待办 */
|
/** 执行删除:账号配置 + 该账号全部本地日程/待办 + 该系统日历中该账号的全部镜像日历本 */
|
||||||
private async confirmDeleteAccount(acc: DavAccount): Promise<void> {
|
private async confirmDeleteAccount(acc: DavAccount): Promise<void> {
|
||||||
const context = this.getUIContext().getHostContext();
|
const context = this.getUIContext().getHostContext();
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
|
||||||
// force=true:删除最后一个账号时列表为空是合法场景,允许覆盖
|
// force=true:删除最后一个账号时列表为空是合法场景,允许覆盖
|
||||||
await AccountStore.saveAll(context, this.accounts, true);
|
await AccountStore.saveAll(context, this.accounts, true);
|
||||||
@@ -156,7 +174,11 @@ struct AccountsPage {
|
|||||||
await EventDb.deleteAccountEvents(context, acc.id);
|
await EventDb.deleteAccountEvents(context, acc.id);
|
||||||
await AppSettings.cleanupAccountSettings(context, acc.id);
|
await AppSettings.cleanupAccountSettings(context, acc.id);
|
||||||
this.getUIContext().getPromptAction()
|
this.getUIContext().getPromptAction()
|
||||||
.showToast({ message: `「${acc.name}」已删除` });
|
.showToast({
|
||||||
|
message: mirrored > 0
|
||||||
|
? `「${acc.name}」已删除,并清除系统日历中 ${mirrored} 个镜像日历本`
|
||||||
|
: `「${acc.name}」已删除`
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
this.getUIContext().getPromptAction()
|
this.getUIContext().getPromptAction()
|
||||||
@@ -247,25 +269,48 @@ struct AccountsPage {
|
|||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
|
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||||
|
.backgroundColor($r('app.color.page_bg'))
|
||||||
|
.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
|
@Builder
|
||||||
emptyState() {
|
emptyState() {
|
||||||
Column({ space: 12 }) {
|
Column({ space: 12 }) {
|
||||||
Text('+')
|
// ⭐ 空状态的「+」本身可点击 → 直接进入添加账号页(此前只是个静态图标,点了没反应)
|
||||||
.fontSize(30)
|
Button() {
|
||||||
.fontColor($r('app.color.text_hint'))
|
Text('+')
|
||||||
.width(72)
|
.fontSize(34)
|
||||||
.height(72)
|
.fontColor($r('app.color.brand'))
|
||||||
.textAlign(TextAlign.Center)
|
.fontWeight(FontWeight.Medium)
|
||||||
.borderRadius(20)
|
}
|
||||||
.backgroundColor($r('app.color.card_bg'))
|
.width(84)
|
||||||
.border({ width: 1.5, color: $r('app.color.shadow_color') })
|
.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 账号')
|
Text('还没有任何 CalDAV 账号')
|
||||||
.fontSize(16)
|
.fontSize(16)
|
||||||
.fontWeight(FontWeight.Medium)
|
.fontWeight(FontWeight.Medium)
|
||||||
.fontColor($r('app.color.text_primary'))
|
.fontColor($r('app.color.text_primary'))
|
||||||
Text('点击右下角 + 添加 CalDAV 账号')
|
Text('点击上方 + 或右下角 + 添加 CalDAV 账号')
|
||||||
.fontSize(13)
|
.fontSize(13)
|
||||||
.fontColor($r('app.color.text_secondary'))
|
.fontColor($r('app.color.text_secondary'))
|
||||||
.textAlign(TextAlign.Center)
|
.textAlign(TextAlign.Center)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// entry/src/main/ets/pages/AddAccountPage.ets
|
// entry/src/main/ets/pages/AddAccountPage.ets
|
||||||
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
|
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
|
||||||
import { http } from '@kit.NetworkKit';
|
import { http } from '@kit.NetworkKit';
|
||||||
import { router } from '@kit.ArkUI';
|
import { curves, router } from '@kit.ArkUI';
|
||||||
import { buffer } from '@kit.ArkTS';
|
import { buffer } from '@kit.ArkTS';
|
||||||
import url from '@ohos.url';
|
import url from '@ohos.url';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
|
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
|
||||||
|
import { DavDiscovery, DavCapability } from '../common/DavDiscovery';
|
||||||
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
|
||||||
@Entry
|
@Entry
|
||||||
@Component
|
@Component
|
||||||
@@ -107,12 +109,30 @@ struct AddAccountPage {
|
|||||||
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
|
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
|
||||||
}
|
}
|
||||||
if (code === 401) {
|
if (code === 401) {
|
||||||
this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码';
|
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
return 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) {
|
if (code >= 200 && code < 500) {
|
||||||
this.statusMsg = '服务器连接成功';
|
// ⭐ 即使可连,也顺手提示"地址看起来不完整",让用户提前知道下一步会自动补全
|
||||||
|
if (DavDiscovery.looksIncomplete(serverUrl)) {
|
||||||
|
this.statusMsg = '服务器连接成功(该地址看起来是服务器根路径,下一步会自动尝试定位日历家目录)';
|
||||||
|
} else {
|
||||||
|
this.statusMsg = '服务器连接成功';
|
||||||
|
}
|
||||||
this.statusOk = true;
|
this.statusOk = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -186,6 +206,17 @@ struct AddAccountPage {
|
|||||||
if (this.isLoading) {
|
if (this.isLoading) {
|
||||||
return;
|
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.isLoading = true;
|
||||||
this.statusMsg = '正在连接服务器…';
|
this.statusMsg = '正在连接服务器…';
|
||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
@@ -229,7 +260,7 @@ struct AddAccountPage {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
|
|
||||||
Column({ space: 16 }) {
|
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) => {
|
(value: string) => {
|
||||||
this.serverUrl = value;
|
this.serverUrl = value;
|
||||||
}, false)
|
}, false)
|
||||||
@@ -292,9 +323,24 @@ struct AddAccountPage {
|
|||||||
.height('100%')
|
.height('100%')
|
||||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||||
.backgroundColor($r('app.color.page_bg'))
|
.backgroundColor($r('app.color.page_bg'))
|
||||||
|
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
.alignItems(HorizontalAlign.Center)
|
.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
|
@Builder
|
||||||
formField(label: string, placeholder: string, value: string,
|
formField(label: string, placeholder: string, value: string,
|
||||||
onChange: (value: string) => void, isPassword: boolean) {
|
onChange: (value: string) => void, isPassword: boolean) {
|
||||||
@@ -310,6 +356,10 @@ struct AddAccountPage {
|
|||||||
.fontSize(15)
|
.fontSize(15)
|
||||||
.backgroundColor($r('app.color.input_bg'))
|
.backgroundColor($r('app.color.input_bg'))
|
||||||
.borderRadius(8)
|
.borderRadius(8)
|
||||||
|
// 显式指定文字/提示/光标颜色:默认取系统主题色,深色模式下会与底色撞色而"看不清"
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.placeholderColor($r('app.color.text_hint'))
|
||||||
|
.caretColor($r('app.color.brand'))
|
||||||
.onChange(onChange)
|
.onChange(onChange)
|
||||||
}
|
}
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// entry/src/main/ets/pages/CalendarListPage.ets
|
// entry/src/main/ets/pages/CalendarListPage.ets
|
||||||
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
|
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
|
||||||
import { router } from '@kit.ArkUI';
|
import { curves, router } from '@kit.ArkUI';
|
||||||
import { buffer } from '@kit.ArkTS';
|
import { buffer } from '@kit.ArkTS';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
||||||
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
||||||
|
import { DavDiscovery, DiscoverResult, DavCapability } from '../common/DavDiscovery';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,17 +99,44 @@ struct CalendarListPage {
|
|||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
return;
|
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(与「编辑账号」页共用同一实现,避免重复正则逻辑)
|
// PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑)
|
||||||
try {
|
try {
|
||||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, 'Basic ' + token);
|
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, auth);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
if (disc.statusCode === 401) {
|
if (disc.statusCode === 401) {
|
||||||
this.statusMsg = '登录已失效,请返回重新连接';
|
this.statusMsg = '服务器拒绝凭据(401):请返回检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
|
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
|
||||||
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
|
// ⭐ 0.0.7:把生硬的状态码换成人话 + 可操作指引
|
||||||
|
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
|
||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -118,7 +146,8 @@ struct CalendarListPage {
|
|||||||
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
|
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
|
||||||
}
|
}
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)';
|
// ⭐ 0.0.7:区分"地址层级不对"与"确实没有日历",并给出正确填法示例
|
||||||
|
this.statusMsg = DavDiscovery.emptyHint(this.serverUrl, this.username);
|
||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
} else {
|
} else {
|
||||||
this.calendarList = list;
|
this.calendarList = list;
|
||||||
@@ -249,6 +278,7 @@ struct CalendarListPage {
|
|||||||
.fontSize(16)
|
.fontSize(16)
|
||||||
.fontColor($r('app.color.text_primary'))
|
.fontColor($r('app.color.text_primary'))
|
||||||
.placeholderColor($r('app.color.text_hint'))
|
.placeholderColor($r('app.color.text_hint'))
|
||||||
|
.caretColor($r('app.color.brand'))
|
||||||
.backgroundColor($r('app.color.input_bg'))
|
.backgroundColor($r('app.color.input_bg'))
|
||||||
.borderRadius(10)
|
.borderRadius(10)
|
||||||
.border({ width: 1.5, color: $r('app.color.brand') })
|
.border({ width: 1.5, color: $r('app.color.brand') })
|
||||||
@@ -364,8 +394,23 @@ struct CalendarListPage {
|
|||||||
.height('100%')
|
.height('100%')
|
||||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||||
.backgroundColor($r('app.color.page_bg'))
|
.backgroundColor($r('app.color.page_bg'))
|
||||||
|
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
.alignItems(HorizontalAlign.Center)
|
.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
|
@Component
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
// entry/src/main/ets/pages/EditAccountPage.ets
|
// 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 { common } from '@kit.AbilityKit';
|
||||||
import { buffer } from '@kit.ArkTS';
|
import { buffer } from '@kit.ArkTS';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { DavAccount, AccountStore } from '../common/AccountStore';
|
import { DavAccount, AccountStore } from '../common/AccountStore';
|
||||||
|
import { DavDiscovery, DiscoverResult } from '../common/DavDiscovery';
|
||||||
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
||||||
import { EventDb } from '../common/EventDb';
|
import { EventDb } from '../common/EventDb';
|
||||||
|
import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,14 +24,27 @@ export class EditCalendarItem {
|
|||||||
href: string;
|
href: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string; // 服务器定义的颜色(calendar-color),可能为空
|
color: string; // 服务器定义的颜色(calendar-color),可能为空
|
||||||
|
/** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */
|
||||||
|
comps: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
|
||||||
constructor(href: string, name: string, color: string) {
|
constructor(href: string, name: string, color: string, comps: string = 'unknown') {
|
||||||
this.href = href;
|
this.href = href;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.color = color;
|
this.color = color;
|
||||||
|
this.comps = comps;
|
||||||
this.selected = false;
|
this.selected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 是否待办本(纯待办或混合) */
|
||||||
|
isTodoBook(): boolean {
|
||||||
|
return this.comps === 'todo' || this.comps === 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否日历本(纯日历、混合、或服务器未声明) */
|
||||||
|
isEventBook(): boolean {
|
||||||
|
return this.comps === 'event' || this.comps === 'both' || this.comps === 'unknown';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,24 +155,47 @@ struct EditAccountPage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现)
|
// PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现)
|
||||||
|
const auth: string = 'Basic ' + token;
|
||||||
|
// ⭐ 0.0.7:地址自动发现/智能补全(老账号可能当初填的是 DAV 根或已失效的路径)
|
||||||
try {
|
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;
|
this.isLoading = false;
|
||||||
if (disc.statusCode === 401) {
|
if (disc.statusCode === 401) {
|
||||||
this.statusMsg = '登录已失效,请检查账号密码';
|
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
|
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
|
||||||
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
|
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
|
||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
|
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
|
||||||
new EditCalendarItem(e.href, e.displayName, e.color));
|
new EditCalendarItem(e.href, e.displayName, e.color, e.comps));
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
LogUtil.write('编辑账号:未发现任何日历本');
|
LogUtil.write('编辑账号:未发现任何日历本');
|
||||||
this.statusMsg = '该路径下未发现日历本';
|
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
|
||||||
this.statusOk = false;
|
this.statusOk = false;
|
||||||
} else {
|
} else {
|
||||||
// 已勾选的日历本按账号当前配置预选
|
// 已勾选的日历本按账号当前配置预选
|
||||||
@@ -217,8 +259,42 @@ struct EditAccountPage {
|
|||||||
target.calendarHrefs = newHrefs;
|
target.calendarHrefs = newHrefs;
|
||||||
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
|
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
|
||||||
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
|
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
|
||||||
|
target.calendarComps = selectedItems.map((c: EditCalendarItem): string => c.comps);
|
||||||
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
|
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
|
||||||
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
|
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
|
||||||
|
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
|
||||||
|
// 序号收窄后,被取消本的旧 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]> = [];
|
const remapPairs: Array<[string, string]> = [];
|
||||||
for (let oi: number = 0; oi < oldHrefs.length; oi++) {
|
for (let oi: number = 0; oi < oldHrefs.length; oi++) {
|
||||||
const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]);
|
const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]);
|
||||||
@@ -243,8 +319,15 @@ struct EditAccountPage {
|
|||||||
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
|
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
|
||||||
await EventDb.pruneAccountEvents(context, target.id, validKeys);
|
await EventDb.pruneAccountEvents(context, target.id, validKeys);
|
||||||
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
|
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
|
||||||
this.getUIContext().getPromptAction()
|
// ⭐ 若刚被取消勾选的本正是「系统日历 → CalDAV」的备份目标,**当场**判它失效并关闭备份功能。
|
||||||
.showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` });
|
// 不这么做的话,要等下一次同步才会发现目标没了;这期间开关还显示"备份到 CalDAV",
|
||||||
|
// 用户会以为备份仍在正常工作,而实际已经导不进去(或更糟:导进了别的本)。
|
||||||
|
const backupCheck: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context);
|
||||||
|
this.getUIContext().getPromptAction().showToast({
|
||||||
|
message: backupCheck.closed
|
||||||
|
? '原系统日历备份目标已被取消同步,备份功能已自动关闭'
|
||||||
|
: `已保存,同步 ${selectedItems.length} 个日历本`
|
||||||
|
});
|
||||||
router.back();
|
router.back();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
@@ -318,6 +401,7 @@ struct EditAccountPage {
|
|||||||
.fontSize(16)
|
.fontSize(16)
|
||||||
.fontColor($r('app.color.text_primary'))
|
.fontColor($r('app.color.text_primary'))
|
||||||
.placeholderColor($r('app.color.text_hint'))
|
.placeholderColor($r('app.color.text_hint'))
|
||||||
|
.caretColor($r('app.color.brand'))
|
||||||
.backgroundColor($r('app.color.input_bg'))
|
.backgroundColor($r('app.color.input_bg'))
|
||||||
.borderRadius(10)
|
.borderRadius(10)
|
||||||
.border({ width: 1.5, color: $r('app.color.brand') })
|
.border({ width: 1.5, color: $r('app.color.brand') })
|
||||||
@@ -434,8 +518,23 @@ struct EditAccountPage {
|
|||||||
.height('100%')
|
.height('100%')
|
||||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||||
.backgroundColor($r('app.color.page_bg'))
|
.backgroundColor($r('app.color.page_bg'))
|
||||||
|
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
.alignItems(HorizontalAlign.Center)
|
.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
|
@Component
|
||||||
@@ -445,6 +544,22 @@ struct EditCalendarRow {
|
|||||||
|
|
||||||
build() {
|
build() {
|
||||||
Row({ space: 12 }) {
|
Row({ space: 12 }) {
|
||||||
|
// ⭐ SVG_ICON 类型图标:放在颜色圆点**前面**,
|
||||||
|
// 让用户先看清"这本是日历还是待办",再看它是什么颜色。
|
||||||
|
//
|
||||||
|
// 用 SVG 而不是字符:字符图标(▦ / ✓)依赖系统字体字形,
|
||||||
|
// 笔画粗细与视觉重量不统一,跨设备还可能缺字形变成方框。
|
||||||
|
// 这两个 SVG 共用同一套笔触(stroke 2、round 端点、24×24),
|
||||||
|
// 渲染成 16vp 时重量一致。
|
||||||
|
//
|
||||||
|
// 混合本(both):显示待办图标(它确实能存待办),
|
||||||
|
// 但用户看名字/颜色仍能分辨,不必塞两个图标挤在一行。
|
||||||
|
Image(this.item.comps === 'todo' || this.item.comps === 'both'
|
||||||
|
? $r('app.media.ic_todo_book')
|
||||||
|
: $r('app.media.ic_event_book'))
|
||||||
|
.width(16)
|
||||||
|
.height(16)
|
||||||
|
.objectFit(ImageFit.Contain)
|
||||||
if (this.item.color !== '') {
|
if (this.item.color !== '') {
|
||||||
Circle()
|
Circle()
|
||||||
.width(12)
|
.width(12)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1071
-176
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,12 @@ struct Widget2x2Card {
|
|||||||
@LocalStorageProp('dateMd') dateMd: string = '';
|
@LocalStorageProp('dateMd') dateMd: string = '';
|
||||||
@LocalStorageProp('weekday') weekday: string = '';
|
@LocalStorageProp('weekday') weekday: string = '';
|
||||||
@LocalStorageProp('todayCount') todayCount: number = 0;
|
@LocalStorageProp('todayCount') todayCount: number = 0;
|
||||||
|
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
|
||||||
|
@LocalStorageProp('selectedDate') selectedDate: number = 0;
|
||||||
|
|
||||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */
|
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||||
|
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||||
|
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||||
@Builder
|
@Builder
|
||||||
addButton() {
|
addButton() {
|
||||||
Button() {
|
Button() {
|
||||||
@@ -35,12 +39,12 @@ struct Widget2x2Card {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 卡片"打开 App":点击日期区或今日日程数区域触发;添加按钮是 header 行的兄弟节点,其点击不会冒泡到这里 */
|
/** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
|
||||||
private openApp(): void {
|
private openApp(): void {
|
||||||
postCardAction(this, {
|
postCardAction(this, {
|
||||||
action: 'router',
|
action: 'router',
|
||||||
abilityName: 'EntryAbility',
|
abilityName: 'EntryAbility',
|
||||||
params: {}
|
params: { openDate: `${this.selectedDate}` }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +65,6 @@ struct Widget2x2Card {
|
|||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
|
|
||||||
this.addButton()
|
this.addButton()
|
||||||
}
|
}
|
||||||
@@ -92,12 +95,13 @@ struct Widget2x2Card {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.backgroundColor('#FFFFFF')
|
.backgroundColor('#FFFFFF')
|
||||||
.borderRadius(16)
|
.borderRadius(16)
|
||||||
|
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日(原来只有日期区/条数区可点)
|
||||||
|
.onClick(() => this.openApp())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ struct Widget4x2Card {
|
|||||||
@LocalStorageProp('lunarText') lunarText: string = '';
|
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||||
@LocalStorageProp('todayCount') todayCount: number = 0;
|
@LocalStorageProp('todayCount') todayCount: number = 0;
|
||||||
@LocalStorageProp('ongoingJson') ongoingJson: string = '[]';
|
@LocalStorageProp('ongoingJson') ongoingJson: string = '[]';
|
||||||
|
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
|
||||||
|
@LocalStorageProp('selectedDate') selectedDate: number = 0;
|
||||||
|
|
||||||
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */
|
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */
|
||||||
private parseOngoing(): CardItem2x4[] {
|
private parseOngoing(): CardItem2x4[] {
|
||||||
@@ -57,12 +59,12 @@ struct Widget4x2Card {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 卡片"打开 App":点击左侧日期区、今日条数区、右侧日程区触发;添加按钮是 header 行兄弟节点,不冒泡 */
|
/** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
|
||||||
private openApp(): void {
|
private openApp(): void {
|
||||||
postCardAction(this, {
|
postCardAction(this, {
|
||||||
action: 'router',
|
action: 'router',
|
||||||
abilityName: 'EntryAbility',
|
abilityName: 'EntryAbility',
|
||||||
params: {}
|
params: { openDate: `${this.selectedDate}` }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +120,6 @@ struct Widget4x2Card {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.justifyContent(FlexAlign.Center)
|
.justifyContent(FlexAlign.Center)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
@@ -141,7 +142,6 @@ struct Widget4x2Card {
|
|||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
|
|
||||||
this.addButton()
|
this.addButton()
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,6 @@ struct Widget4x2Card {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.height('100%')
|
.height('100%')
|
||||||
@@ -197,7 +196,6 @@ struct Widget4x2Card {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.justifyContent(FlexAlign.Center)
|
.justifyContent(FlexAlign.Center)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
@@ -208,5 +206,7 @@ struct Widget4x2Card {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
.backgroundColor('#FFFFFF')
|
.backgroundColor('#FFFFFF')
|
||||||
.borderRadius(16)
|
.borderRadius(16)
|
||||||
|
// ⭐ 整卡可点:左右空白处也能进 App 并定位到当前显示日
|
||||||
|
.onClick(() => this.openApp())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,40 +110,32 @@ struct Widget4x4Card {
|
|||||||
return r >= 0 && Math.floor(r * 24) === h;
|
return r >= 0 && Math.floor(r * 24) === h;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。
|
/** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
|
||||||
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */
|
* (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
|
||||||
private w4Range(): W4Range {
|
private w4Range(): W4Range {
|
||||||
const rg = new W4Range();
|
const rg = new W4Range();
|
||||||
const bs: TBlock[] = this.parseBlocks();
|
const bs: TBlock[] = this.parseBlocks();
|
||||||
let s: number = -1;
|
let s: number = 8;
|
||||||
let e: number = -1;
|
let e: number = 18;
|
||||||
for (const b of bs) {
|
for (const b of bs) {
|
||||||
const bh: number = Math.floor(b.topRatio * 24);
|
const bh: number = Math.floor(b.topRatio * 24);
|
||||||
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
||||||
if (s < 0 || bh < s) {
|
if (bh < s) {
|
||||||
s = bh;
|
s = bh;
|
||||||
}
|
}
|
||||||
if (e < 0 || eh > e) {
|
if (eh > e) {
|
||||||
e = eh;
|
e = eh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const r: number = this.w4NowR();
|
const r: number = this.w4NowR();
|
||||||
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
|
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
|
||||||
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程
|
// 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
|
||||||
const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1;
|
const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1;
|
||||||
if (s < 0) {
|
if (nh >= 0 && nh < s && s < 8) {
|
||||||
s = nh >= 0 ? nh : 8;
|
s = nh;
|
||||||
}
|
}
|
||||||
if (e < 0) {
|
if (nh >= 0 && nh + 1 > e && e > 18) {
|
||||||
e = nh >= 0 ? nh + 1 : 20;
|
e = nh + 1;
|
||||||
}
|
|
||||||
if (nh >= 0) {
|
|
||||||
if (nh < s) {
|
|
||||||
s = nh;
|
|
||||||
}
|
|
||||||
if (nh + 1 > e) {
|
|
||||||
e = nh + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (s < 0) {
|
if (s < 0) {
|
||||||
s = 0;
|
s = 0;
|
||||||
@@ -395,7 +387,9 @@ struct Widget4x4Card {
|
|||||||
return h < 10 ? `0${h}` : `${h}`;
|
return h < 10 ? `0${h}` : `${h}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡到其它点击区) */
|
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||||
|
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||||
|
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||||
@Builder
|
@Builder
|
||||||
addButton() {
|
addButton() {
|
||||||
Button() {
|
Button() {
|
||||||
@@ -450,6 +444,7 @@ struct Widget4x4Card {
|
|||||||
.borderRadius(11)
|
.borderRadius(11)
|
||||||
.padding(0)
|
.padding(0)
|
||||||
.backgroundColor('#F2F3F5')
|
.backgroundColor('#F2F3F5')
|
||||||
|
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
|
||||||
.onClick(() => this.page(act))
|
.onClick(() => this.page(act))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +465,7 @@ struct Widget4x4Card {
|
|||||||
.borderRadius(11)
|
.borderRadius(11)
|
||||||
.padding(0)
|
.padding(0)
|
||||||
.backgroundColor('#007DFF')
|
.backgroundColor('#007DFF')
|
||||||
|
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
|
||||||
.onClick(() => this.page('today'))
|
.onClick(() => this.page('today'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,7 +558,6 @@ struct Widget4x4Card {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.justifyContent(FlexAlign.Center)
|
.justifyContent(FlexAlign.Center)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
} else {
|
} else {
|
||||||
List({ space: 4 }) {
|
List({ space: 4 }) {
|
||||||
ForEach(this.parseList(), (item: LItem4) => {
|
ForEach(this.parseList(), (item: LItem4) => {
|
||||||
@@ -576,7 +571,6 @@ struct Widget4x4Card {
|
|||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.scrollBar(BarState.Auto)
|
.scrollBar(BarState.Auto)
|
||||||
.cachedCount(8)
|
.cachedCount(8)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +591,6 @@ struct Widget4x4Card {
|
|||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
this.pageBtn('›', 'next')
|
this.pageBtn('›', 'next')
|
||||||
this.sideBtn()
|
this.sideBtn()
|
||||||
}
|
}
|
||||||
@@ -638,7 +631,6 @@ struct Widget4x4Card {
|
|||||||
.padding({ left: 5, right: 5 })
|
.padding({ left: 5, right: 5 })
|
||||||
.borderRadius(5)
|
.borderRadius(5)
|
||||||
.backgroundColor(a.color)
|
.backgroundColor(a.color)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`)
|
}, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`)
|
||||||
if (this.w4AllDayRest() > 0) {
|
if (this.w4AllDayRest() > 0) {
|
||||||
Text(`还有 ${this.w4AllDayRest()} 个全天日程`)
|
Text(`还有 ${this.w4AllDayRest()} 个全天日程`)
|
||||||
@@ -698,6 +690,9 @@ struct Widget4x4Card {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
.backgroundColor('#FFFFFF')
|
.backgroundColor('#FFFFFF')
|
||||||
.borderRadius(16)
|
.borderRadius(16)
|
||||||
|
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
|
||||||
|
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
|
||||||
|
.onClick(() => this.openApp())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
||||||
@@ -823,7 +818,6 @@ struct Widget4x4Card {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.height(this.w4BlockVp(b))
|
.height(this.w4BlockVp(b))
|
||||||
.constraintSize({ minHeight: 14 })
|
.constraintSize({ minHeight: 14 })
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}, (b: TBlock) => `b_${r.key}_${b.eventKey}`)
|
}, (b: TBlock) => `b_${r.key}_${b.eventKey}`)
|
||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
|
|||||||
@@ -109,40 +109,32 @@ struct Widget6x4Card {
|
|||||||
return r >= 0 && Math.floor(r * 24) === h;
|
return r >= 0 && Math.floor(r * 24) === h;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。
|
/** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
|
||||||
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */
|
* (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
|
||||||
private w6Range(): W6Range {
|
private w6Range(): W6Range {
|
||||||
const rg = new W6Range();
|
const rg = new W6Range();
|
||||||
const bs: TBlock6[] = this.parseBlocks();
|
const bs: TBlock6[] = this.parseBlocks();
|
||||||
let s: number = -1;
|
let s: number = 8;
|
||||||
let e: number = -1;
|
let e: number = 18;
|
||||||
for (const b of bs) {
|
for (const b of bs) {
|
||||||
const bh: number = Math.floor(b.topRatio * 24);
|
const bh: number = Math.floor(b.topRatio * 24);
|
||||||
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
||||||
if (s < 0 || bh < s) {
|
if (bh < s) {
|
||||||
s = bh;
|
s = bh;
|
||||||
}
|
}
|
||||||
if (e < 0 || eh > e) {
|
if (eh > e) {
|
||||||
e = eh;
|
e = eh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const r: number = this.w6NowR();
|
const r: number = this.w6NowR();
|
||||||
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
|
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
|
||||||
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程
|
// 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
|
||||||
const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1;
|
const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1;
|
||||||
if (s < 0) {
|
if (nh >= 0 && nh < s && s < 8) {
|
||||||
s = nh >= 0 ? nh : 8;
|
s = nh;
|
||||||
}
|
}
|
||||||
if (e < 0) {
|
if (nh >= 0 && nh + 1 > e && e > 18) {
|
||||||
e = nh >= 0 ? nh + 1 : 20;
|
e = nh + 1;
|
||||||
}
|
|
||||||
if (nh >= 0) {
|
|
||||||
if (nh < s) {
|
|
||||||
s = nh;
|
|
||||||
}
|
|
||||||
if (nh + 1 > e) {
|
|
||||||
e = nh + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (s < 0) {
|
if (s < 0) {
|
||||||
s = 0;
|
s = 0;
|
||||||
@@ -392,7 +384,9 @@ struct Widget6x4Card {
|
|||||||
return h < 10 ? `0${h}:00` : `${h}:00`;
|
return h < 10 ? `0${h}:00` : `${h}:00`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡) */
|
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||||
|
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||||
|
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||||
@Builder
|
@Builder
|
||||||
addButton() {
|
addButton() {
|
||||||
Button() {
|
Button() {
|
||||||
@@ -447,6 +441,7 @@ struct Widget6x4Card {
|
|||||||
.borderRadius(11)
|
.borderRadius(11)
|
||||||
.padding(0)
|
.padding(0)
|
||||||
.backgroundColor('#F2F3F5')
|
.backgroundColor('#F2F3F5')
|
||||||
|
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
|
||||||
.onClick(() => this.page(act))
|
.onClick(() => this.page(act))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +460,7 @@ struct Widget6x4Card {
|
|||||||
.borderRadius(11)
|
.borderRadius(11)
|
||||||
.padding(0)
|
.padding(0)
|
||||||
.backgroundColor('#007DFF')
|
.backgroundColor('#007DFF')
|
||||||
|
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
|
||||||
.onClick(() => this.page('today'))
|
.onClick(() => this.page('today'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,7 +553,6 @@ struct Widget6x4Card {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.justifyContent(FlexAlign.Center)
|
.justifyContent(FlexAlign.Center)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
} else {
|
} else {
|
||||||
List({ space: 4 }) {
|
List({ space: 4 }) {
|
||||||
ForEach(this.parseList(), (item: LItem6) => {
|
ForEach(this.parseList(), (item: LItem6) => {
|
||||||
@@ -571,7 +566,6 @@ struct Widget6x4Card {
|
|||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.scrollBar(BarState.Auto)
|
.scrollBar(BarState.Auto)
|
||||||
.cachedCount(8)
|
.cachedCount(8)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +586,6 @@ struct Widget6x4Card {
|
|||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
Text(`${this.dayCount} 条`)
|
Text(`${this.dayCount} 条`)
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.fontColor('#B0B0B0')
|
.fontColor('#B0B0B0')
|
||||||
@@ -637,7 +630,6 @@ struct Widget6x4Card {
|
|||||||
.padding({ left: 6, right: 6 })
|
.padding({ left: 6, right: 6 })
|
||||||
.borderRadius(6)
|
.borderRadius(6)
|
||||||
.backgroundColor(a.color)
|
.backgroundColor(a.color)
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`)
|
}, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`)
|
||||||
if (this.w6AllDayRest() > 0) {
|
if (this.w6AllDayRest() > 0) {
|
||||||
Text(`还有 ${this.w6AllDayRest()} 个全天日程`)
|
Text(`还有 ${this.w6AllDayRest()} 个全天日程`)
|
||||||
@@ -697,6 +689,9 @@ struct Widget6x4Card {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
.backgroundColor('#FFFFFF')
|
.backgroundColor('#FFFFFF')
|
||||||
.borderRadius(16)
|
.borderRadius(16)
|
||||||
|
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
|
||||||
|
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
|
||||||
|
.onClick(() => this.openApp())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
||||||
@@ -822,7 +817,6 @@ struct Widget6x4Card {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.height(this.w6BlockVp(b))
|
.height(this.w6BlockVp(b))
|
||||||
.constraintSize({ minHeight: 16 })
|
.constraintSize({ minHeight: 16 })
|
||||||
.onClick(() => this.openApp())
|
|
||||||
}, (b: TBlock6) => `b_${r.key}_${b.eventKey}`)
|
}, (b: TBlock6) => `b_${r.key}_${b.eventKey}`)
|
||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
// entry/src/main/ets/syncwork/SyncWorkAbility.ets
|
// entry/src/main/ets/syncwork/SyncWorkAbility.ets
|
||||||
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步
|
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步。
|
||||||
// 注意:必须幂等——同步本身基于 ETag 全量比对,天然幂等;执行完必须 stopWork,否则系统按超时处理
|
//
|
||||||
|
// ⚠️ 约束(勿改):
|
||||||
|
// - 单次回调**上限 2 分钟**,超时进程被系统终止 → 逻辑必须轻量(本类只调 Runner,不做事后处理)。
|
||||||
|
// - 周期由 BackgroundSyncService 设为 2 小时(系统活跃分组下限),此处不再重复设置。
|
||||||
|
// - 必须幂等:同步基于 etag 全量比对 + dirty 标记,天然幂等。
|
||||||
|
// - 必须主动 stopWork,否则系统按超时处理。
|
||||||
import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit';
|
import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||||
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 { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
|
||||||
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||||
@@ -24,47 +23,9 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
|||||||
|
|
||||||
private async doSync(workInfo: workScheduler.WorkInfo): Promise<void> {
|
private async doSync(workInfo: workScheduler.WorkInfo): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const context = this.context;
|
// 完整的后台同步流程(导入/对账/逐账号同步/镜像/卡片/提醒)统一由 Runner 实现,
|
||||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
// 与"网络恢复补同步"、"回到前台补同步"共用同一套逻辑,避免三处实现漂移。
|
||||||
// 备份模式:先导入系统本地日历(幂等),再随同步推送
|
await BackgroundSyncRunner.runOnce(this.context, 'deferred');
|
||||||
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}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
// 必须主动结束,否则系统按 120 秒超时处理
|
// 必须主动结束,否则系统按 120 秒超时处理
|
||||||
workScheduler.stopWork(workInfo);
|
workScheduler.stopWork(workInfo);
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ohos.permission.INTERNET"
|
"name": "ohos.permission.INTERNET"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "ohos.permission.PUBLISH_AGENT_REMINDER"
|
"name": "ohos.permission.PUBLISH_AGENT_REMINDER"
|
||||||
},
|
},
|
||||||
@@ -39,18 +36,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ohos.permission.LOCATION",
|
"name": "ohos.permission.WRITE_CALENDAR",
|
||||||
"reason": "$string:perm_location",
|
"reason": "$string:perm_write_calendar",
|
||||||
"usedScene": {
|
|
||||||
"abilities": [
|
|
||||||
"EntryAbility"
|
|
||||||
],
|
|
||||||
"when": "inuse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ohos.permission.APPROXIMATELY_LOCATION",
|
|
||||||
"reason": "$string:perm_location_approx",
|
|
||||||
"usedScene": {
|
"usedScene": {
|
||||||
"abilities": [
|
"abilities": [
|
||||||
"EntryAbility"
|
"EntryAbility"
|
||||||
@@ -73,9 +60,6 @@
|
|||||||
"startWindowBackground": "$color:start_window_background",
|
"startWindowBackground": "$color:start_window_background",
|
||||||
"exported": true,
|
"exported": true,
|
||||||
"orientation": "auto_rotation_restricted",
|
"orientation": "auto_rotation_restricted",
|
||||||
"backgroundModes": [
|
|
||||||
"dataTransfer"
|
|
||||||
],
|
|
||||||
"skills": [
|
"skills": [
|
||||||
{
|
{
|
||||||
"entities": [
|
"entities": [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{ "name": "text_secondary", "value": "#66000000" },
|
{ "name": "text_secondary", "value": "#66000000" },
|
||||||
{ "name": "text_hint", "value": "#4D000000" },
|
{ "name": "text_hint", "value": "#4D000000" },
|
||||||
{ "name": "brand", "value": "#007DFF" },
|
{ "name": "brand", "value": "#007DFF" },
|
||||||
|
{ "name": "brand_text", "value": "#0057C2" },
|
||||||
{ "name": "brand_disabled", "value": "#99007DFF" },
|
{ "name": "brand_disabled", "value": "#99007DFF" },
|
||||||
{ "name": "button_text", "value": "#FFFFFF" },
|
{ "name": "button_text", "value": "#FFFFFF" },
|
||||||
{ "name": "success", "value": "#00B34A" },
|
{ "name": "success", "value": "#00B34A" },
|
||||||
@@ -18,6 +19,8 @@
|
|||||||
{ "name": "grid_line", "value": "#14000000" },
|
{ "name": "grid_line", "value": "#14000000" },
|
||||||
{ "name": "today_bg", "value": "#14007DFF" },
|
{ "name": "today_bg", "value": "#14007DFF" },
|
||||||
{ "name": "selected_bg", "value": "#007DFF" },
|
{ "name": "selected_bg", "value": "#007DFF" },
|
||||||
{ "name": "chip_off_bg", "value": "#0D000000" }
|
{ "name": "chip_off_bg", "value": "#0D000000" },
|
||||||
|
{ "name": "event_mark", "value": "#D93025" },
|
||||||
|
{ "name": "soft_red", "value": "#F2938A" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -18,20 +18,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "perm_write_calendar",
|
"name": "perm_write_calendar",
|
||||||
"value": "写入系统日历,用于保存您创建的日程"
|
"value": "写入系统日历,用于把您选择的 CalDAV 日历本镜像到系统日历,让小艺、桌面日历卡片和手表也能看到您的日程"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "perm_read_whole_calendar",
|
"name": "perm_read_whole_calendar",
|
||||||
"value": "读取所有日历账户的日程,用于在日历中统一展示"
|
"value": "读取所有日历账户的日程,用于在日历中统一展示"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "perm_location",
|
|
||||||
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "perm_location_approx",
|
|
||||||
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "card_ability_desc",
|
"name": "card_ability_desc",
|
||||||
"value": "同步日历服务卡片"
|
"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 |
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"color": [
|
||||||
|
{ "name": "start_window_background", "value": "#000000" },
|
||||||
|
{ "name": "page_bg", "value": "#000000" },
|
||||||
|
{ "name": "card_bg", "value": "#1C1C1E" },
|
||||||
|
{ "name": "input_bg", "value": "#2C2C2E" },
|
||||||
|
{ "name": "text_primary", "value": "#E5E5E7" },
|
||||||
|
{ "name": "text_secondary", "value": "#99FFFFFF" },
|
||||||
|
{ "name": "text_hint", "value": "#66FFFFFF" },
|
||||||
|
{ "name": "brand", "value": "#0A84FF" },
|
||||||
|
{ "name": "brand_text", "value": "#4DA3FF" },
|
||||||
|
{ "name": "brand_disabled", "value": "#660A84FF" },
|
||||||
|
{ "name": "button_text", "value": "#FFFFFF" },
|
||||||
|
{ "name": "success", "value": "#34C759" },
|
||||||
|
{ "name": "success_bg", "value": "#1F34C759" },
|
||||||
|
{ "name": "error", "value": "#FF453A" },
|
||||||
|
{ "name": "error_bg", "value": "#1FFF453A" },
|
||||||
|
{ "name": "shadow_color", "value": "#1FFFFFFF" },
|
||||||
|
{ "name": "grid_line", "value": "#1FFFFFFF" },
|
||||||
|
{ "name": "today_bg", "value": "#330A84FF" },
|
||||||
|
{ "name": "selected_bg", "value": "#0A84FF" },
|
||||||
|
{ "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