16 Commits
Author SHA1 Message Date
laoyang ed26da8dc4 0.0.7:CalDAV 地址自动发现 + 人话错误提示 + 只读公开日历
背景:用户反馈(Nextcloud/内网)——DAVx5 用 CalDAV 地址能完整读取,换本应用
读不出日历;用"分享链接"绑定时提示服务器返回 405。

定位:不是协议问题,而是"填入的 URL 不是 CalDAV 端点"。
  ① 我们的发现=PROPFIND+Depth:1 只认 resourcetype 含 calendar 的集合;填 DAV 根
     /remote.php/dav 只能看到 principals/calendars/addressbooks → "未发现日历本"。
  ② 分享链接(/apps/calendar/p/<token>、/s/<token>)是网页路由 → PROPFIND 405。
  ③ 全工程此前无 .well-known 自动发现(grep 无命中)→ 必须手填完整路径,
     而 DAVx5 内置自动发现,故"DAVx5 能用、我们不能"。

实现(新增 common/DavDiscovery.ets):
1. .well-known/caldav 自动发现:207/200 直接用;301/302/307/308/401 读 Location
2. 智能补全:按 Nextcloud/ownCloud/Radicale/Baikal 等常见路径生成候选并逐个探测,
   保留子目录前缀(填 https://h/nextcloud → /nextcloud/remote.php/dav/calendars/<u>/)
3. 分享链接识别:/apps/calendar/p/、/s/、/index.php/s/ 直接拦下并给改法
4. OPTIONS 能力探测:读 DAV/Allow 头,区分"不是 DAV 端点"与"服务端挡了方法"
5. 状态码人话化:405/401/403/404/跳转/网络失败各有可操作指引;
   "未发现日历本"区分"地址差一层"(给出补全后的正确地址)与"确实为空"
6. 只读公开日历:识别 public-calendars 链接;只读由既有 calendarWritable 探测自动标记

接入三处:
- CalendarListPage.fetchCalendars / EditAccountPage:先 resolve() 定位端点,
  命中则自动修正 serverUrl(并落库),失败则直接给人话原因
- AddAccountPage:连接前拦分享链接;probeServer 遇 405 用 OPTIONS 判定并给指引;
  地址输入框提示同步更新

设计要点:发现顺序=原地址优先(保持既有行为、不破坏已能用的用户)→ .well-known
→ 常见候选;单个候选失败不抛错;ArkTS 静态方法内禁用 this → 统一用类名调用。

验证:编译通过;离线用例覆盖分类/完整性判定/候选路径(4 种输入形态)全部通过。

版本:AppScope/app.json5 → 0.0.7 / 700
文档:新增《上架更新说明-0.0.6到0.0.7.md》;官网 sf/uh 补功能说明与使用指引、
  版本号与日期更新、404 重建,已部署并线上核验。
打包:assembleApp release → verify-app success、profile type=release、
  app-distribution-type=app_gallery、包内版本 700/0.0.7。
2026-10-13 12:43:26 +08:00
laoyang 68329c57a0 docs: README 补充自行构建的签名配置说明,并修正过时描述
用户诉求:仓库为安全起见把含密钥/口令的构建配置排除在版本库外,
但他人克隆代码后需要自行补齐才能构建,应在说明文件里讲清:
补什么文件、什么格式、在哪个文件里填、这些信息怎么申请。

新增内容:
- README「📦 构建与运行」重写为四节:准备工程 / 首次构建补签名配置 /
  编译运行 / 打上架包(.app)
- 「首次构建」一节详述:
  · 需要的 4 个文件(.p12 密钥库 / .cer 证书 / .p7b Profile / 两个口令)
  · 两条申请途径 —— 途径A DevEco「Automatically generate signature」自动生成
    (真机调试用,只需实名认证的华为开发者账号);途径B 在 AGC 后台申请
    发布证书与发布 Profile(上架用,含 keytool 生成 CSR 的说明)
  · 填写位置与字段说明(signingConfigs[].material.* + products[0].signingConfig)
  · 明确「根 build-profile.json5 已 gitignore、勿提交;entry/build-profile.json5
    是模块级配置、必须保留」这一易混淆点
  · 附完整可抄的 JSON5 示例
- 新增 build-profile.example.json5 模板(占位符形式,无任何真实密钥与个人路径),
  用户可直接复制改名使用
- 打 .app 上架包章节补充 verify-app 需带 -inForm zip(否则误报 codesign block)

顺带修正 README 中已过时的描述(与上一轮代码改动保持一致):
- 版本号 0.0.3 → 0.0.6
- 「后台持续同步/长时任务 dataTransfer/常驻通知」→ 分层调度说明
  (前台按间隔 + 后台定时唤起(最短约2小时) + 回前台/网络恢复即时补同步)
- 权限列表移除已删除的 KEEP_BACKGROUND_RUNNING
- 目录结构中后台同步模块改为 BackgroundSyncRunner/BackgroundSyncService/
  NetworkSyncWatcher

安全:已扫描确认 README 与模板不含任何口令、密钥或本机绝对路径。
2026-10-11 21:39:11 +08:00
laoyang 2b29f197e8 后台同步重构:移除不合规长时任务,改为分层调度
背景:用户反馈"后台常驻其实没实现,过一段时间常驻通知就没了"。调研确认这是
系统强制管控、不是 bug:DATA_TRANSFER 长时任务有一致性校验,**超 10 分钟未更新
传输进度即被强制取消**。日历同步是"间歇跑、平时无进度",不符合该语义 → 必被取消,
且频繁触发校验失败可能被列入管控名单。另:workScheduler 请求 20 分钟低于系统
"应用活跃分组"下限(活跃≥2h),被静默压制 → 名义 20 分钟实际从未生效。

改为分层调度(目标:用户要看时数据一定最新,而非追求进程常驻):
1. **移除 dataTransfer 长时任务**:BackgroundSyncService 不再申请;
   module.json5 去掉 backgroundModes 与 ohos.permission.KEEP_BACKGROUND_RUNNING
2. **延迟任务周期 20 分钟 → 2 小时**(对齐系统下限,避免被压制);保留 isRepeat/isPersisted
3. **新增 BackgroundSyncRunner**:后台同步唯一执行体(抽取自 SyncWorkAbility),
   供延迟任务/网络恢复/回到前台三处共用,避免逻辑漂移
4. **新增 NetworkSyncWatcher**:net.connection 监听,**从无网恢复到有网时立即补同步**
   (带 20s 去抖;仅在 netLost→netAvailable 时触发,单纯切网不重复同步)
5. **回到前台补同步**:UIAbility.onForeground 立即同步 + 取消短时任务归还配额
6. **退后台短时任务**:onBackground 用 requestSuspendDelay 兜住退后台那次同步;
   回到前台 cancelSuspendDelay(配额仅 10 分钟/日,必须归还)
7. **设置页开关语义调整**:"后台持续同步(常驻通知)" → "后台自动同步",
   文案如实说明"系统最短约 2 小时一次 + 回前台/网络恢复立即补同步";
   开关现在真正控制延迟任务的注册/取消
8. **官网文档同步**:sf/uh 改写后台同步说明;upa **移除 KEEP_BACKGROUND_RUNNING 权限行**
   (权限表必须与实际声明一致);已重建 404 并部署线上

注:applyEfficiencyResources 在当前 SDK 不可用,未采用。
2026-10-10 12:49:11 +08:00
laoyang da0249d9f4 版本号升至 0.0.6(versionCode 600) 2026-10-10 08:30:12 +08:00
laoyang 17d7602d7d 重复日程:新增结束条件(到某天/共几次);修复仅这一次失效与标题被清空
新增能力:
- 新建重复日程时可设"结束条件":永不结束 / 重复到某天 / 共重复几次
  (之前只能选重复频率,无法指定次数或截止日期)
- 编辑既有重复日程会**回显**服务器上已有的 COUNT / UNTIL 并可修改

修复三个真 bug:
1. ⭐ "仅这一次"失效(实际改了全部):重复日程在列表里展开后,所有次共用
   主事件的 id,编辑页只拿到 id、拿不到"用户点的是哪一次" → occStartMs 被当成
   主事件的第一次发生 → EXDATE 排除的是第一次、覆盖行写在第一次上,
   用户当次既没排掉、系列属性又被动过,看起来就是"全改了"。
   修法:Index 打开编辑页时用 pendingOccurrenceTs 传入被点那次的 startTime;
   seriesBoundaries 再把该时刻"吸附"到真实发生(容差 12h),确保 EXDATE 能匹配。
2. ⭐ 已有标题被删空:original 快照漏拷贝 title/description/location/reminders,
   restoreRowById 于是把主事件行还原成空标题。
   修法:快照补全全部字段。
3. ⭐ setExdate/setRrule 被 restoreRowById 覆盖(顺序错误):
   先写 EXDATE 再还原 → 快照里的旧 exdate 又把排除覆盖掉。
   修法:一律"先还原主事件行、再写 EXDATE/截断 RRULE"。

其它:
- 编辑页现在显示"被点那一次"的实际时间(此前显示的是主事件第一次的时间)
- 'all' 分支改为把改动写到主事件行,整系列一起改
- 复用唯一底部弹层(新增 sheetMode='repeatEnd' 选截止日期),仍保持只挂 1 个 bindSheet
2026-10-10 07:05:32 +08:00
laoyang cd2401242c 修复:编辑页挂了两个 bindSheet 导致时间选择框弹不出来
症状(用户反馈,本轮改动引入的回归):编辑/新建日程时点开始或结束时间,
白框一闪而过、日期时间选择器不出现。

根因:ArkUI 同一组件**只支持一个 bindSheet**,后挂的会覆盖先挂的。
上一轮新增"重复日程范围选择"弹层时,在同一根容器上挂了第二个 bindSheet,
于是它把原有的"时间选择"弹层顶掉了 —— 表现正是白框一闪即消失。

修法:合并为**唯一**一个 bindSheet,用 sheetMode 区分内容:
- sheetMode='picker' → 日期/时间选择(高度 380)
- sheetMode='scope'  → 重复日程范围选择(高度 320)
新增 bottomSheet() builder 按模式分发;原 pickerShow/scopeConfirmShow
两个状态合并为 sheetShow + sheetMode。
2026-10-09 22:41:17 +08:00
laoyang 5e6f347fc6 修复:编辑重复日程缺范围选择;同步并发不再误报失败
问题1(用户反馈):删除重复日程有"仅这一次/此次及以后/全部"三选项,编辑却没有。
根因:save() 把 validate() 与 chosenBook() 检查放在 isSeriesOccurrence() **之前**,
      任一不满足即提前 return,永远到不了范围选择弹层;delete 路径则是先判范围。
修法:
- save() 把"重复日程先选范围"提到最前(与删除路径一致),校验推迟到 doSave() 内
- chosenBook() 增加兜底:展开编辑时若该日程所属日历本不在"可写清单"里
  (被标记只读/写权限探测失败),用该日程自己的 calKey/href 构造目标;
  initPage 里还会用 CalendarDataBridge.lookupBook 把它补进选择列表(带真实名称/颜色)
- 新增 original 原始行快照:doSave 会就地改写 this.event 的 calKey/startTime,
  而 EXDATE/UNTIL/拆系列必须依据原值 —— 否则范围计算用错系列
- applySeriesEdit 的 one/future 分支在改写当前行后,用 EventDb.restoreRowById 还原原行,
  避免原系列残留幽灵行
- 加保存判定日志(脱敏)便于诊断

问题2(用户反馈):同步进行中再点手动同步/账号管理确定,提示"同步失败"造成误解。
根因:互斥锁抛的是普通 Error,三个调用点统一按"失败"提示。
修法:
- SyncEngine 新增 SyncBusyError 专用类型 + isSyncing/isAnySyncing 查询;
  锁命中时抛 SyncBusyError,且 catch 不再把它包成普通 Error
- Index.syncSingle / AccountsPage.syncSingleAccount:命中→提示
  "有同步任务正在进行中,请耐心等待",不显示失败
- Index.syncAll:命中记为 busySkipped(不算失败),全部被跳过时也只提示等待
2026-10-09 22:19:31 +08:00
laoyang 5c80c0b773 重复日程支持单次/后续/全部范围;时间轴刻度改为 8-18 固定
重复日程编辑与删除:
- 编辑或删除重复日程前先让用户选范围:仅这一次 / 此次及以后 / 全部
- 仅这一次:主事件加 EXDATE 排除该次,另插一条带 RECURRENCE-ID 的覆盖行
- 此次及以后:主事件 RRULE 加 UNTIL 截断,剩余部分拆成新 uid 的新系列
- 全部:删除/改写整个系列
- IcsUtil 新增 vevent / buildSeries / rruleWithUntil / fmtIcsTime:
  同一个 .ics 资源内写主事件 + 全部覆盖实例(标准写法),
  此前对覆盖实例直接跳过推送、导致单次改动无法上传服务器
- SyncEngine.pushSeries:按 uid 取全系列行重建整份 .ics 后一次性 PUT
- EventDb 新增 queryByUid / purgeByUid / purgeByUidFrom / reassignOverridesFrom /
  setExdate / setRruleForUid

时间轴刻度(App + 4x4/6x6 卡片同口径):
- 8:00~18:00 恒定显示;仅当 8 点前 / 18 点后有日程时才向该侧扩展,
  其余时刻不再出现'只有一条日程就只剩一小段刻度'的观感
2026-10-09 21:40:53 +08:00
laoyang d5e5d54d35 账号页空状态的「+」可点击;版本号升至 0.0.5(versionCode 500)
- AccountsPage.emptyState 的「+」改为可点击按钮,直接进入添加 CalDAV 账号页
  (此前只是静态图标,点了没反应);提示文案同步更新
- 版本号 0.0.4 → 0.0.5
2026-10-09 19:23:29 +08:00
laoyang c76c78b8a6 删除 CalDAV 账号时一并清除其系统日历镜像本
问题:在 App 里删除一个 CalDAV 账号后,之前镜像进系统日历的日历本
(synccalendar.mirror.<accId>-<hash> 账户) 仍留在系统日历里,形成幽灵账户。
真机实测确认:账号 1789564608312 已删,其名下 13 个镜像账户仍在(KEEP)。

根因:
- AccountsPage.confirmDeleteAccount 只清账号配置+本地库+设置,完全不碰系统日历;
- 自动兜底也失效:SystemCalendarMirror.syncNow 在解析不到任何选中本时提前 return,
  删完账号后正是此状态,purgeOrphans 永不执行。

修复:
- SystemCalendarMirror.removeByAccount(accId):按 'mirror.<accId>-' 前缀删除该账号全部
  镜像账户(兼容历史 '<accId>_' 桶名),逐个独立 try/catch + 清空日程降级;
  绝不按 displayName 匹配 —— 不同账号下的同名日历本不会误删;同步清快照与选中项设置。
- AccountsPage 删除账号时先 removeByAccount(必须在改账号配置前,否则 bookId 解析不出)。
- EditAccountPage 取消勾选日历本时删除其镜像账户(用 href 算稳定 bookId)。
- SettingsPage 账号库重置时 removeAll。
- syncNow 提前 return 前加 purgeOrphanAccountMirrors 兜底,带安全阀(账号列表为空即跳过,
  防读失败误删全量)。
2026-10-09 08:47:50 +08:00
laoyang 32162b8f04 修复折叠屏合盖旋转不再切换布局的回归
上一版为满足"接近1:1屏幕横竖屏布局一致",把宽屏判据改成纯短边≥840vp,
导致长条形屏幕(普通手机、折叠屏合上后的外屏)旋转横屏也不出双栏。
现在两条判据满足其一即双栏:
① 短边≥840vp(展开态/平板)——旋转不变,维持商店合规要求;
② 横屏且宽高比≥1.5(长条形屏幕)——旋转正常切换,恢复 0.0.2 行为。
判据实现由 mediaquery 改为 windowSizeChange(px→vp),旋转/折叠展开均实时生效,
并记录窗口尺寸日志便于真机排查。
2026-09-29 09:09:06 +08:00
laoyang b83ce83d43 版本号升至 0.0.4(versionCode 400) 2026-09-29 08:46:48 +08:00
laoyang d0b8720c41 同步容错:服务器删除日历本后 404 不再导致整个账号同步失败
- DavClient 新增 DavHttpError(带 HTTP 状态码),REPORT/PUT/DELETE 统一抛出
- 同步引擎对单本 404 跳过并自动从账号移除(先按 href 清行再迁移序号),
  全部日历本均 404 时保留账号配置并给出明确提示
- 推送阶段单条失败不再中断整个账号(401 凭据失效仍立即抛出)
- 编辑账号保存与自动移除都在序号迁移前先删被移除日历本的行,
  修复行混入其它日历本 calKey 的老问题(实测「读书」988 行混进「轻舟」)
- applyRemote 刷新行的 href:日历本被删除重建后集合 URL 变化,
  旧行不再残留死地址(编辑推送 404 / 永久卡待推送)
- pruneOrphanRows 追加 href 维度兜底清扫(跳过 dirty 行)
2026-09-29 08:46:01 +08:00
laoyang 8b5519bc31 更新 README:补充系统日历镜像、后台同步与备份、深色/沉浸式/大屏横竖屏与转场适配、设置项一览、隐私与权限策略、工程结构、构建说明、截图与相关链接;按现状修正视图与卡片描述 2026-09-28 15:23:54 +08:00
laoyang 2f4657ced3 上架自检两项整改:横竖屏布局一致性 + 页面转场改为淡入淡出(弹簧曲线)
一、横竖屏(意见 1:折叠屏展开态 / 平板需支持横竖屏切换;接近 1:1 的设备横竖屏布局需保持一致)
- 主界面布局判据由"是否横屏 (orientation: landscape)"改为"窗口短边是否达到大屏断点 840vp":
  min(宽,高) >= 840vp 等价于 (width >= 840vp) && (height >= 840vp),用两条 mediaquery 合成。
- 短边在旋转时不变(宽高互换),因此横竖屏布局天然保持一致 —— 屏幕比例接近 1:1 的设备
  (折叠屏展开态、平板)在旋转时不会再出现"单栏 ⇄ 双栏"的布局跳变。
- 不锁死屏幕方向(module.json5 仍为 auto_rotation_restricted),折叠屏展开态与平板
  依旧可以自由横竖屏切换显示。

二、转场动效(意见 2:使用系统转场,淡入淡出,曲线优先弹簧曲线)
- 全部 7 个 @Entry 页面新增 pageTransition():只做 opacity 淡入淡出,
  不调用 .slide() / .translate(),即不做左右平移 / 上下位移 / 单帧直接切换。
- 转场曲线使用 curves.springMotion() 弹簧曲线。
2026-09-28 14:20:14 +08:00
laoyang 736a0a6587 新增系统日历镜像功能,镜像与备份目标统一改用 bookId;修复重复日程覆盖行、备份目标随勾选漂移等一批问题
- 新增 SystemCalendarMirror / MirrorSnapshot / MirrorInbound:把选定的 CalDAV 日历本镜像写入系统日历(供小艺、桌面日历卡片、手表可见),并做入站对账,区分"自己写入的 / 用户改的 / 用户删的 / 用户新建的",镜像标识一律用稳定的 bookId(accId-href哈希),不再用会随勾选顺序漂移的 calKey
- 后台同步接入镜像:同步前先做入站对账、同步后自动出站镜像,无需用户手动触发
- 系统日历备份目标改用 bookId:当用户取消同步某个日历本导致目标失效时,自动清空目标并把模式回退为"仅显示";开启备份必须先选定目标本,不再自动挑第一个本
- EventDb:覆盖行保留 RECURRENCE-ID,排除主事件对应发生改用 overrideKey(calKey, uid, recurrenceId, startTime),修复重复日程出现"幽灵条目"的问题
- 新增 WRITE_CALENDAR 权限声明及其用途说明;添加日历不再出现"本机",日历本对齐显示;版本升至 0.0.3(300)
2026-09-19 23:07:03 +08:00
31 changed files with 5996 additions and 440 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 200, "versionCode": 700,
"versionName": "0.0.2", "versionName": "0.0.7",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+315 -24
View File
@@ -1,48 +1,339 @@
# SyncCalendar(同步日历) # SyncCalendar(同步日历)
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。 一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
- 应用市场送审版本:**0.0.6** | 仓库当前:**0.0.6**(versionCode 600)
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
- 开源协议: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**,时间轴样式设计
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
### 系统日历镜像(可选增强,默认关闭)
镜像是**可选项**,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)· 镜像到系统日历 · 选择要镜像的日历本 · 回写开关 · 默认视图 · 日程显示方式 · 自动同步间隔 · 后台自动同步 · 通知权限 · 混合显示系统日历 · 日历本只读 / 静音 · 重建日程数据 · 重置账号库 · 软件特性 · 使用帮助 · 隐私政策与用户协议
---
## 🔒 隐私
我们遵循"**数据自持、最小必要**"原则:
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机)
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒、点击地点导航)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
---
## 🛠 技术要点 ## 🛠 技术要点
- **架构**: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` 即表示签名有效。
---
## 📷 截图
| 月视图 | 周视图 |
|---|---|
| ![月视图](截图/微信图片_20260915083446_376_241.jpg) | ![周视图](截图/微信图片_20260915083448_379_241.jpg) |
| 列表 / 时间轴 | 日程详情 |
|---|---|
| ![列表](截图/微信图片_20260915083449_380_241.jpg) | ![详情](截图/微信图片_20260915083450_381_241.jpg) |
| 设置 | 账号管理 |
|---|---|
| ![设置](截图/微信图片_20260915083450_382_241.jpg) | ![账号](截图/微信图片_20260915083451_383_241.jpg) |
| 桌面卡片 | 深色模式 |
|---|---|
| ![卡片](截图/微信图片_20260915083452_384_241.jpg) | ![深色](截图/微信图片_20260915083452_385_241.jpg) |
---
## 🔗 相关链接
| | |
|---|---|
| 官方网站 | <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://synccalendar.yangyq.net/feedback/> |
网站备案:**鲁ICP备17014997号-6A**
---
## 📄 开源说明 ## 📄 开源说明
本项目仅供学习交流使用。 本项目仅供学习交流使用,采用 **Apache License 2.0** 协议,详见 [LICENSE](LICENSE)。
该项目使用人工智能辅助完成。 该项目使用人工智能辅助完成。
+90
View File
@@ -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"
]
}
]
}
]
}
+190 -5
View File
@@ -11,13 +11,22 @@ 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'; // 应用内提醒上次检查时间戳
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(默认关)
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"**——
@@ -149,6 +158,141 @@ 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}`);
}
}
/** /**
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
@@ -286,9 +430,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 +468,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 +484,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 {
@@ -0,0 +1,102 @@
// entry/src/main/ets/common/BackgroundSyncRunner.ets
// ⭐ 后台同步的**唯一**执行体:延迟任务 / 短时任务 / 网络恢复 / 回到前台 都调它。
//
// 设计要点(2026-10-10 定时同步方案调研结论):
// - 不再依赖"进程常驻"(系统不允许,dataTransfer 长时任务会被强制取消)
// - 而是让"最合适的机制在最合适的时机把数据对齐"
// - 本类只负责"跑一次完整同步",幂等(基于 etag 全量比对 + dirty 标记)
//
// ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
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 {
/**
* 跑一次完整同步。全流程 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();
try {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
if (accounts.length === 0) {
LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
return r;
}
// 备份模式:先导入系统本地日历(幂等),再随同步推送
try {
await SystemCalendarImport.importIfNeeded(context);
} catch (err) {
// 导入失败不影响正常同步
}
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
try {
await MirrorInbound.reconcile(context);
} catch (err) {
// 对账失败不影响正常同步
}
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}`);
}
}
// 全部账号同步成功才结束"一次性全量重拉"状态
if (r.attempted > 0 && r.ok === r.attempted) {
await AppSettings.markFullRefetchDone(context);
}
await SyncEngine.settleLocalEvents(context);
await SyncEngine.pruneOrphanRows(context, accounts);
// ⚠️ 必须回写账号: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) {
// 镜像失败不影响同步
}
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
try {
await CardDataService.pushToAllForms(context);
await ReminderService.refreshReminders(context as common.UIAbilityContext);
} catch (err) {
// 卡片/提醒刷新失败不影响同步结果
}
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号`);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}`);
}
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> { * ⭐ 退到后台时申请**短时任务**:让"刚退后台的这一次同步"能跑完。
if (enabled) { * 必须在**前台或退后台 5 秒内**调用,否则申请失败。
// 先申请任务,成功才落盘设置(失败时设置页回滚开关) * 单次最长 3 分钟(实际申请到的时长以返回值为准),单日总配额 10 分钟。
await BackgroundSyncService.startContinuousTask(context); */
await AppSettings.setBackgroundSync(context, true); static requestTransientSync(reason: string): void {
} else { if (BackgroundSyncService.transientId !== 0) {
await AppSettings.setBackgroundSync(context, false); return; // 已有短时任务在跑,不重复申请(重复申请会浪费配额)
try { }
await BackgroundSyncService.stopContinuousTask(context); try {
} catch (err) { const info: backgroundTaskManager.DelaySuspendInfo =
// 停止失败(如任务本就不在运行)不影响设置保存 backgroundTaskManager.requestSuspendDelay(reason, (): void => {
} // 超时回调:配额用完,系统即将挂起;清状态,等下次前台/退后台再申请
LogUtil.write('短时任务超时(配额用尽),后台同步将被挂起');
BackgroundSyncService.transientId = 0;
});
BackgroundSyncService.transientId = info.requestId;
LogUtil.write(`短时任务已申请:requestId=${info.requestId},可用 ${Math.round(info.actualDelayTime / 1000)} 秒`);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`短时任务申请失败: ${e.code} - ${e.message}`);
} }
} }
}
/** 回到前台时取消短时任务:不占用当日配额(很重要,配额只有 10 分钟) */
static cancelTransientSync(): void {
if (BackgroundSyncService.transientId === 0) {
return;
}
try {
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncService.transientId);
LogUtil.write('短时任务已取消(回到前台,归还配额)');
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`短时任务取消失败: ${e.code} - ${e.message}`);
}
BackgroundSyncService.transientId = 0;
}
/** 查询短时任务剩余时间(毫秒);无任务或无配额返回 0 */
static async remainingTransientMs(): Promise<number> {
if (BackgroundSyncService.transientId === 0) {
return 0;
}
try {
return await backgroundTaskManager.getRemainingDelayTime(BackgroundSyncService.transientId);
} catch (err) {
return 0;
}
}
/** App 启动时统一入口:按用户设置注册/不注册延迟任务兜底同步 */
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> {
const enabled: boolean = await AppSettings.getBackgroundSync(context);
if (enabled) {
BackgroundSyncService.scheduleDeferredSync(context);
} else {
LogUtil.write('后台自动同步未开启:不注册延迟任务');
}
}
/** 设置页开关切换:控制"后台自动同步"(延迟任务注册与否) */
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
await AppSettings.setBackgroundSync(context, enabled);
if (enabled) {
BackgroundSyncService.scheduleDeferredSync(context);
} else {
BackgroundSyncService.cancelDeferredSync(context);
}
}
}
@@ -202,12 +202,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;
@@ -232,7 +234,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) {
+19 -5
View File
@@ -36,6 +36,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
@@ -346,7 +360,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 +417,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 +473,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 +503,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();
+458
View File
@@ -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;
}
+327 -7
View File
@@ -25,6 +25,10 @@ export class LocalEvent {
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED) completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生 rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
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[] = []; // 全部提醒提前分钟数(多选),按添加顺序
} }
@@ -44,6 +48,7 @@ export class RemoteEvent {
completed: boolean = false; // VTODO STATUS:COMPLETED completed: boolean = false; // VTODO STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE rrule: string = ''; // 原始 RRULE
exdate: string = ''; // 原始 EXDATE(分号分隔) exdate: string = ''; // 原始 EXDATE(分号分隔)
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
reminders: number[] = []; // 全部提醒提前分钟数(多选) reminders: number[] = []; // 全部提醒提前分钟数(多选)
} }
@@ -51,6 +56,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;
@@ -104,6 +139,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,6 +165,18 @@ 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'));
@@ -146,6 +199,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 +232,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(',')
}; };
@@ -371,12 +426,22 @@ export class EventDb {
} }
const remoteKeys: string[] = []; const remoteKeys: string[] = [];
// ⭐ 同一次解析里可能出现同一资源的多条相同 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 才唯一)
const key: string = `${r.uid}_${r.startTime}_${r.recurrenceId}`;
if (seenKeys.has(key)) {
continue; // 同一资源内的重复条目 → 只保留一条
}
seenKeys.add(key);
remoteKeys.push(key); remoteKeys.push(key);
const found = existing.find((x: LocalEvent): boolean => const found = existing.find((x: LocalEvent): boolean =>
!x.dirty && x.uid === r.uid && x.startTime === r.startTime); !x.dirty && x.uid === r.uid && x.startTime === r.startTime
&& x.recurrenceId === r.recurrenceId);
if (found === undefined) { if (found === undefined) {
// 新增 // 新增
const e = new LocalEvent(); const e = new LocalEvent();
@@ -398,14 +463,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 +491,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 +500,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,7 +516,7 @@ 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)) { if (!remoteKeys.includes(localKey)) {
const del = new relationalStore.RdbPredicates('events'); const del = new relationalStore.RdbPredicates('events');
del.equalTo('id', local.id); del.equalTo('id', local.id);
@@ -446,11 +527,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 +624,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 +797,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}_${序号}`)。
* *
+120 -8
View File
@@ -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;
@@ -224,6 +236,42 @@ export class IcsUtil {
* 若线上出现"标题被截断"反馈,需在此补 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 => { const fmtUtc = (ms: number): string => {
const d = new Date(ms); const d = new Date(ms);
@@ -244,24 +292,88 @@ export class IcsUtil {
dtstart = 'DTSTART:' + fmtUtc(e.startTime); dtstart = 'DTSTART:' + fmtUtc(e.startTime);
dtend = 'DTEND:' + fmtUtc(e.endTime); dtend = 'DTEND:' + fmtUtc(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:${fmtUtc(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) : fmtUtc(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 的 ICS 时间串。
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用 UTC 的 `YYYYMMDDTHHMMSSZ`
*/
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.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}
/**
* ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。
* - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现)
* - UNTIL 用 UTC 形式;`untilMs` 一般是"该次发生的开始时刻 − 1 秒"
*/
static rruleWithUntil(rrule: string, untilMs: number): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const d = new Date(untilMs);
const until: string = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
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(已规范化去重) */
+328
View File
@@ -0,0 +1,328 @@
// 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;
}
/* ==================== 单个日历本 ==================== */
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 cal.getEvents(
undefined, MirrorInbound.QUERY_FIELDS);
// 分成两类:带我们 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,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);
}
}
+281 -20
View File
@@ -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}`);
@@ -79,13 +134,28 @@ 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} 个资源`);
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag) // 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
@@ -185,9 +255,92 @@ 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
* 失败静默(颜色不影响数据正确性) * 失败静默(颜色不影响数据正确性)
@@ -280,6 +433,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 +446,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 +462,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 +583,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 +613,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
+20 -15
View File
@@ -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;
} }
} }
@@ -2,6 +2,8 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration,
import { hilog } from '@kit.PerformanceAnalysisKit'; import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI'; import { window } from '@kit.ArkUI';
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';
const DOMAIN = 0x0000; const DOMAIN = 0x0000;
@@ -12,8 +14,11 @@ export default class EntryAbility extends UIAbility {
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log // 文件日志初始化:所有页面的同步日志统一写入 filesDir/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);
// ⚠️ 这里**不再申请任何权限**(含通知权限)。 // ⚠️ 这里**不再申请任何权限**(含通知权限)。
@@ -189,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('正在同步日历数据');
} }
} }
+56 -14
View File
@@ -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()
@@ -252,23 +274,43 @@ struct AccountsPage {
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .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)
+48 -4
View File
@@ -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)
@@ -297,6 +328,19 @@ struct AddAccountPage {
.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) {
+47 -5
View File
@@ -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;
@@ -369,6 +398,19 @@ struct CalendarListPage {
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .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
+89 -7
View File
@@ -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';
/** /**
@@ -136,16 +142,39 @@ 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;
} }
@@ -153,7 +182,7 @@ struct EditAccountPage {
new EditCalendarItem(e.href, e.displayName, e.color)); new EditCalendarItem(e.href, e.displayName, e.color));
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 {
// 已勾选的日历本按账号当前配置预选 // 已勾选的日历本按账号当前配置预选
@@ -219,6 +248,39 @@ struct EditAccountPage {
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color); target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
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 +305,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;
@@ -439,6 +508,19 @@ struct EditAccountPage {
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .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
File diff suppressed because it is too large Load Diff
+123 -26
View File
@@ -1,13 +1,13 @@
// entry/src/main/ets/pages/Index.ets // entry/src/main/ets/pages/Index.ets
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表 // 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
import { mediaquery, router } from '@kit.ArkUI'; import { curves, display, router, window } from '@kit.ArkUI';
import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit'; import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService'; import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService';
import { SyncEngine } from '../common/SyncEngine'; import { SyncEngine, SyncBusyError } from '../common/SyncEngine';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
import { LunarUtil } from '../common/LunarUtil'; import { LunarUtil } from '../common/LunarUtil';
import { CardDataService } from '../common/CardDataService'; import { CardDataService } from '../common/CardDataService';
@@ -20,6 +20,8 @@ import { RruleUtil } from '../common/RruleUtil';
import { IcsUtil } from '../common/IcsUtil'; import { IcsUtil } from '../common/IcsUtil';
import { DavClient, RemoteItem } from '../common/DavClient'; import { DavClient, RemoteItem } from '../common/DavClient';
import { SystemCalendarImport } from '../common/SystemCalendarImport'; import { SystemCalendarImport } from '../common/SystemCalendarImport';
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { MirrorInbound } from '../common/MirrorInbound';
import { ScreenKeeper } from '../common/ScreenKeeper'; import { ScreenKeeper } from '../common/ScreenKeeper';
import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil'; import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil';
@@ -52,8 +54,16 @@ struct Index {
@State syncing: boolean = false; @State syncing: boolean = false;
@State loading: boolean = true; @State loading: boolean = true;
@State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单 @State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单
@State isLandscape: boolean = false; // 横屏:月视图切左右双栏(左月历/右当日日程) // 宽屏(双栏:月视图左月历 / 右当日日程,周视图同理)判据,两条满足其一即双栏:
private landscapeListener: mediaquery.MediaQueryListener | null = null; // ① 窗口短边 ≥ 840vp —— 折叠屏展开态 / 平板这类"大屏"。短边在旋转时不变,
// 故横竖屏布局天然一致,满足"屏幕比例接近 1:1 的设备,横竖屏布局需保持一致";
// ② 横屏且宽高比 ≥ 1.5 —— 长条形屏幕(普通手机、折叠屏**合上后的外屏**)。
// 这类设备比例远离 1:1,旋转应当切换布局(0.0.2 起即如此,用户已习惯)。
// 加比例下限是为了兜底"短边不足 840 但比例接近 1:1"的小方屏:旋转依旧不变。
// 2026-09-29 修复:上一版只留了判据 ①,导致 Mate X7 合盖后旋转屏幕布局不再变化。
@State wideLayout: boolean = false;
private winObj: window.Window | null = null;
private winSizeCallback: ((size: window.Size) => void) | null = null;
private accounts: DavAccount[] = []; private accounts: DavAccount[] = [];
private sources: CalSource[] = []; private sources: CalSource[] = [];
private swiperController: SwiperController = new SwiperController(); private swiperController: SwiperController = new SwiperController();
@@ -110,7 +120,7 @@ struct Index {
} }
} }
this.rebuildPages(); this.rebuildPages();
this.initLandscapeListener(); this.initLayoutListener();
// 首启同意门禁:未同意《隐私政策》与《用户协议》前,不申请任何系统权限、不加载/同步数据(AGC 硬性要求) // 首启同意门禁:未同意《隐私政策》与《用户协议》前,不申请任何系统权限、不加载/同步数据(AGC 硬性要求)
if (ctx === undefined) { if (ctx === undefined) {
this.policyChecked = true; this.policyChecked = true;
@@ -140,19 +150,57 @@ struct Index {
clearInterval(this.nowTimer); clearInterval(this.nowTimer);
this.nowTimer = -1; this.nowTimer = -1;
} }
if (this.landscapeListener !== null) { if (this.winObj !== null && this.winSizeCallback !== null) {
this.landscapeListener.off('change'); try {
this.landscapeListener = null; this.winObj.off('windowSizeChange', this.winSizeCallback);
} catch (err) {
// 窗口可能已销毁(页面退出),忽略
}
this.winSizeCallback = null;
this.winObj = null;
} }
} }
/** 监听横竖屏:旋转后 isLandscape 驱动月视图在上下/左右布局间切换 */ /**
private initLandscapeListener(): void { * 由窗口实际尺寸(px → vp)计算单栏/双栏。判据见 wideLayout 字段注释:
this.landscapeListener = * 短边 ≥ 840vp(大屏,旋转不变)或 横屏且宽高比 ≥ 1.5(长条形屏幕,旋转切换)。
this.getUIContext().getMediaQuery().matchMediaSync('(orientation: landscape)'); */
this.isLandscape = this.landscapeListener.matches; private applyLayoutFromWindow(win: window.Window): void {
this.landscapeListener.on('change', (result: mediaquery.MediaQueryResult) => { try {
this.isLandscape = result.matches; const rect = win.getWindowProperties().windowRect;
const density: number = display.getDefaultDisplaySync().densityPixels;
if (density <= 0) {
return;
}
const w: number = rect.width / density;
const h: number = rect.height / density;
const shortEdge: number = Math.min(w, h);
const ratio: number = h > 0 ? w / h : 1;
const wide: boolean = shortEdge >= 840 || (w > h && ratio >= 1.5);
LogUtil.write(`窗口尺寸 ${Math.round(w)}x${Math.round(h)}vp(短边 ${Math.round(shortEdge)},宽高比 ${ratio.toFixed(2)})→ ${wide ? '双栏' : '单栏'}`);
if (wide !== this.wideLayout) {
this.wideLayout = wide;
}
} catch (err) {
// 取窗口信息失败时保持现状(罕见:窗口已销毁)
}
}
private initLayoutListener(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
window.getLastWindow(ctx).then((win: window.Window): void => {
this.winObj = win;
this.applyLayoutFromWindow(win);
this.winSizeCallback = (_size: window.Size): void => {
// size 参数为 px;重新读取属性并换算(折叠/展开、旋转都会触发)
this.applyLayoutFromWindow(win);
};
win.on('windowSizeChange', this.winSizeCallback);
}).catch((err: BusinessError): void => {
LogUtil.write(`窗口尺寸监听初始化失败(保持默认单栏):${err.message}`);
}); });
} }
@@ -536,6 +584,12 @@ struct Index {
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: `同步失败:${e.message}` }); .showToast({ message: `同步失败:${e.message}` });
@@ -585,12 +639,20 @@ struct Index {
await ScreenKeeper.acquire(context as common.UIAbilityContext); await ScreenKeeper.acquire(context as common.UIAbilityContext);
let ok: number = 0; let ok: number = 0;
let failMsg: string = ''; let failMsg: string = '';
// ⭐ 是否因为"已有同步在进行"而被跳过(这不算失败,只提示等待)
let busySkipped: boolean = false;
// 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器 // 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器
try { try {
await SystemCalendarImport.importIfNeeded(context); await SystemCalendarImport.importIfNeeded(context);
} catch (err) { } catch (err) {
// 导入失败不影响正常同步 // 导入失败不影响正常同步
} }
// 入站对账:把用户在系统日历里的改动读回来置 dirty,**必须在同步之前**,才能借本次同步一起推送
try {
await MirrorInbound.reconcile(context);
} catch (err) {
// 对账失败不影响正常同步
}
for (const acc of this.accounts) { for (const acc of this.accounts) {
if (acc.type !== TYPE_CALDAV) { if (acc.type !== TYPE_CALDAV) {
continue; continue;
@@ -600,6 +662,11 @@ struct Index {
acc.lastSyncTime = this.formatNow(); acc.lastSyncTime = this.formatNow();
ok++; ok++;
} catch (err) { } catch (err) {
// ⭐ 并发被互斥锁拦下:不是失败,只是"已有同步在跑",稍后自动同步会覆盖,直接略过
if (err instanceof SyncBusyError) {
busySkipped = true;
continue;
}
const e = err as BusinessError; const e = err as BusinessError;
failMsg = e.message; failMsg = e.message;
} }
@@ -616,11 +683,24 @@ struct Index {
const e = err as BusinessError; const e = err as BusinessError;
failMsg = e.message; failMsg = e.message;
} }
// 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点)
try {
if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) {
const mr = await SystemCalendarMirror.syncNow(context);
LogUtil.write(`同步后自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
}
} catch (err) {
// 镜像失败不影响同步结果
}
this.syncing = false; this.syncing = false;
await ScreenKeeper.release(context as common.UIAbilityContext); await ScreenKeeper.release(context as common.UIAbilityContext);
if (failMsg !== '') { if (failMsg !== '') {
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` }); .showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` });
} else if (busySkipped && ok === 0) {
// 全部账号都因"已有同步在进行"被跳过 → 只提示等待,不说失败
this.getUIContext().getPromptAction()
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
} else if (ok > 0 && manual) { } else if (ok > 0 && manual) {
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `同步完成(${ok} 个账号)` }); .showToast({ message: `同步完成(${ok} 个账号)` });
@@ -950,9 +1030,13 @@ struct Index {
.borderRadius(12) .borderRadius(12)
.onClick(() => { .onClick(() => {
const id: number = this.detailEvent !== null ? this.detailEvent.id : 0; const id: number = this.detailEvent !== null ? this.detailEvent.id : 0;
// ⭐ 把"用户点的是哪一次发生"一起带过去:重复日程展开后所有次共用主事件的 id,
// 不带发生时刻的话,编辑页无法知道用户点的是哪一次 → "仅这一次"会算错。
const occ: number = this.detailEvent !== null ? this.detailEvent.startTime : 0;
this.detailShow = false; this.detailShow = false;
if (id > 0) { if (id > 0) {
AppStorage.setOrCreate<number>('pendingEventId', id); AppStorage.setOrCreate<number>('pendingEventId', id);
AppStorage.setOrCreate<number>('pendingOccurrenceTs', occ);
router.pushUrl({ url: 'pages/EventEditPage' }); router.pushUrl({ url: 'pages/EventEditPage' });
} }
}) })
@@ -1054,6 +1138,19 @@ struct Index {
}) })
} }
/**
* 页面转场:淡入淡出。
* 不做左右平移 / 上下位移(不调用 .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);
}
/** /**
* 首启隐私政策同意门禁(整页渲染,不用弹窗 —— 弹窗在本环境实测无法弹出)。 * 首启隐私政策同意门禁(整页渲染,不用弹窗 —— 弹窗在本环境实测无法弹出)。
* AGC 要求:明确同意前不得申请权限、不得收集/处理个人信息,故未同意时整页挡住主界面。 * AGC 要求:明确同意前不得申请权限、不得收集/处理个人信息,故未同意时整页挡住主界面。
@@ -1581,8 +1678,8 @@ struct Index {
@Builder @Builder
monthBody() { monthBody() {
if (this.isLandscape) { if (this.wideLayout) {
// 横屏(平板适配):左右双栏——左侧月历,右侧当日日程 // 宽屏(平板 / 折叠屏展开态):左右双栏——左侧月历,右侧当日日程
Row() { Row() {
Column() { Column() {
this.monthWeekHeader() this.monthWeekHeader()
@@ -1650,7 +1747,7 @@ struct Index {
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`) }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`)
} }
.width('100%') .width('100%')
.layoutWeight(this.isLandscape ? 1 : 0) .layoutWeight(this.wideLayout ? 1 : 0)
}, (week: MonthCell[], idx: number) => String(idx)) }, (week: MonthCell[], idx: number) => String(idx))
} }
.width('100%') .width('100%')
@@ -1662,7 +1759,7 @@ struct Index {
.loop(false) .loop(false)
.indicator(false) .indicator(false)
.width('100%') .width('100%')
.layoutWeight(this.isLandscape ? 1 : 0) .layoutWeight(this.wideLayout ? 1 : 0)
.onChange((index: number) => { .onChange((index: number) => {
this.handleSwiperChange(index); this.handleSwiperChange(index);
}) })
@@ -1873,7 +1970,7 @@ struct Index {
@Builder @Builder
weekBody() { weekBody() {
if (this.isLandscape) { if (this.wideLayout) {
// 横屏(平板适配):左侧一周日期,右侧选中日日程 // 横屏(平板适配):左侧一周日期,右侧选中日日程
Row() { Row() {
Column() { Column() {
@@ -2508,20 +2605,20 @@ struct Index {
dayCell(cell: MonthCell) { dayCell(cell: MonthCell) {
Column({ space: 2 }) { Column({ space: 2 }) {
Text(String(cell.day)) Text(String(cell.day))
.fontSize(this.isLandscape ? 12 : 13) .fontSize(this.wideLayout ? 12 : 13)
.fontWeight(cell.isToday || cell.dateMs === this.selectedDate ? FontWeight.Bold : FontWeight.Normal) .fontWeight(cell.isToday || cell.dateMs === this.selectedDate ? FontWeight.Bold : FontWeight.Normal)
.fontColor(!cell.inMonth .fontColor(!cell.inMonth
? $r('app.color.text_hint') ? $r('app.color.text_hint')
: (cell.dateMs === this.selectedDate ? $r('app.color.button_text') : $r('app.color.text_primary'))) : (cell.dateMs === this.selectedDate ? $r('app.color.button_text') : $r('app.color.text_primary')))
.width(this.isLandscape ? 22 : 26) .width(this.wideLayout ? 22 : 26)
.height(this.isLandscape ? 22 : 26) .height(this.wideLayout ? 22 : 26)
.textAlign(TextAlign.Center) .textAlign(TextAlign.Center)
.borderRadius(this.isLandscape ? 11 : 13) .borderRadius(this.wideLayout ? 11 : 13)
.backgroundColor(cell.dateMs === this.selectedDate .backgroundColor(cell.dateMs === this.selectedDate
? $r('app.color.selected_bg') ? $r('app.color.selected_bg')
: (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent))
if (!this.isLandscape) { if (!this.wideLayout) {
// 横屏高度有限,省略农历腾出空间 // 宽屏下月历格子变窄,省略农历腾出空间
Text(cell.lunar) Text(cell.lunar)
.fontSize(8) .fontSize(8)
.fontColor(cell.dateMs === this.selectedDate .fontColor(cell.dateMs === this.selectedDate
+494 -62
View File
@@ -1,6 +1,6 @@
// entry/src/main/ets/pages/SettingsPage.ets // entry/src/main/ets/pages/SettingsPage.ets
// 设置页:系统日历模式(仅显示/备份到 CalDAV)+ 混合显示开关 + 自动同步间隔 + 后台同步 // 设置页:系统日历模式(仅显示/备份到 CalDAV)+ 混合显示开关 + 自动同步间隔 + 后台同步
import { router } from '@kit.ArkUI'; import { curves, router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { notificationManager } from '@kit.NotificationKit'; import { notificationManager } from '@kit.NotificationKit';
import { AppSettings } from '../common/AppSettings'; import { AppSettings } from '../common/AppSettings';
@@ -9,6 +9,8 @@ import { CalendarDataService } from '../common/CalendarDataService';
import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { BackgroundSyncService } from '../common/BackgroundSyncService';
import { AccountStore, DavAccount } from '../common/AccountStore'; import { AccountStore, DavAccount } from '../common/AccountStore';
import { ReminderService } from '../common/ReminderService'; import { ReminderService } from '../common/ReminderService';
import { BusinessError } from '@kit.BasicServicesKit';
import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { DocViewer } from '../common/DocViewer'; import { DocViewer } from '../common/DocViewer';
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
@@ -16,6 +18,7 @@ const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
/** 日历本选项(备份目标 / 只读标记 / 静音标记共用) */ /** 日历本选项(备份目标 / 只读标记 / 静音标记共用) */
class BackupTarget { class BackupTarget {
calKey: string = ''; calKey: string = '';
bookId: string = ''; // ⭐ 稳定标识(由服务器 href 派生):镜像选中项用它存,不随序号变
label: string = ''; label: string = '';
serverWritable: boolean = true; // 服务器探测结果 serverWritable: boolean = true; // 服务器探测结果
} }
@@ -27,8 +30,9 @@ struct SettingsPage {
@State intervalMinutes: number = 1; @State intervalMinutes: number = 1;
@State backgroundSync: boolean = false; @State backgroundSync: boolean = false;
@State sysMode: string = 'display'; // display | backup @State sysMode: string = 'display'; // display | backup
@State backupKey: string = ''; @State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
@State backupTargets: BackupTarget[] = []; @State backupTargets: BackupTarget[] = [];
@State backupNotice: string = ''; // 备份目标失效/未选择时的提示文案
@State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读/静音标记管理用) @State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读/静音标记管理用)
@State manualKeys: string[] = []; // 手动标记只读的 calKey @State manualKeys: string[] = []; // 手动标记只读的 calKey
@State mutedKeys: string[] = []; // 提醒静音的 calKey @State mutedKeys: string[] = []; // 提醒静音的 calKey
@@ -40,6 +44,13 @@ struct SettingsPage {
@State docUrl: string = ''; @State docUrl: string = '';
@State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda @State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda
@State displayStyle: string = 'timeline'; // 日程显示方式:timeline(时间轴)| list(列表) @State displayStyle: string = 'timeline'; // 日程显示方式:timeline(时间轴)| list(列表)
@State mirrorEnabled: boolean = false; // 是否把选中 CalDAV 日历本镜像到系统日历(默认关)
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
@State mirrorRunning: boolean = false; // 正在执行镜像
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
private context?: common.Context; private context?: common.Context;
aboutToAppear(): void { aboutToAppear(): void {
@@ -84,6 +95,29 @@ struct SettingsPage {
this.refreshNotifyState(); this.refreshNotifyState();
this.loadBackupSettings(); this.loadBackupSettings();
this.loadAllBooks(); this.loadAllBooks();
AppSettings.getMirrorEnabled(ctx).then((v: boolean): void => {
this.mirrorEnabled = v;
// 状态自洽:记录为"开"但权限已被系统回收 → 自动回落为"关"
if (v) {
SystemCalendarMirror.hasPermission().then((ok: boolean): void => {
if (!ok && this.context !== undefined) {
this.mirrorEnabled = false;
AppSettings.setMirrorEnabled(this.context, false);
}
});
}
});
// ⭐ 走 selectedBookRefs:它兼做历史迁移(老版本存的是 calKey)与"排除备份目标本"过滤
SystemCalendarMirror.selectedBookRefs(ctx).then((refs: BookRef[]): void => {
const ids: string[] = [];
for (const r of refs) {
ids.push(r.bookId);
}
this.mirrorKeys = ids;
});
AppSettings.getMirrorInbound(ctx).then((v: boolean): void => {
this.mirrorInbound = v;
});
} }
/** 重新检测通知权限状态 */ /** 重新检测通知权限状态 */
@@ -138,7 +172,51 @@ struct SettingsPage {
books.push(b); books.push(b);
} }
} }
// ⭐ 补上稳定标识 bookId(由服务器 href 派生)。镜像的"选中状态"必须按它存,
// 否则日历本重排后序号一变,选中的本就串成另一个本。
if (this.context !== undefined) {
const refs: BookRef[] = await SystemCalendarMirror.listBooks(this.context);
for (const b of books) {
for (const ref of refs) {
if (ref.calKey === b.calKey) {
b.bookId = ref.bookId;
break;
}
}
}
}
this.allBooks = books; this.allBooks = books;
await this.refreshMirrorCandidates();
}
/**
* 可镜像的日历本 = 全部 DAV 本 **去掉「系统日历备份目标本」**。
* 备份目标本里的日程本来就来自系统日历(uid 前缀 syscal-),再镜像回系统日历就是自我复制。
*/
private async refreshMirrorCandidates(): Promise<void> {
if (this.context === undefined) {
return;
}
const ctx: common.Context = this.context;
const blocked: string[] = await SystemCalendarMirror.blockedBookIds(ctx);
this.mirrorCandidates =
this.allBooks.filter((b: BackupTarget): boolean => b.bookId === '' || !blocked.includes(b.bookId));
// 已选中的本若被改成备份目标 → 自动剔除并删掉它在系统日历里的镜像账户
const kept: string[] = this.mirrorKeys.filter((k: string): boolean => !blocked.includes(k));
if (kept.length !== this.mirrorKeys.length) {
for (const k of this.mirrorKeys) {
if (blocked.includes(k)) {
await SystemCalendarMirror.removeById(ctx, k);
}
}
this.mirrorKeys = kept;
await AppSettings.setMirrorBookIds(ctx, kept);
}
}
/** 因"是备份目标本"而被隐藏、不能镜像的本的数量 */
private mirrorHiddenCount(): number {
return this.allBooks.length - this.mirrorCandidates.length;
} }
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */ /** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
@@ -193,12 +271,21 @@ struct SettingsPage {
}); });
} }
/** 加载备份目标候选(可写 DAV 日历本)+ 当前选择 */ /**
* 加载备份目标候选(可写 DAV 日历本)+ 当前选择。
*
* ⭐ 当前选择用**稳定标识 bookId** 记录(老版本存 calKey,序号会漂移 → 曾把系统日历
* 导进用户没选过的本)。这里统一走 `checkBackupTarget()`:
* - 老值自动迁移为 bookId;
* - 目标本已不存在(被取消勾选/账号删除)→ 它会把备份模式**自动关回 display**,
* 这里只负责把结果反映到 UI 并给出提示。
*/
private async loadBackupSettings(): Promise<void> { private async loadBackupSettings(): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
} }
const accounts: DavAccount[] = await AccountStore.loadAll(this.context); const ctx: common.Context = this.context;
const accounts: DavAccount[] = await AccountStore.loadAll(ctx);
const targets: BackupTarget[] = []; const targets: BackupTarget[] = [];
for (const acc of accounts) { for (const acc of accounts) {
if (acc.type !== 'caldav') { if (acc.type !== 'caldav') {
@@ -219,49 +306,94 @@ struct SettingsPage {
} }
} }
// 手动标记只读的本不可作为备份目标 // 手动标记只读的本不可作为备份目标
const manual: string[] = await AppSettings.getManualReadonlyKeys(this.context); const manual: string[] = await AppSettings.getManualReadonlyKeys(ctx);
this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey)); this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey));
const saved: string = await AppSettings.getBackupCalKey(this.context); // 补 bookId(稳定标识)—— 选择状态必须按它存
this.backupKey = targets.some((t: BackupTarget): boolean => t.calKey === saved) ? saved : ''; const refs: BookRef[] = await SystemCalendarMirror.listBooks(ctx);
for (const t of this.backupTargets) {
for (const r of refs) {
if (r.calKey === t.calKey) {
t.bookId = r.bookId;
break;
}
}
}
// 解析当前目标(可能触发失效清理,并在功能开着时自动关闭)
const check: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(ctx);
this.backupKey = check.ref === undefined ? '' : check.ref.bookId;
if (check.reason !== '') {
this.backupNotice = check.closed
? `${check.reason},已自动关闭系统日历备份。如需继续备份,请在下方重新选择目标日历本。`
: `${check.reason}。请重新选择备份目标日历本后再开启备份。`;
} else {
this.backupNotice = '';
}
// 最后再以"落盘值"为准刷新一次选择器:aboutToAppear 里另有一条 getSysCalMode 的异步读取,
// 若 checkBackupTarget 刚刚把模式关成了 display,就以这里读到的为准(避免旧值把 UI 覆盖回去)
this.sysMode = await AppSettings.getSysCalMode(ctx);
} }
/**
* 切换「系统日历日程的处理方式」。
*
* ⚠️ 顺序很重要:**先校验备份目标、再申请权限、最后才落盘**。
* - 没有目标本就不允许开启备份(绝不替用户"随便挑一个本"——
* 那样会把系统日程导进他没选过的日历本,用户实测反馈的问题);
* - 校验不过就不申请「读取全部日程」权限,避免为一次注定失败的开启弹权限框。
*/
private async saveSysMode(mode: string): Promise<void> { private async saveSysMode(mode: string): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
} }
// "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限 if (mode !== 'backup') {
if (mode === 'backup') { this.sysMode = 'display';
const granted: boolean = await CalendarDataService.requestSystemCalendarPermission( await AppSettings.setSysCalMode(this.context, 'display');
this.context as common.UIAbilityContext); this.backupNotice = '';
if (!granted) { await this.refreshMirrorCandidates(); // 备份模式变化 → 重算可镜像的本(防回灌)
this.sysMode = 'display'; // 回弹选择,保持与实际授权状态一致 this.getUIContext().getPromptAction().showToast({
this.getUIContext().getPromptAction().showToast({ message: '已切换为仅显示:不再把系统日程备份到 CalDAV'
message: '未获得"读取全部日程"权限,无法开启系统日历备份' });
}); return;
return; }
} // ① 必须先有明确的备份目标本(见上方 loadBackupSettings / saveBackupTarget)
if (this.backupKey === '') {
this.sysMode = 'display'; // 回弹,且**不写 preferences**:宁可不开启,也不默认挑一个本
this.backupNotice = this.backupTargets.length > 0
? '请先选择备份目标日历本:未选定目标前不会开启备份,也不会导入任何系统日程。'
: '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限。';
this.getUIContext().getPromptAction().showToast({
message: '请先选择备份目标日历本,再开启备份'
});
return;
}
// ② "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限
const granted: boolean = await CalendarDataService.requestSystemCalendarPermission(
this.context as common.UIAbilityContext);
if (!granted) {
this.sysMode = 'display'; // 回弹选择,保持与实际授权状态一致
this.getUIContext().getPromptAction().showToast({
message: '未获得"读取全部日程"权限,无法开启系统日历备份'
});
return;
} }
this.sysMode = mode; this.sysMode = mode;
await AppSettings.setSysCalMode(this.context, mode); await AppSettings.setSysCalMode(this.context, mode);
if (mode === 'backup' && this.backupKey === '') { this.backupNotice = '';
if (this.backupTargets.length > 0) { await this.refreshMirrorCandidates(); // 备份模式/目标变化 → 重算可镜像的本(防回灌)
this.backupKey = this.backupTargets[0].calKey;
await AppSettings.setBackupCalKey(this.context, this.backupKey);
}
}
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: mode === 'backup' message: '已开启备份:下次同步时把系统本地日程导入所选日历本'
? '已开启备份:下次同步时把系统本地日程导入所选日历本'
: '已切换为仅显示:不再把系统日程备份到 CalDAV'
}); });
} }
private async saveBackupTarget(calKey: string): Promise<void> { /** 保存备份目标(入参为 **bookId**,稳定标识,不再是会漂移的 calKey) */
private async saveBackupTarget(bookId: string): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
} }
this.backupKey = calKey; this.backupKey = bookId;
await AppSettings.setBackupCalKey(this.context, calKey); await AppSettings.setBackupBookId(this.context, bookId);
this.backupNotice = ''; // 用户显式选定了目标 → 清掉"失效/未选择"提示
await this.refreshMirrorCandidates(); // 备份目标变了 → 可镜像的本也要跟着变(防回灌)
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: '备份目标已更新,下次同步生效' }); .showToast({ message: '备份目标已更新,下次同步生效' });
} }
@@ -291,6 +423,135 @@ struct SettingsPage {
.showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' }); .showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' });
} }
/**
* 「镜像到系统日历」开关。
* ⚠️ 合规约束(《审核指南》7.17):写入系统日历依赖 WRITE_CALENDAR 权限,
* **只在用户主动打开这个开关时申请**;未授权则开关回弹、不保存设置。
* 关闭时会把之前镜像出去的账户一并删掉,避免系统日历里留下"幽灵日程"。
*/
private async saveMirrorEnabled(isOn: boolean): Promise<void> {
if (this.context === undefined) {
return;
}
if (isOn) {
const granted: boolean = await SystemCalendarMirror.requestPermission(
this.context as common.UIAbilityContext);
if (!granted) {
this.mirrorEnabled = false; // 回弹开关,保持与实际授权状态一致
this.getUIContext().getPromptAction().showToast({
message: '未获得"写入系统日历"权限,无法开启镜像'
});
return;
}
}
this.mirrorEnabled = isOn;
await AppSettings.setMirrorEnabled(this.context, isOn);
if (isOn) {
this.getUIContext().getPromptAction().showToast({
message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」'
});
return;
}
const n: number = await SystemCalendarMirror.removeAll(this.context);
this.getUIContext().getPromptAction().showToast({
message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像'
});
}
/** 勾选/取消勾选要镜像的日历本;取消勾选会顺手删掉对应的系统日历账户。⚠️ 传的是 bookId */
private async toggleMirrorKey(bookId: string, on: boolean): Promise<void> {
if (this.context === undefined || bookId === '') {
return;
}
const idx: number = this.mirrorKeys.indexOf(bookId);
if (on && idx < 0) {
this.mirrorKeys = [...this.mirrorKeys, bookId];
} else if (!on && idx >= 0) {
this.mirrorKeys = this.mirrorKeys.filter((k: string): boolean => k !== bookId);
await SystemCalendarMirror.removeById(this.context, bookId);
} else {
return;
}
await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys);
}
/**
* 「把系统日历的改动回写」开关(双向闭环的回程)。
* 依赖镜像快照(MirrorSnapshot)防回灌:只把"和上次写入值不一样"的当成用户改动。
* ⚠️ 默认关,因为它会写用户的服务器数据 —— 建议先单向观察一轮再打开。
*/
private async saveMirrorInbound(isOn: boolean): Promise<void> {
if (this.context === undefined) {
return;
}
this.mirrorInbound = isOn;
await AppSettings.setMirrorInbound(this.context, isOn);
this.getUIContext().getPromptAction().showToast({
message: isOn
? '已开启回写:系统日历里的新建/修改/删除,下次同步时传回服务器'
: '已关闭回写:系统日历的改动不再传回服务器'
});
}
/** 手动执行一次镜像(幂等,可反复点) */
private async runMirrorNow(): Promise<void> {
if (this.context === undefined || this.mirrorRunning) {
return;
}
if (this.mirrorKeys.length === 0) {
this.getUIContext().getPromptAction().showToast({ message: '请先勾选要镜像的日历本' });
return;
}
this.mirrorRunning = true;
try {
const res: MirrorResult = await SystemCalendarMirror.syncNow(this.context);
const skip: string = res.skipped > 0 ? ` / 跳过 ${res.skipped}` : '';
this.getUIContext().getPromptAction().showToast({
message: `镜像完成:${res.books} 个本,新增 ${res.added} / 更新 ${res.updated} / 删除 ${res.deleted}${skip}`
});
} catch (err) {
const e = err as BusinessError;
const msg: string = e.message !== undefined && e.message !== ''
? e.message : '请确认已授予"写入系统日历"权限';
this.getUIContext().getPromptAction().showToast({ message: `镜像失败:${msg}` });
} finally {
this.mirrorRunning = false;
}
}
/**
* 「清理残留镜像账户(诊断)」按钮:
* ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志;
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
*/
private async runPurgeOrphans(): Promise<void> {
if (this.context === undefined || this.purgeRunning) {
return;
}
this.purgeRunning = true;
try {
const r: PurgeResult = await SystemCalendarMirror.forceCleanup(this.context);
this.accountDump = r.detail.join('\n');
let msg: string = `已清理 ${r.removed} 个残留账户`;
if (r.failed > 0) {
msg += `,失败 ${r.failed} 个`;
}
if (r.remaining > 0) {
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
}
msg += '(完整账户清单见同步日志)';
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
} catch (err) {
const e = err as BusinessError;
this.getUIContext().getPromptAction().showToast({
message: `清理失败:${e.message ?? '请确认已授予"写入系统日历"权限'}`
});
} finally {
this.purgeRunning = false;
}
}
private async saveInterval(minutes: number): Promise<void> { private async saveInterval(minutes: number): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
@@ -305,23 +566,17 @@ struct SettingsPage {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
} }
// 后台持续同步依赖常驻通知 → 在用户主动开启时才申请通知权限(不随启动自动弹窗)
if (value) {
await this.ensureNotifyPermission();
}
try { try {
await BackgroundSyncService.setBackgroundSync( await BackgroundSyncService.setBackgroundSync(
this.context as common.UIAbilityContext, value); this.context as common.UIAbilityContext, value);
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: value message: value ? '后台自动同步已开启' : '后台自动同步已关闭'
? '后台持续同步已开启,通知栏将显示常驻通知'
: '后台持续同步已关闭'
}); });
} catch (err) { } catch (err) {
this.backgroundSync = !value; // 失败回滚 UI this.backgroundSync = !value; // 失败回滚 UI
const msg: string = (err as BusinessError).message !== '' const msg: string = (err as BusinessError).message !== ''
? (err as BusinessError).message : '请重试'; ? (err as BusinessError).message : '请重试';
this.getUIContext().getPromptAction().showToast({ message: `开启失败:${msg}` }); this.getUIContext().getPromptAction().showToast({ message: `设置失败:${msg}` });
} }
} }
@@ -421,9 +676,20 @@ struct SettingsPage {
return; return;
} }
try { try {
// ⭐ 账号库整体重置 = 所有账号都没了 → 系统日历里的全部镜像账户也应一并清除
// (否则会留下一堆无主的幽灵日历本,且再无人能对应回账号)。
// 未授权/无镜像时返回 0,不影响重置本身。
let mirrored: number = 0;
try {
mirrored = await SystemCalendarMirror.removeAll(this.context);
} catch (err) {
// 清理失败不阻断重置
}
await AccountStore.resetVault(this.context); await AccountStore.resetVault(this.context);
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: '账号库已重置,请重新添加账号' message: mirrored > 0
? `账号库已重置,并清除系统日历中 ${mirrored} 个镜像日历本`
: '账号库已重置,请重新添加账号'
}); });
} catch (err) { } catch (err) {
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
@@ -533,31 +799,184 @@ struct SettingsPage {
this.saveSysMode(mode); this.saveSysMode(mode);
} }
}) })
if (this.sysMode === 'backup') { // ⭐ 备份目标**始终可配置**(不再只在 backup 模式下显示):
Text(this.backupTargets.length > 0 // 否则"先选目标才能开启备份"会变成死锁(Select 只在 backup 模式下渲染)。
? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份' Text(this.backupTargets.length > 0
: '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限') ? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份'
.fontSize(12) : '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限')
.fontColor(this.backupTargets.length > 0 .fontSize(12)
? $r('app.color.text_secondary') : $r('app.color.error')) .fontColor(this.backupTargets.length > 0
? $r('app.color.text_secondary') : $r('app.color.error'))
.width('100%')
if (this.backupTargets.length > 0) {
Select(this.backupTargets.map((t: BackupTarget): SelectOption => {
return { value: t.label } as SelectOption;
}) as SelectOption[])
.selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.bookId === this.backupKey))
.value(this.backupTargets.find((t: BackupTarget): boolean => t.bookId === this.backupKey)?.label
?? '请选择日历本')
.fontColor($r('app.color.text_primary'))
.font({ size: 14 })
.optionFont({ size: 14 })
.selectedOptionFont({ size: 14 })
.width('100%') .width('100%')
if (this.backupTargets.length > 0) { .onSelect((index: number) => {
Select(this.backupTargets.map((t: BackupTarget): SelectOption => { if (index >= 0 && index < this.backupTargets.length) {
return { value: t.label } as SelectOption; this.saveBackupTarget(this.backupTargets[index].bookId);
}) as SelectOption[]) }
.selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.calKey === this.backupKey)) })
.value(this.backupTargets.find((t: BackupTarget): boolean => t.calKey === this.backupKey)?.label }
?? '请选择日历本') // 失效/未选择提示:目标本没了时功能已被自动关闭,必须让用户看得见原因
if (this.backupNotice !== '') {
Row({ space: 6 }) {
Text('⚠️')
.fontSize(12)
.fontColor($r('app.color.error'))
Text(this.backupNotice)
.fontSize(12)
.fontColor($r('app.color.error'))
.layoutWeight(1)
}
.width('100%')
.padding(8)
.borderRadius(8)
.backgroundColor($r('app.color.error_bg'))
} else if (this.backupTargets.length > 0 && this.backupKey === '') {
Text('尚未选择备份目标:请先选择要把系统日历备份到哪个 CalDAV 日历本,否则无法开启备份')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%')
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 镜像到系统日历:把选中的 CalDAV 日历本写入系统日历,让小艺 / 桌面卡片 / 手表也能看到
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('镜像到系统日历')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
.font({ size: 14 }) Text('把选中的 CalDAV 日历本写入系统日历,这样小艺、桌面日历卡片和手表也能看到你的日程(需授权写入系统日历)')
.optionFont({ size: 14 }) .fontSize(12)
.selectedOptionFont({ size: 14 }) .fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.mirrorEnabled })
.selectedColor($r('app.color.brand'))
.onChange((isOn: boolean) => {
this.saveMirrorEnabled(isOn);
})
}
.width('100%')
if (this.mirrorEnabled) {
if (this.mirrorCandidates.length === 0) {
Text('没有可镜像的 CalDAV 日历本,请先添加账号')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%') .width('100%')
.onSelect((index: number) => { } else {
if (index >= 0 && index < this.backupTargets.length) { Text('选择要镜像的日历本:每个本在系统日历里是独立账户,可单独设色与隐藏')
this.saveBackupTarget(this.backupTargets[index].calKey); .fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
if (this.mirrorHiddenCount() > 0) {
Text(`另有 ${this.mirrorHiddenCount()} 个本被设为「系统日历备份目标」,其日程本就来自系统日历,已自动排除以免重复`)
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.width('100%')
}
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
Row({ space: 6 }) {
Text(b.label)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Toggle({ type: ToggleType.Checkbox, isOn: this.mirrorKeys.indexOf(b.bookId) >= 0 })
.selectedColor($r('app.color.brand'))
.onChange((on: boolean) => {
this.toggleMirrorKey(b.bookId, on);
})
} }
.width('48%')
.padding({ left: 2, right: 2, top: 4, bottom: 4 })
}, (b: BackupTarget): string => b.calKey)
}
.width('100%')
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('把系统日历的改动回写')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('你在系统日历里新建、修改、删除的日程,同步回 CalDAV 服务器')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.mirrorInbound })
.selectedColor($r('app.color.brand'))
.onChange((isOn: boolean) => {
this.saveMirrorInbound(isOn);
})
}
.width('100%')
.padding({ top: 4 })
Button(this.mirrorRunning ? '正在执行…' : '立即执行镜像')
.fontSize(14)
.width('100%')
.enabled(!this.mirrorRunning)
.onClick(() => {
this.runMirrorNow();
}) })
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.backgroundColor(Color.Transparent)
.width('100%')
.enabled(!this.purgeRunning)
.onClick(() => {
this.runPurgeOrphans();
})
if (this.accountDump !== '') {
Column({ space: 4 }) {
Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):')
.fontSize(11)
.fontColor($r('app.color.text_secondary'))
.width('100%')
Scroll() {
Text(this.accountDump)
.fontSize(10)
.lineHeight(14)
.fontColor($r('app.color.text_primary'))
.copyOption(CopyOptions.LocalDevice)
.width('100%')
}
.height(150)
.width('100%')
.backgroundColor($r('app.color.page_bg'))
.borderRadius(8)
.padding(6)
}
.width('100%')
.padding({ top: 4 })
}
} }
} }
} }
@@ -772,15 +1191,15 @@ struct SettingsPage {
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') }) .border({ width: 1, color: $r('app.color.shadow_color') })
// 后台持续同步(长时任务) // 后台自动同步
Column({ space: 8 }) { Column({ space: 8 }) {
Row({ space: 10 }) { Row({ space: 10 }) {
Column({ space: 2 }) { Column({ space: 2 }) {
Text('后台持续同步') Text('后台自动同步')
.fontSize(15) .fontSize(15)
.fontWeight(FontWeight.Medium) .fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
Text('开启后申请后台长时任务,App 退到后台也按设定间隔继续同步(通知栏会显示常驻通知)') Text('开启后,退到后台仍会定时同步(系统最短约 2 小时一次);回到前台或网络恢复时会立即补同步一次')
.fontSize(12) .fontSize(12)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
} }
@@ -1048,6 +1467,19 @@ struct SettingsPage {
.height('100%') .height('100%')
} }
/**
* 页面转场:淡入淡出。
* 不做左右平移 / 上下位移(不调用 .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
legalSheet() { legalSheet() {
+11 -19
View File
@@ -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;
+11 -19
View File
@@ -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;
+12 -51
View File
@@ -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,50 +23,12 @@ 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);
} }
} }
} }
+10 -6
View File
@@ -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"
}, },
@@ -38,6 +35,16 @@
"when": "inuse" "when": "inuse"
} }
}, },
{
"name": "ohos.permission.WRITE_CALENDAR",
"reason": "$string:perm_write_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{ {
"name": "ohos.permission.LOCATION", "name": "ohos.permission.LOCATION",
"reason": "$string:perm_location", "reason": "$string:perm_location",
@@ -73,9 +80,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": [
@@ -18,7 +18,7 @@
}, },
{ {
"name": "perm_write_calendar", "name": "perm_write_calendar",
"value": "写入系统日历,用于保存您创建的日程" "value": "写入系统日历,用于把您选择的 CalDAV 日历本镜像到系统日历,让小艺、桌面日历卡片和手表也能看到您的日程"
}, },
{ {
"name": "perm_read_whole_calendar", "name": "perm_read_whole_calendar",