12 Commits
Author SHA1 Message Date
laoyang 29898d5dff 修复:改了日历本颜色,系统日历镜像颜色不跟变
根因:SyncEngine.refreshCalendarColors 每轮同步都 PROPFIND 服务器把新
calendar-color 写进**内存**里的账号对象,但从不 saveAll(唯一落盘点是
removeDeadBooks 的"服务器删本"分支)。而镜像 listBooks 是重新
AccountStore.loadAll 从磁盘读 → 拿到的永远是旧颜色 → ensureCalendar
setConfig(旧色) → 系统日历颜色永远不变。
用户在「账号设置」页看到的是**当场 PROPFIND** 的新颜色,所以表现为
"设置页颜色对、系统日历颜色错"。

修法:
1) refreshCalendarColors 返回 boolean(颜色/写权限是否有实际变化,
   含数组长度补齐);syncAccountInner 在有变化时立刻 loadAll+saveAll
   落盘(同 removeDeadBooks 的既有模式),镜像同轮即可读到新色;
2) SystemCalendarMirror.ensureCalendar 的 setConfig 失败由静默改为
   写日志(新建/复用两处),"颜色不变"从此可排查。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-21 07:11:23 +08:00
laoyang c711cdb7d5 版本号 0.0.8 -> 0.0.9(versionCode 900) 2026-10-20 22:34:12 +08:00
laoyang 2f124eada0 镜像到系统日历:新增「全选/全不选」日历本
设置页镜像勾选网格上方加一行「全选(已选 x/N)」:
· 整行可点;勾选框半选时显示为关,点按 = 全选,再次点按 = 全不选;
· 全选:一次性把全部候选本(已排除"系统日历备份目标本"防回灌)写入
  mirror_book_ids —— preferences 只落盘一次、提醒缓存只失效一次,
  与逐个 toggle 的关键区别;
· 全不选:逐本 removeById 删对应系统日历账户(与取消单个勾选同语义),
  逐个独立 try,失败不影响其余;
· onChange 加同值守卫,过滤系统重复回调导致的二次翻转。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-20 22:31:29 +08:00
laoyang 942f71bfdf 0.0.8 上架收尾:移除同步记录落盘 + 镜像空清单防护 + 前台默认间隔 15 分钟
三处改动(均来自 2026-10-16 真机日志实测):

1) 移除「同步记录」落盘
   · LogUtil 顶部新增 ENABLE_FILE_LOG=false(发行版只输出 console),
     真机排查时改 true 即可恢复落盘,所有调用点无需改动;
   · init() 里顺手 unlink 历史版本遗留的 sync.log / mirror_diag.txt,
     保证升级上来的设备沙箱也干净;
   · SystemCalendarMirror 删掉 dumpDiagnosis 整个方法(不再导出 mirror_diag.txt),
     连带去掉已无引用的 fs 依赖;
   · SettingsPage「清理残留镜像账户」提示语由"见同步日志"改为"见下方"(清单本就在页面上)。

2) 镜像防护:系统日历清单为 0 时跳过创建
   · 依据:2026-10-16 07:55:00 日志 `系统日历账户清单(共 0)` →
     `镜像完成:本=0 … 失败=12`,12 个 bookId 连续 `201 CreateCalendar failed!`。
   · 真因:设备重启后尚未解锁(direct boot),日历数据在加密存储里读不到,
     getAllCalendars() 返回空列表 ⇒ createCalendar 必然失败,还可能留下半创建账户。
   · 正常设备至少有一个本机日历,故"清单为 0"只可能是服务未就绪 ⇒ syncNow()
     在 plan/syncBook 之前直接跳过本轮,等解锁后的下一次同步自然恢复。

3) 前台自动同步默认间隔 1 分钟 → 15 分钟
   · 依据:2026-10-16 17:16–17:20 日志,5 分钟内连跑 5 轮
     "12 本全量比对 + 出站镜像 + 提醒刷新",随后
     `17:20:43 短时任务超时(配额用尽),后台同步将被挂起`
     —— 前台把短时任务配额烧光,反而拖垮了真正的后台同步通道。
   · 设备 preferences 里并无 sync_interval_minutes 键 ⇒ 走的就是这个默认值。
   · AppSettings.getSyncIntervalMinutes 默认值 + Index.startAutoSync 的
     setOrCreate 与 ?? 兜底 + SettingsPage 的 @State 初值,四处一并改为 15。
   · 用户仍可在设置页选 1/5/15/30 分钟 或 1 小时。

验证:assembleHap 与 assembleApp 均 BUILD SUCCESSFUL。
2026-10-16 19:38:21 +08:00
laoyang 24d8117ce7 v51c 卡片后台同步:修复"每轮全量重拉",单账号 236 秒 → 10 秒
真机实测根因:服务端 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/','_',$uid)
派生资源文件名(UID movie-188@familybook → 资源 movie-188_familybook.ics),
而客户端 SyncEngine.syncAccountInner 拿 REPORT 给的**资源名当 uid** 去本地行里查
⇒ byUid 永远查不到 ⇒ 每轮判"变了" ⇒ 全量 GET。ETag 明明一样,比较压根执行不到。

改动:
· SyncEngine —— 加 byEtag 兜底(uid 查不到就用内容哈希认领本地行)+ 复用判定计数日志
  (uid 命中 / etag 兜底命中 / 需补拉);抽 groupPush() 去重。
· EventDb.applyRemote —— existing.find() 的 O(n·m) → keyed.get() 的 O(n)。
· BackgroundSyncRunner —— runOnce 每阶段计时(+Xms)+ pid;预算可由 preferences
  card_budget_ms 覆盖(诊断开关,默认走内置 9 秒)。
· AppSettings —— getCardBudgetMs()。
· EntryFormAbility —— onUpdateForm 日志带 pid。
· MirrorInbound —— 顺带修一个已死的功能:getEvents(undefined, 字段) 在 7.0.0.109 上
  必抛 401 type error!,导致**入站对账 12/12 全失败**;改成"先无参、失败再带字段"
  (照抄 SystemCalendarMirror.queryEvents 的绕法)。

真机验证(DEL-AL20,卡片 onUpdateForm 触发,卡片进程 pid=27411):
  [21:33:06] 卡片驱动同步:开始(可用窗口 9 秒)
  [21:33:09] 「观影」复用判定:uid 命中 0、etag 兜底命中 193、需补拉 0(共 193)
  [21:33:09] 「观影」落库完成:不变193,耗时 1 秒     ← 修复前 124 秒
  [21:33:17] 同步账号完成:拉取 2182 条,耗时 10 秒   ← 修复前 215~236 秒
  [21:33:22] 后台同步[cardTick] 完成:1/1 个账号,总耗时 16 秒
12 个日历本全部跑完 + 出站镜像,9 秒窗口不再"连第一个本都过不去"。

复算(真库快照 + 真服务器 PROPFIND,_tooling/_sc_etag_sim.py):
  旧逻辑补拉 1384 个 → 新逻辑 80 个(降 94.2%);观影 193→0、旅行 23→0、工作 362→5。
  另证服务端 ETag == md5(正文)、三次 PROPFIND 逐字节一致 ⇒ etag 兜底语义正确、非碰巧。
2026-10-15 21:39:54 +08:00
laoyang 8ae4e11507 修复同步后日程时间少 8 小时(服务器会删掉 DTSTART/DTEND 的 Z / TZID)
现象:客户端建的 10-16 08:00~17:00 同步到服务器后变成 00:00~09:00。

根因(2026-10-15 连服务器 family.yangyq.net 实测确认,不是 App 的解析 bug):
服务器会**直接删掉** DTSTART/DTEND 上的 `Z` 与 `TZID` 参数,**且不对数值做任何
时移**:
  PUT DTSTART:20261020T060000Z                     -> 存回 DTSTART:20261020T060000
  PUT DTSTART;TZID=Asia/Shanghai:20261020T140000   -> 存回 DTSTART:20261020T140000
于是"UTC 00:00"被当成"本地 00:00"。而本地 08:00 的正确 UTC 恰好就是 00:00,
所以症状正好是阅读端少 8 小时。
(DTSTAMP/CREATED/LAST-MODIFIED 是 RFC 规定的 UTC-only 属性,服务器反而保留 Z。)

交叉验证:全账号 2190 条事件里,计时事件(1163 条)的 DTSTART **无一例外都是
浮动本地时间**——带 Z 的 0 条、带 TZID 的 0 条。即该服务器的事实约定就是
"所有时间都是本地墙钟"。第三方客户端写的事件同样是浮动 DTSTART,且
DTSTART 浮动 + `UNTIL=...`(浮动)成对出现(例:研究生例会)。

改法:事件时间一律写**浮动本地时间**,与服务器约定对齐。
- IcsUtil.vevent:DTSTART/DTEND/RECURRENCE-ID/EXDATE 由 fmtUtc 改为 fmtLocal
  (本地 getter、不带 Z)
- IcsUtil.fmtIcsTime:改为浮动本地(EXDATE / RECURRENCE-ID / UNTIL 共用)
- IcsUtil.rruleWithUntil:UNTIL 改为浮动本地
  (RFC 5545:DTSTART 是本地时间时 UNTIL 必须也是本地时间,二者必须同形态)
- DTSTAMP 仍写 UTC+Z(RFC 规定),fmtUtcNow 不变
- 读取侧无需改动:parseTime / RruleUtil.parseUntil 已把"无 Z"按本地解析,
  正好匹配浮动语义,往返一致

端到端验证(真服务器):
  新写法 DTSTART:20261016T080000 -> 服务器原样存回 -> 读回仍为本地 08:00 ✅

数据修复:服务器上唯一一条受影响的日程(工作 / 项目评审 /
UID syncal-1792039765773-100047)已由 00:00~09:00 改回 08:00~17:00。
全账号仅此一条是本 App 创建的计时事件。
2026-10-15 18:45:51 +08:00
laoyang c4dc27ef3a 0.0.8:卡片整卡可点 —— 点空白处也能进 App 并定位到当天
用户反馈:卡片的空白区域点了没反应,只有落在日程/文字上才跳转。
期望:点卡片**任何位置**都进入 App 并**定位到卡片当前显示的那一天**。

实现(4 张卡片:2x2 / 2x4 / 4x4 / 6x4 一致处理):
1. 根容器加 `.onClick(() => this.openApp())` → **整卡可点**(含留白);
2. `openApp()` 携带 `openDate` 深链参数,值为卡片数据里的 `selectedDate`
   (该字段早已在 CardData 中维护:CardDataService.ets:194 = 卡片当前显示日 0 点毫秒);
3. **传参必须转字符串**:`params: { openDate: `${this.selectedDate}` }`。
   ⚠️ 依据 4x4 原有注释:param 的数值按 **int32** 传递,13 位毫秒时间戳会溢出成
   2147483647(曾导致进 App 后定位到 1970)。2x2/4x2 原本传的是 number → 已修正为字符串。
4. **移除内部原有的 openApp 点击**(共 13 处:4x2 四、4x4 四、6x4 五),
   避免"子区域 + 根容器"双重触发同一动作。

关于事件冒泡(重要,避免误改):
- ArkUI 的 `onClick` 是**非冒泡**事件,父子组件同绑时**子组件优先**,同一时刻只有一个响应
  (华为官方论坛结论)。因此:
  · 根容器整卡可点**不会**抢走内部 ‹ › 今 + 按钮的点击;
  · **无需** `stopPropagation`(ClickEvent 上也**没有**这个方法——它只在
    MouseEvent / TouchEvent 上,实测编译报错已验证)。
- 内部原有的 4x4/6x4 注释写"兄弟节点布局不冒泡"是对的;本次统一补上说明。

验证:
- 编译通过(BUILD SUCCESSFUL)。
- 四卡一致性核验:每卡 openApp 点击点=1(仅根容器)、根容器=1、selectedDate 声明=1、
  openDate 字符串传参=1。
- 结构完整性:花括号配平(26/26、53/53、228/228、228/228),编译无语法错误。

⚠️ 未验证(待真机):实际点击留白是否跳转、是否正确定位到所显示的那一天;
以及点 ‹ › 今 + 是否**不会**连带打开 App(依赖上述"子组件优先"结论,需实测确认)。
2026-10-14 22:45:51 +08:00
laoyang bd3afb7bd1 0.0.8:卡片同步尝试用短时任务延长窗口(10 秒 → 最多 60 秒)
背景:上一步实现了"卡片 30 分钟刷新驱动同步",但受 FormExtensionAbility
"回调后进程仅存续 10 秒"的硬限制,同步可能被回收掐断。

用户建议:能不能调一个后台进程、或让异步同步在 10 秒后继续跑一会儿?

调研结论(已核实官方文档):
1. ❌ ServiceExtensionAbility(独立后台服务)—— 三方应用**根本不能用**:
   官方"仅系统应用可使用",需 Full SDK + AllowAppUsePrivilegeExtension 特权 + 设备白名单。
2. ❌ 从卡片进程 startAbility 拉起主应用 —— 会被拦:官方《组件启动规则》
   "位于后台的 UIAbility 应用,启动组件需校验 ohos.permission.START_ABILITIES_FROM_BACKGROUND,
    **该权限仅系统应用可申请**"。
   (卡片 postCardAction 的 `call` 事件确实能"后台拉起 UIAbility 且不弹界面",
    但**只能由用户点击卡片触发**,系统定时刷新的 onUpdateForm 用不上。)
3. ✅ **短时任务 `requestSuspendDelay`** —— 可行路径:让进程在后台多活一会儿,
   单次最长 3 分钟(低电量 1 分钟),实际时长以 actualDelayTime 为准。

本次改动(BackgroundSyncRunner):
- 新增 `acquireCardWindow()`:尝试申请短时任务,
  · **容错尝试**:官方规定应在"应用在前台"或"onBackground 回调"里调用,
    卡片进程是否符合**未知** → 抛错 / 时长为 0 时**优雅退回 9 秒软超时**,不阻塞流程;
  · 留 **15 秒余量**提前收手(保证能干净地 cancelSuspendDelay,
    否则超时不取消会被系统强制终止进程并浪费配额);
  · 加 **60 秒/次总上限**(CARD_MAX_TOTAL_MS)。
- 新增 `releaseCardWindow()`:无论成败都在 finally 里释放,归还配额。
- `runOnceThrottledByCard()` 改为按"实际可用窗口"设软超时,并记录窗口秒数到日志。

⚠️ 配额账(关键取舍):
  短时任务**单日总配额仅 10 分钟**,且**只在被挂起后计时**(10 秒内跑完不计费)。
  · 正常网络下增量同步几秒完成 → 多落在 10 秒窗口内 → 消耗极少;
  · 网络慢才会吃配额;吃满 10 分钟后自动降级为 9 秒软超时,不报错;
  · ⚠️ **不能吃光配额**:EntryAbility.onBackground 也申请短时任务(兜"退后台那一次同步"),
    故卡片侧设 60 秒/次上限 + 15 秒提前释放,避免把它饿死。

验证:编译通过(BUILD SUCCESSFUL);窗口预算逻辑离线用例通过
(申请被拒/时长为0 → 9 秒;低电量 → 45 秒;正常 3 分钟 → 被 60 秒上限截断;40 秒 → 25 秒)。
2026-10-14 16:51:56 +08:00
laoyang 3025f92cb6 0.0.8:借服务卡片 30 分钟刷新驱动后台同步
用户反馈"基本不会定期同步,每次都要手动点进去才同步"。
诊断发现主因是「后台自动同步」默认关闭(workScheduler 2 小时兜底根本没注册),
且系统对普通应用后台频率有硬管控(活跃分组下限 2 小时)。

本提交新增一条**免权限、约 30 分钟一次**的定期同步路径:
利用服务卡片已有的定时刷新(form_config.json 的 updateDuration=1,**单位 30 分钟**,
系统每 30 分钟调用一次 onUpdateForm),在该回调里顺带跑一次后台同步。

改动:
1. AppSettings 新增 `card_sync_at`(上次卡片驱动同步时间戳,用于节流)。
2. BackgroundSyncRunner 新增 `runOnceThrottledByCard(context)`:
   · 节流 CARD_MIN_GAP_MS = 25 分钟(略小于 30 分钟卡片周期,避免系统刷新漂移被误跳过);
   · **先写时间戳再执行** → 防止同步期间卡片再次刷新导致并发重入;
   · 复用现有 `runOnce(context,'cardTick')`(完整同步:入站对账→推送→拉取→镜像→刷新卡片与提醒);
   · **永不抛错**(后台路径没有 UI 承担异常)。
3. EntryFormAbility.onUpdateForm:
   · ① 先用**本地缓存**立刻刷卡片(毫秒级,不依赖网络);
   · ② 再跑节流同步,完成后二次推卡片数据,让卡片显示刚同步回来的结果。

⚠️ 关键约束(官方 FormExtensionAbility 生命周期原文):
  "FormExtensionAbility 进程不能常驻后台,生命周期回调中无法处理长时间的任务,
   生命周期调度完成后会继续存在 **10 秒**……可能需要 10 秒以上才能完成的业务逻辑,
   建议拉起主应用进行处理。"
  ⇒ 同步**可能被进程回收中断**,故:
    · 设 **9 秒软超时**主动收手(干净放弃而非被硬杀在半途);
    · 语义明确为"**尽力而为地提高后台同步频率**",不承诺 30 分钟必然成功;
    · 中断安全:同步幂等(etag 增量比对 + dirty 标记),中断不损坏数据,
      下个周期会重试;正常网络下增量同步通常数秒内完成,落在窗口内;
    · **不采用"拉起主应用同步"** —— 那会每 30 分钟弹出一次界面,体验不可接受。

配额核算:卡片定时刷新官方上限为**每张卡片每天 50 次**;30 分钟一次 = 48 次/天,
**不超配额**。相比 workScheduler(受活跃分组压制到 ≥2 小时),频率提升约 4 倍。

验证:编译通过(BUILD SUCCESSFUL);节流逻辑离线用例通过
(从未同步/30 分钟/26 分钟/正好 25 分钟 → 执行;24/5/1 分钟 → 跳过)。
2026-10-14 16:42:04 +08:00
laoyang d890a2b053 0.0.8 提醒修复(补):已镜像的本由系统日历全权负责,且判据改为"镜像真实存在"
用户要求:如果用户已把自己的日历同步到系统日历里,那么本应用就**完全不再提醒**了
—— 因为那必然是重复提醒。

采纳。同时修正上一版实现的一个**会漏提醒的弱点**:

问题:上一版按"**用户勾选了要镜像的本**"来跳过 App 提醒。但"勾选"只是**意图**,
本可能还没真正写进系统日历(如刚勾选未点「立即执行镜像」、系统日历里还没有对应账户)
→ 按意图跳过会导致**两边都不提醒(漏提醒)**。

改法:判据改为「**镜像账户真实存在**」,新增 `SystemCalendarMirror.mirroredCalKeys(context)`:
  1. 镜像开关未开 / 无系统日历权限 → 直接返回**空集**(不跳过任何本);
  2. 读系统日历全部账户,取出 name 以 `synccalendar.mirror.` 开头者的 **bookId**;
  3. 与 `listBooks()` 的 `bookId → calKey` 对照 → 只有**账户真实存在**的本才计入。
  安全阀:任何异常 → 空集 → 不跳过 → **宁可多提醒,绝不漏提醒**。

`ReminderService.mirroredKeys()` 改为委托给它(保留 30 秒缓存与 `invalidateMirrorCache()`);
`refreshReminders` / `tickReminders` 两处仍照旧跳过这些本(跳过即"App 完全不再提醒")。

交互完整性(不变):镜像开关开启/关闭、勾选本变化、权限被回收 四处
均 `invalidateMirrorCache()` + 立即 `refreshReminders()`,
确保**关闭镜像后这些本立刻恢复由 App 提醒**。

验证:编译通过(BUILD SUCCESSFUL);手机未连接 → 未装机。
2026-10-14 14:28:42 +08:00
laoyang 19cfe3cf34 0.0.8 修复:系统日历重复提醒 / 已过日程仍提醒
用户反馈:① 日程已开始/已过去,系统日历仍提醒;② 同一提前量(如提前 15 分钟)
提醒两次;③ 怀疑"打开 App 后又写一次系统日历"就多提醒一次。
诊断见《系统日历重复提醒-根因诊断.md》。用户怀疑正确,确为两个 bug。

修复一:消除双通道重复提醒(提醒两次)
- 根因:镜像到系统日历后,**系统日历自己会提醒**(条目 reminderTime),
  而 ReminderService 为**所有**本地日程排代理提醒、**完全不区分**该本是否已镜像
  → 同一条日程、同一提前量被两个应用各弹一次。
- 改法:新增 `ReminderService.mirroredKeys()` —— 取"已镜像到系统日历"的日历本 calKey
  集合(仅在「镜像开关已开 + 有系统日历权限 + 有选中本」时才非空),
  `refreshReminders` 与 `tickReminders` **两处都跳过**这些本,改由系统日历提醒。
- ⚠️ 保守设计:判定失败一律返回**空集**(不跳过任何本)→ 宁可多提醒,绝不漏提醒。
- ⚠️ 交互完整性:镜像开关变化时必须**立即失效缓存并重排提醒**(否则会漏提醒):
  · 开启镜像 → 交回系统日历负责;· 关闭镜像 → **立刻恢复 App 提醒**;
  · 勾选本变化、权限被系统回收 → 同样失效缓存并重排。
  新增 `ReminderService.invalidateMirrorCache()`。

修复二:消除"已过去的日程仍提醒"
- 根因:镜像窗口含**过去 12 个月**(windowStart 往前推 12 月),过去的日程也在系统日历里;
  而 `updateEvent` 是**整条覆写**(带 reminderTime),系统在覆写时会**重新计算提醒时刻**,
  时刻已过就**立刻补发**一条 → 表现为"过去了还提醒"。
- 改法:`toSystemEvent` 写入时只带**提醒时刻尚未到**的提醒(新增 `futureReminders()`)。
  已过期提醒已无意义 → 不写,从根上消除覆写补发。
  ⭐ 全天日程按系统口径以**当天 09:00** 为提醒基准(新增 `reminderBaseMs()`)。
- 刻意**不收缩镜像窗口**:保住历史日程在系统日历中的可见性,只去掉提醒。

修复三:让"改了提醒时间"能同步到系统日历(反向 bug)
- 根因:`diffOf` 只比较 title/isAllDay/start/end/description/location,
  **不比较 reminderTime** → 用户改了提醒时间被判"无差异",永不触发 updateEvent
  → 系统日历侧提醒一直不更新。
- 改法:`diffOf` 纳入 `reminderTime` 比较,用规范化 key(排序去重)避免因顺序/重复误判,
  且与修复二同口径(只比"会被写入的"提醒),不会导致每轮都重写。

验证:
- 编译通过(BUILD SUCCESSFUL)。
- 离线用例全部通过:
  · 修复二:已过去/提醒刚过/昨天全天 → 不写提醒;未来日程/明天全天 → 保留;
  · 修复三:提醒改了/新增 → 触发覆写;相同集合(顺序不同、含重复)→ 不覆写。
- 手机未连接 → 未装机(待用户插线测试)。
2026-10-14 14:00:58 +08:00
laoyang a49e7d03b1 0.0.8:系统日历账户显示名可切换 + 清理判定改为 bookId 身份校验
背景:应用往系统日历写数据时,账户显示名固定为「同步日历 · <本名>」。多账号用户
无法看出某个日历本属于哪个账号;若把"应用名+账号名"都写上又太长,本名被挤压看不清。
方案见《镜像账户命名方案-去掉应用名前缀.md》,影响评估见《0.0.8升级影响评估.md》。

平台硬约束(已核实 @ohos.calendarManager):
  **没有"修改账户"的 API**(只有 create/delete/get/getAll;CalendarConfig 仅
  enableReminder+color;CalendarAccount.name 为 readonly)→ displayName 只能在
  创建时定,改名必须"删账户 → 重建"。

实现:
1. **三种显示名格式**(分隔符统一半角点 `·` U+00B7、**无空格**):
     1 = `同步日历·<本名>`
     2 = `同步日历·<账号>·<本名>`
     3 = `<账号>·<本名>`  ★默认
   ⚠️ **不提供"仅本名"**:裸本名与用户原有同名日历本无法区分 → 有误删风险;
      每个镜像账户名必须至少带一个归属标识(同步日历/账号名/二者)。
   displayName 上限 64 字符 → 超长自动截断。
2. ⭐ **一键重命名** `MirrorRenamer.reapplyDisplayNames`,顺序**不可调换**:
     ① `MirrorInbound.reconcile()` 先回读(把用户在系统日历里手动新建/改动的
        收进本地库并置 dirty)—— 因为 deleteCalendar 会删掉账户下**所有**日程,
        含用户手动加的,不回读就会丢数据;回读失败则**中止**重命名(宁可不改)。
     ② `removeAccountsByBookIds` 删旧账户(按 bookId 精确定位,与名字无关;
        含"清空日程后重试"降级)
     ③ `syncNow()` 按新格式重建并全量写回
   独立成模块的原因:MirrorInbound 已 import SystemCalendarMirror,反向 import 会成环。
3. ⭐⭐ **清理判定改为「bookId 身份校验」**(`isOurMirrorAccount`)—— 用户指出的关键改进:
     **两道闸**:① name 必须以 `synccalendar.mirror.` 开头(挡掉别人家的账户);
     ② 从 name 切出 `accId`(取最后一个 `-` 之前)并确认在账号库中。
     ⭐ **附加安全阀**:账号库读不到 → **一律不认为是我们的**(宁可不清,不可误删)。
     `displayName` 兜底**降级为仅格式 1/2 可用**(格式 3 = `账号·本名` 与别的 App
     账户可能同名,参与兜底会误删)。
     ⇒ **删不删看 accId 是不是我们的;名字只用来显示。** 判定与名字彻底解耦。
4. `AppSettings` 新增 `mirror_display_format`(1|2|3,默认 3;非法值回落 3)。
5. 设置页新增「系统日历中的显示名称」:三种格式胶囊选择 + **用真实账号名/本名实时预览**
   + 「应用」按钮(未变化时禁用、失败自动回滚选项)+ 检测到旧格式时的弱提示
   + 说明"期间日程会短暂消失几秒"。
6. `ensureCalendar` 按格式创建(已有账户**不在此处改名** —— 改名是破坏性操作,
   必须由用户显式点「应用」触发);`syncBook` 增加 format 参数并逐层传递。
7. `describeCal` / `purgeOrphans` 参数语义更新(aggressive → allowDisplayMark)。

**不做自动迁移**(用户决定):升级后老账户名字不变、日程不动,用户想换才在设置页点。
理由:自动迁移期间日程会短暂消失,老用户可能正在开会/开车,看到"日程全没了"会惊慌;
且默认格式是主观偏好,强改所有人等于替用户做决定。

验证:
- 编译通过(BUILD SUCCESSFUL);包内版本 800/0.0.8。
- 离线用例覆盖:三种格式生成(确认半角点、无空格)、64 字符截断、
  身份判定五种情形(含"别的 App 同名账户不被删""accId 失效的孤儿仍被清"
  "读不到账号时一律不删""格式 3 兜底失效")—— **全部通过**。
- 手机未连接 → 未装机(待用户插线后安装测试)。
2026-10-14 10:09:48 +08:00
19 changed files with 1219 additions and 246 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 700, "versionCode": 900,
"versionName": "0.0.7", "versionName": "0.0.9",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+83 -4
View File
@@ -20,6 +20,13 @@ export class AppSettings {
private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey
private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳 private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳
// ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(用于节流)。
// 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1),
// 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。
private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at';
/** ⭐ 诊断开关:卡片驱动同步的时间预算(毫秒)。0/未设置 = 用内置默认(见 BackgroundSyncRunner)。
* 留给真机排查用 —— 不用重编译就能做"9 秒 vs 长窗口"对照实验。 */
private static readonly KEY_CARD_BUDGET_MS: string = 'card_budget_ms';
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list' private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list'
@@ -27,6 +34,9 @@ export class AppSettings {
private static readonly KEY_MIRROR_KEYS: string = 'mirror_cal_keys'; // 要镜像的 DAV 日历本 calKey 列表 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_BOOK_IDS: string = 'mirror_book_ids'; // ⭐ 要镜像的日历本**稳定标识** bookId(由服务器 href 派生,不随序号变)
private static readonly KEY_MIRROR_INBOUND: string = 'mirror_inbound'; // 是否把系统日历的改动回写 CalDAV(默认关) private static readonly KEY_MIRROR_INBOUND: string = 'mirror_inbound'; // 是否把系统日历的改动回写 CalDAV(默认关)
// ⭐ 系统日历账户的**显示名格式**(1/2/3,默认 3=`<账号>·<本名>`)。
// 因平台无"改账户名"API,切换格式需删账户重建(用户显式点「应用」才执行)。
private static readonly KEY_MIRROR_DISPLAY_FORMAT: string = 'mirror_display_format';
private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议 private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。 // 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**—— // 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**——
@@ -293,6 +303,67 @@ export class AppSettings {
} }
} }
/**
* ⭐ 系统日历账户显示名格式:1=`同步日历·本名`,2=`同步日历·账号·本名`,3=`账号·本名`(默认)。
* 只接受 1/2/3,其它值一律回落到默认 3(防止脏数据导致拼出意外名字)。
*/
static async getMirrorDisplayFormat(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
const v = await store.get(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, 3) as number;
return (v === 1 || v === 2 || v === 3) ? v : 3;
} catch (err) {
return 3;
}
}
static async setMirrorDisplayFormat(context: common.Context, value: number): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, value);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存显示名格式失败: ${e.message}`);
}
}
/** ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(0 = 从未执行) */
static async getCardSyncAt(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_CARD_SYNC_AT, 0) as number;
} catch (err) {
return 0;
}
}
static async setCardSyncAt(context: common.Context, ts: number): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_CARD_SYNC_AT, ts);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存卡片同步时间失败: ${e.message}`);
}
}
/** ⭐ 诊断用:卡片驱动同步的时间预算(毫秒)。0 = 未设置(用内置默认值)。 */
static async getCardBudgetMs(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_CARD_BUDGET_MS, 0) as number;
} catch (err) {
return 0;
}
}
/** /**
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
@@ -577,15 +648,23 @@ export class AppSettings {
} }
} }
/** 自动同步间隔(分钟),默认 1 分钟 */ /**
* 自动同步间隔(分钟),默认 15 分钟。
*
* ⭐ 0.0.8:默认值由 1 分钟改为 15 分钟。原因(真机日志 2026-10-16 17:16–17:20 实测):
* 1 分钟间隔会让「App 打开期间」几乎不间断地跑"12 个日历本全量比对 + 出站镜像 + 提醒刷新",
* 5 分钟内连跑 5 轮后触发系统提示「短时任务超时(配额用尽),后台同步将被挂起」——
* 反而把真正的后台同步通道一起拖垮。改为 15 分钟后,前台同步仍足够及时,且不再烧配额。
* 用户仍可在设置页自行改成 1 / 5 / 15 / 30 分钟 或 1 小时。
*/
static async getSyncIntervalMinutes(context: common.Context): Promise<number> { static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
try { try {
const store: preferences.Preferences = const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE); await preferences.getPreferences(context, AppSettings.STORE);
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number; const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 15) as number;
return v > 0 ? v : 1; return v > 0 ? v : 15;
} catch (err) { } catch (err) {
return 1; return 15;
} }
} }
@@ -8,7 +8,9 @@
// //
// ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。 // ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore'; import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore';
import { SyncEngine } from './SyncEngine'; import { SyncEngine } from './SyncEngine';
import { CardDataService } from './CardDataService'; import { CardDataService } from './CardDataService';
@@ -27,6 +29,152 @@ export class BackgroundSyncResult {
} }
export class BackgroundSyncRunner { export class BackgroundSyncRunner {
/**
* ⭐ 卡片的系统定时刷新间隔(与 form_config.json 的 updateDuration=1 保持一致,单位 30 分钟)。
* ⚠️ 两处必须同步修改:改这里也要改 form_config.json,否则节流窗口对不上。
*/
static readonly CARD_TICK_MS: number = 30 * 60 * 1000;
/** 距上次同步不足该时长则跳过(略小于卡片周期,避免系统刷新漂移导致被误跳过) */
private static readonly CARD_MIN_GAP_MS: number = 25 * 60 * 1000;
/**
* 卡片进程的软超时(**未申请到短时任务时**):官方规定 FormExtensionAbility
* 生命周期回调后进程**仅存续 10 秒**,超过就被回收(异步任务被强制中止)。
* 这里主动在 9 秒收手,让"这一次同步"**干净地放弃**而不是被硬杀在半途。
*/
private static readonly CARD_SOFT_TIMEOUT_MS: number = 9 * 1000;
/**
* 申请到短时任务后的软超时:短时任务单次最长 3 分钟(低电量 1 分钟),
* 但实际时长以 `actualDelayTime` 为准 → 留 15 秒余量提前收手,保证能干净地
* `cancelSuspendDelay`(否则超时不取消会被系统强制终止进程、并浪费当日配额)。
*/
private static readonly CARD_EXT_SOFT_RESERVE_MS: number = 15 * 1000;
/**
* 卡片驱动同步可用的**总时长上限**:即使申请到短时任务也不超过这个值。
* 理由:短时任务**单日总配额仅 10 分钟**,30 分钟一次的话每次平均只能分到约 20 秒,
* 全用光会让"退后台那一次同步"(EntryAbility.onBackground 申请)无配额可用。
*/
private static readonly CARD_MAX_TOTAL_MS: number = 60 * 1000;
/** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */
private static cardTransientId: number = 0;
/** 当前进程 pid(取不到返回 -1)。用来判断卡片回调是否与主 App 同进程。 */
private static pidOf(): number {
try {
return process.pid;
} catch (err) {
return -1;
}
}
/**
* ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。
*
* 为什么用它:系统给普通应用的后台调度很受限 ——
* `workScheduler` 的周期被"应用活跃分组"压制到 **≥2 小时**;
* 而服务卡片配置了 `updateDuration: 1`(**单位 30 分钟**),系统会**每 30 分钟**
* 触发一次 `onUpdateForm()`,且不受活跃分组压制(每天上限 50 次)。
* 借这个时机跑一次完整同步,即可得到"约 30 分钟一次"的定期后台同步。
*
* ⚠️ 节流:系统刷新本身可能有漂移、也可能因故重入 → 用时间戳挡一道,
* 保证两次真实同步之间至少间隔 `CARD_MIN_GAP_MS`。
* ⚠️ 本方法**永不抛错**(后台路径没有 UI 承担异常)。
*/
static async runOnceThrottledByCard(context: common.Context): Promise<void> {
try {
const last: number = await AppSettings.getCardSyncAt(context);
const now: number = Date.now();
if (last > 0 && now - last < BackgroundSyncRunner.CARD_MIN_GAP_MS) {
const mins: number = Math.round((now - last) / 60000);
LogUtil.write(`卡片驱动同步:距上次仅 ${mins} 分钟,跳过(节流 ${BackgroundSyncRunner.CARD_MIN_GAP_MS / 60000} 分钟)`);
return;
}
// 先写时间戳:避免同步执行期间卡片再次刷新导致并发重入
await AppSettings.setCardSyncAt(context, now);
// ⭐ 尝试申请**短时任务**,把 10 秒窗口延长到最多 3 分钟。
// ⚠️ 官方规定 requestSuspendDelay 应在"应用处于前台"或"onBackground 回调"里调用;
// 卡片进程(FormExtensionAbility)是否符合**未知** → 故此处**容错尝试**:
// 成功则按实际时长延长,失败(抛错/时长为 0)则退回 9 秒软超时。
// ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟),
// 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。
const windowMs: number = await BackgroundSyncRunner.acquireCardWindow();
let budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS);
// ⭐ 诊断开关(preferences:card_budget_ms):>0 时**覆盖**上面算出的可用窗口。
// 用途:在真机上回答"卡片进程究竟能活多久""一次完整同步要多久",
// 从而决定卡片这条路该做多少事 —— 不必为此重新编译。
const overrideMs: number = await AppSettings.getCardBudgetMs(context);
if (overrideMs > 0) {
budget = overrideMs;
LogUtil.write(`卡片驱动同步:预算被诊断开关覆盖为 ${Math.round(budget / 1000)} 秒`);
}
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,pid=${BackgroundSyncRunner.pidOf()},可用窗口 ${Math.round(budget / 1000)} 秒)`);
// 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试)
const worker: Promise<void> =
BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {});
const guard: Promise<void> = new Promise<void>((_res: () => void, rej: (e: Error) => void) => {
setTimeout((): void => rej(new Error('本次同步时间预算用尽,主动收手')),
budget);
});
try {
await Promise.race<void>([worker, guard]);
} catch (err) {
const e = err as Error;
LogUtil.write(`卡片驱动同步提前收手:${e.message}(幂等,下轮会重试)`);
} finally {
// 无论成败都释放短时任务:① 归还当日配额 ② 避免超时不取消被系统强杀进程
BackgroundSyncRunner.releaseCardWindow();
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`卡片驱动同步异常: ${e.message}`);
}
}
/**
* 为卡片同步申请短时任务,返回**实际可用窗口(毫秒)**。
* 申请失败/异常/时长为 0 → 返回 0,由调用方退回默认的 9 秒软超时(不阻塞流程)。
*/
private static async acquireCardWindow(): Promise<number> {
try {
const info: backgroundTaskManager.DelaySuspendInfo =
backgroundTaskManager.requestSuspendDelay('正在同步日历数据', (): void => {
// 超时回调:配额即将用尽,清掉本地记录(系统会自行处理挂起)
LogUtil.write('卡片同步的短时任务即将超时');
BackgroundSyncRunner.cardTransientId = 0;
});
BackgroundSyncRunner.cardTransientId = info.requestId;
const actual: number = info.actualDelayTime ?? 0;
if (actual <= 0) {
// 拿到了 requestId 但没有实际时长 → 视为不可用
BackgroundSyncRunner.releaseCardWindow();
LogUtil.write('卡片同步:短时任务时长为 0,退回 9 秒软超时');
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
}
// 留 15 秒余量提前收手,保证能干净地取消
const usable: number = Math.max(actual - BackgroundSyncRunner.CARD_EXT_SOFT_RESERVE_MS,
BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS);
LogUtil.write(`卡片同步:短时任务已获批,实际时长 ${Math.round(actual / 1000)} 秒`);
return usable;
} catch (err) {
// 例如"未在前台/退后台时机申请"被拒 —— 退回 9 秒软超时,不影响流程
const e = err as BusinessError;
LogUtil.write(`卡片同步:短时任务申请失败(${e.code ?? ''} ${e.message}),退回 9 秒软超时`);
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
}
}
/** 释放卡片同步的短时任务(幂等) */
private static releaseCardWindow(): void {
if (BackgroundSyncRunner.cardTransientId === 0) {
return;
}
try {
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncRunner.cardTransientId);
} catch (err) {
// 取消失败无需处理
}
BackgroundSyncRunner.cardTransientId = 0;
}
/** /**
* 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。 * 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。
* @param context 应用上下文 * @param context 应用上下文
@@ -34,8 +182,12 @@ export class BackgroundSyncRunner {
*/ */
static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> { static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> {
const r = new BackgroundSyncResult(); const r = new BackgroundSyncResult();
// ⭐ 阶段计时:后台同步被时间预算切断时,这些 `+Xms` 就是"钱花在哪了"的直接证据。
const t0: number = Date.now();
const el = (): number => Date.now() - t0;
try { try {
const accounts: DavAccount[] = await AccountStore.loadAll(context); const accounts: DavAccount[] = await AccountStore.loadAll(context);
LogUtil.write(`后台同步[${tag}] 阶段:载入账号 ${accounts.length} 个 +${el()}ms`);
if (accounts.length === 0) { if (accounts.length === 0) {
LogUtil.write(`后台同步[${tag}]:无账号,跳过`); LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
return r; return r;
@@ -46,12 +198,14 @@ export class BackgroundSyncRunner {
} catch (err) { } catch (err) {
// 导入失败不影响正常同步 // 导入失败不影响正常同步
} }
LogUtil.write(`后台同步[${tag}] 阶段:系统日历导入 +${el()}ms`);
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前) // 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
try { try {
await MirrorInbound.reconcile(context); await MirrorInbound.reconcile(context);
} catch (err) { } catch (err) {
// 对账失败不影响正常同步 // 对账失败不影响正常同步
} }
LogUtil.write(`后台同步[${tag}] 阶段:入站对账 +${el()}ms`);
for (const acc of accounts) { for (const acc of accounts) {
if (acc.type !== TYPE_CALDAV) { if (acc.type !== TYPE_CALDAV) {
continue; continue;
@@ -66,13 +220,16 @@ export class BackgroundSyncRunner {
r.failed++; r.failed++;
LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`); LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`);
} }
LogUtil.write(`后台同步[${tag}] 阶段:账号 ${acc.id} 同步 +${el()}ms(ok=${r.ok} failed=${r.failed})`);
} }
// 全部账号同步成功才结束"一次性全量重拉"状态 // 全部账号同步成功才结束"一次性全量重拉"状态
if (r.attempted > 0 && r.ok === r.attempted) { if (r.attempted > 0 && r.ok === r.attempted) {
await AppSettings.markFullRefetchDone(context); await AppSettings.markFullRefetchDone(context);
} }
await SyncEngine.settleLocalEvents(context); await SyncEngine.settleLocalEvents(context);
LogUtil.write(`后台同步[${tag}] 阶段:本地收尾 +${el()}ms`);
await SyncEngine.pruneOrphanRows(context, accounts); await SyncEngine.pruneOrphanRows(context, accounts);
LogUtil.write(`后台同步[${tag}] 阶段:孤儿行清理 +${el()}ms`);
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。 // ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。 // 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。
await AccountStore.saveAll(context, accounts); await AccountStore.saveAll(context, accounts);
@@ -85,6 +242,7 @@ export class BackgroundSyncRunner {
} catch (err) { } catch (err) {
// 镜像失败不影响同步 // 镜像失败不影响同步
} }
LogUtil.write(`后台同步[${tag}] 阶段:出站镜像 +${el()}ms`);
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见 // 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
try { try {
await CardDataService.pushToAllForms(context); await CardDataService.pushToAllForms(context);
@@ -92,10 +250,10 @@ export class BackgroundSyncRunner {
} catch (err) { } catch (err) {
// 卡片/提醒刷新失败不影响同步结果 // 卡片/提醒刷新失败不影响同步结果
} }
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号`); LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号,总耗时 ${Math.round(el() / 1000)} 秒`);
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}`); LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}(已耗时 ${Math.round(el() / 1000)} 秒)`);
} }
return r; return r;
} }
+19 -6
View File
@@ -425,7 +425,22 @@ export class EventDb {
rs.close(); rs.close();
} }
const remoteKeys: string[] = []; // ⭐ 2226(2026-10-15 提速):下方逐条要判"这条远端事件本地有没有对应行"。
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
// 语义与 `existing.find(x => !x.dirty && x.uid===r.uid && x.startTime===r.startTime
// && x.recurrenceId===r.recurrenceId)` 完全一致:dirty 行不入索引、同键取**最先**出现的那行。
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
for (const e of existing) {
if (e.dirty) {
continue;
}
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
if (!keyed.has(k)) {
keyed.set(k, e);
}
}
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。 // ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行, // 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。 // 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
@@ -438,10 +453,7 @@ export class EventDb {
continue; // 同一资源内的重复条目 → 只保留一条 continue; // 同一资源内的重复条目 → 只保留一条
} }
seenKeys.add(key); seenKeys.add(key);
remoteKeys.push(key); const found: LocalEvent | undefined = keyed.get(key);
const found = existing.find((x: LocalEvent): boolean =>
!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();
@@ -517,7 +529,8 @@ export class EventDb {
continue; continue;
} }
const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`; const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
if (!remoteKeys.includes(localKey)) { // ⭐ 同样从 O(m) 的数组 includes 换成 Set 查询(见上方 keyed 的说明)
if (!seenKeys.has(localKey)) {
const del = new relationalStore.RdbPredicates('events'); const del = new relationalStore.RdbPredicates('events');
del.equalTo('id', local.id); del.equalTo('id', local.id);
await store.delete(del); await store.delete(del);
+35 -17
View File
@@ -229,7 +229,10 @@ export class IcsUtil {
} }
/** /**
* 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确)。 * 由本地事件构建 VCALENDAR 文本。
*
* ⚠️ 事件时间写的是**浮动本地时间**(不是 UTC),原因见 `vevent()` 里的详细说明
* (服务器会删掉 `Z`/`TZID` 且不做时移)。`DTSTAMP` 仍按 RFC 规定写 UTC+`Z`。
* *
* 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。 * 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。
* 含这些字符的标题在服务器端可能被截断或串行; * 含这些字符的标题在服务器端可能被截断或串行;
@@ -273,10 +276,25 @@ export class IcsUtil {
/** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */ /** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */
private static vevent(e: LocalEvent, now: string): string { private static vevent(e: LocalEvent, now: string): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n); const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const fmtUtc = (ms: number): string => { /**
* ⭐⭐ 事件时间一律写**浮动本地时间**(`YYYYMMDDTHHMMSS`,**不带 `Z`、不带 `TZID`**)。
*
* 为什么不写 RFC 最正统的 UTC+`Z` —— 因为服务器会把它弄坏:
* 本项目的服务器(家书日历 / family.yangyq.net)会**直接删掉** `DTSTART`/`DTEND`
* 上的 `Z` 与 `TZID` 参数,**且不对数值做任何时移**
* (2026-10-15 实测:PUT `DTSTART:20261020T060000Z` → 存回 `DTSTART:20261020T060000`;
* PUT `DTSTART;TZID=Asia/Shanghai:20261020T140000` → 存回 `20261020T140000`)。
* 于是"UTC 00:00"被当成"**本地** 00:00"。而本地 08:00 的正确 UTC 恰好就是 00:00,
* 所以症状正好是**阅读端少了 8 小时**(用户实测:"8:00 的日程同步后变成 0:00")。
*
* 该服务器上全部计时事件(1163 条)的 `DTSTART` **无一例外都是浮动本地时间**
* (带 `Z` 的数量为 **0**、带 `TZID` 为 **0**)→ 这就是它的事实约定,我们随它。
* 浮动时间在任何阅读端都按"当地墙钟"解析,本机 CST 往返完全一致。
*/
const fmtLocal = (ms: number): string => {
const d = new Date(ms); const d = new Date(ms);
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; `T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}; };
const fmtDate = (ms: number): string => { const fmtDate = (ms: number): string => {
const d = new Date(ms); const d = new Date(ms);
@@ -289,8 +307,8 @@ export class IcsUtil {
// 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期 // 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期
dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000); dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000);
} else { } else {
dtstart = 'DTSTART:' + fmtUtc(e.startTime); dtstart = 'DTSTART:' + fmtLocal(e.startTime);
dtend = 'DTEND:' + fmtUtc(e.endTime); dtend = 'DTEND:' + fmtLocal(e.endTime);
} }
// ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它 // ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它
// 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。 // 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。
@@ -298,7 +316,7 @@ export class IcsUtil {
if (e.rrule === '' && e.recurrenceId > 0) { if (e.rrule === '' && e.recurrenceId > 0) {
recId = e.isAllDay recId = e.isAllDay
? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n` ? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n`
: `RECURRENCE-ID:${fmtUtc(e.recurrenceId)}\r\n`; : `RECURRENCE-ID:${fmtLocal(e.recurrenceId)}\r\n`;
} }
// ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加) // ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加)
let exdate: string = ''; let exdate: string = '';
@@ -307,7 +325,7 @@ export class IcsUtil {
for (const raw of e.exdate.split(';')) { for (const raw of e.exdate.split(';')) {
const t = IcsUtil.parseTime(raw, !raw.includes('T')); const t = IcsUtil.parseTime(raw, !raw.includes('T'));
if (t !== null) { if (t !== null) {
vals.push(t.allDay ? fmtDate(t.time) : fmtUtc(t.time)); vals.push(t.allDay ? fmtDate(t.time) : fmtLocal(t.time));
} }
} }
if (vals.length > 0) { if (vals.length > 0) {
@@ -341,8 +359,9 @@ export class IcsUtil {
} }
/** /**
* 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID 的 ICS 时间串。 * 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID / UNTIL 的 ICS 时间串。
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用 UTC 的 `YYYYMMDDTHHMMSSZ` * @param dateOnly 全天日程用 `YYYYMMDD`,否则用**浮动本地时间** `YYYYMMDDTHHMMSS`
* (不带 `Z`:必须与 `DTSTART` 的形态一致,见 `vevent()` 里的说明)
*/ */
static fmtIcsTime(ms: number, dateOnly: boolean): string { static fmtIcsTime(ms: number, dateOnly: boolean): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n); const pad = (n: number): string => n < 10 ? '0' + n : String(n);
@@ -350,20 +369,19 @@ export class IcsUtil {
if (dateOnly) { if (dateOnly) {
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
} }
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; `T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
} }
/** /**
* ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。 * ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。
* - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现) * - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现)
* - UNTIL 用 UTC 形式;`untilMs` 一般是"该次发生的开始时刻 − 1 秒" * - UNTIL 用**浮动本地时间**(RFC 5545:`DTSTART` 是本地时间时,`UNTIL` **必须**也是本地时间;
* 而我们把所有事件时间都写成浮动本地 —— 二者必须一致,否则不同阅读端的展开会错位)
* - `untilMs` 一般是"该次发生的开始时刻 − 1 秒"
*/ */
static rruleWithUntil(rrule: string, untilMs: number): string { static rruleWithUntil(rrule: string, untilMs: number): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n); const until: string = IcsUtil.fmtIcsTime(untilMs, false);
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[] = []; const kept: string[] = [];
for (const part of rrule.split(';')) { for (const part of rrule.split(';')) {
const k: string = part.split('=')[0].trim().toUpperCase(); const k: string = part.split('=')[0].trim().toUpperCase();
+31 -4
View File
@@ -1,30 +1,57 @@
// entry/src/main/ets/common/LogUtil.ets // entry/src/main/ets/common/LogUtil.ets
// 同步日志:写入应用 filesDir/sync.log(含时间戳),超 512KB 自动截断保留后半。 // 日志工具:默认**只输出到 console(hilog)**,不落盘。
// 设置页「同步日志」卡片可查看/复制/清空,方便真机测试时诊断同步与提醒问题。 //
// ⭐ 0.0.8 起:发行版**不再写 sync.log**(原「同步记录」/诊断落盘功能已移除)。
// 需要真机排查同步/提醒问题时,把下面的 ENABLE_FILE_LOG 改成 true 重新编译即可恢复
// 落盘(含 512KB 自动截断保留后半),其余调用点一行都不用动。
// 关闭状态下会在 init 时顺手删掉历史版本遗留的 sync.log 与 mirror_diag.txt,
// 保证升级上来的设备沙箱也是干净的。
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { fileIo as fs } from '@kit.CoreFileKit'; import { fileIo as fs } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS'; import { util } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
export class LogUtil { export class LogUtil {
/** ⭐ 落盘开关:发行版置 false(仅 console);真机排查时改 true 恢复文件日志 */
private static readonly ENABLE_FILE_LOG: boolean = false;
private static dir: string = '';
private static path: string = ''; private static path: string = '';
private static readonly MAX_BYTES: number = 512 * 1024; private static readonly MAX_BYTES: number = 512 * 1024;
static init(context: common.Context): void { static init(context: common.Context): void {
try { try {
LogUtil.path = `${context.filesDir}/sync.log`; LogUtil.dir = context.filesDir;
LogUtil.path = `${LogUtil.dir}/sync.log`;
} catch (err) { } catch (err) {
LogUtil.dir = '';
LogUtil.path = ''; LogUtil.path = '';
} }
if (!LogUtil.ENABLE_FILE_LOG && LogUtil.dir !== '') {
LogUtil.removeLegacyDebugFiles();
}
}
/** 删除历史版本遗留的调试落盘文件(sync.log / mirror_diag.txt) */
private static removeLegacyDebugFiles(): void {
for (const name of ['sync.log', 'mirror_diag.txt']) {
try {
fs.unlinkSync(`${LogUtil.dir}/${name}`);
} catch (err) {
// 文件不存在属正常,忽略
}
}
} }
static logFilePath(): string { static logFilePath(): string {
return LogUtil.path; return LogUtil.path;
} }
/** 写一行日志:console + 文件(文件失败静默,不影响业务) */ /** 写一行日志:console + 文件(发行版不落盘;文件失败静默,不影响业务) */
static write(msg: string): void { static write(msg: string): void {
console.info(`SyncLog ${msg}`); console.info(`SyncLog ${msg}`);
if (!LogUtil.ENABLE_FILE_LOG) {
return;
}
if (LogUtil.path === '') { if (LogUtil.path === '') {
return; return;
} }
+20 -2
View File
@@ -82,6 +82,25 @@ export class MirrorInbound {
/* ==================== 单个日历本 ==================== */ /* ==================== 单个日历本 ==================== */
/**
* 取回该账户下已有日程。
*
* 🔴 2026-10-15 实测(DEL-AL20 / 7.0.0.109):`getEvents(undefined, 字段数组)` 在这个
* 系统版本上**必抛** `401 type error!`。而本文件原先正是这么调的 ⇒ "入站对账"对
* **每一个**日历本都失败(真机日志 12/12 全是 `401 type error!`),
* 整个"把系统日历里的改动回写 CalDAV"等于死的(还白费 12 次调用)。
* 出站镜像(SystemCalendarMirror.queryEvents)早就踩过同一个坑,改成"先无参、失败再带字段"。
* 这里沿用同一策略:无参调用返回的 Event 里 identifier 同样有值。
*/
private static async queryEvents(cal: calendarManager.Calendar): Promise<calendarManager.Event[]> {
try {
return await cal.getEvents();
} catch (err) {
LogUtil.write(`入站对账 getEvents() 失败,退回带字段:${(err as BusinessError).message}`);
return await cal.getEvents(undefined, MirrorInbound.QUERY_FIELDS);
}
}
private static async reconcileBook( private static async reconcileBook(
context: common.Context, context: common.Context,
cm: calendarManager.CalendarManager, cm: calendarManager.CalendarManager,
@@ -109,8 +128,7 @@ export class MirrorInbound {
} }
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket); const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
const events: calendarManager.Event[] = await cal.getEvents( const events: calendarManager.Event[] = await MirrorInbound.queryEvents(cal);
undefined, MirrorInbound.QUERY_FIELDS);
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建) // 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>(); const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
+102
View File
@@ -0,0 +1,102 @@
// entry/src/main/ets/common/MirrorRenamer.ets
// ⭐ 一键重命名镜像账户的显示名(0.0.8)
//
// 为什么需要这个模块:HarmonyOS 的 `@ohos.calendarManager` **没有"修改账户"的 API**
// (只有 create/delete/get/getAll;CalendarConfig 仅 enableReminder+color;
// CalendarAccount.name 是 readonly)→ `displayName` 只能在创建时定,之后改不了。
// ⇒ 想换显示名,只能"**删掉旧账户 → 按新名字重建**"。
//
// ⭐⭐ 安全顺序(不可调换):先回读 → 再删 → 重建 → 全量重写
// ① `MirrorInbound.reconcile()`:把用户在系统日历里**手动新建/修改**的日程先收进本地库(置 dirty)
// —— 因为 `deleteCalendar` 会删掉该账户下**所有**日程(含用户手动加的),不回读就会丢数据。
// ② 删除旧镜像账户(按 bookId 精确定位)
// ③ 立即 `syncNow()`:按用户所选的新格式重建账户并全量写回(含刚回读的 dirty 项)
//
// 为什么放在独立模块:`MirrorInbound` 已经 import 了 `SystemCalendarMirror`,
// 若再把 `MirrorInbound` 反向 import 进 `SystemCalendarMirror` 会形成循环依赖;
// 本模块作为"编排层"同时依赖二者,方向单一、无环。
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { SystemCalendarMirror, BookRef, MirrorResult } from './SystemCalendarMirror';
import { MirrorInbound, InboundResult } from './MirrorInbound';
import { LogUtil } from './LogUtil';
/** 重命名结果(供 UI 提示) */
export class RenameResult {
ok: boolean = false;
readBack: number = 0; // 回读到的用户改动条数(created+updated+deleted)
removed: number = 0; // 删除的旧账户数
mirrored: number = 0; // 重建后镜像的日历本数
message: string = '';
}
export class MirrorRenamer {
/**
* 按当前(已落盘的)显示名格式,重建全部镜像账户的显示名。
*
* ⚠️ 前提:调用方已把用户所选格式写入 `AppSettings`(`setMirrorDisplayFormat`);
* 本方法只负责"按现有配置重新应用"。
* ⚠️ 需要系统日历读写权限(调用方先校验 / 申请)。
*/
static async reapplyDisplayNames(context: common.Context): Promise<RenameResult> {
const res = new RenameResult();
if (!await SystemCalendarMirror.hasPermission()) {
res.message = '未获得系统日历读写权限';
return res;
}
// ① 先回读:把用户在系统日历里的手动新建/修改收进本地库(置 dirty),避免删除时丢数据
try {
const inbound: InboundResult = await MirrorInbound.reconcile(context);
res.readBack = inbound.created + inbound.updated + inbound.deleted;
LogUtil.write(`重命名-回读完成:新增=${inbound.created} 修改=${inbound.updated} 删除=${inbound.deleted}`);
} catch (err) {
// 回读失败 → **中止重命名**(宁可不改,也不能冒着丢数据的风险删账户)
const e = err as BusinessError;
LogUtil.write(`重命名中止:回读失败 ${e.message}`);
res.message = '未能先同步系统日历中的改动,已取消操作(避免数据丢失)。请稍后重试。';
return res;
}
// ② 取出当前选中的镜像本(当前配置)
let refs: BookRef[] = [];
try {
refs = await SystemCalendarMirror.selectedBookRefs(context);
} catch (err) {
res.message = '读取镜像日历本失败';
return res;
}
if (refs.length === 0) {
res.message = '当前没有选中的镜像日历本';
return res;
}
// ③ 删除这些本的旧镜像账户(按 bookId,与名字无关)
const bookIds: string[] = refs.map((r: BookRef): string => r.bookId);
res.removed = await SystemCalendarMirror.removeAccountsByBookIds(context, bookIds);
LogUtil.write(`重命名:已删除 ${res.removed} 个旧账户,准备按新格式重建`);
// ④ 立即按新格式重建并全量写回(含刚回读的 dirty 项)
try {
const mr: MirrorResult = await SystemCalendarMirror.syncNow(context);
res.mirrored = mr.books;
res.ok = true;
res.message = '已按新的显示方式重新写入系统日历';
LogUtil.write(`重命名完成:重建 ${mr.books} 个本(新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted})`);
} catch (err) {
const e = err as BusinessError;
res.ok = false;
res.message = `重建失败:${e.message}`;
LogUtil.write(`重命名重建失败:${e.message}`);
}
return res;
}
/**
* 检测是否还有"旧格式"(`同步日历 · xxx`)的镜像账户 —— 用于设置页给出"可更换"的弱提示。
* 只读,不做任何修改。
*/
static async hasLegacyNamedAccounts(context: common.Context): Promise<boolean> {
return await SystemCalendarMirror.hasLegacyDisplayName(context);
}
}
@@ -12,6 +12,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import { EventDb, LocalEvent } from './EventDb'; import { EventDb, LocalEvent } from './EventDb';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { AccountStore } from './AccountStore'; import { AccountStore } from './AccountStore';
import { SystemCalendarMirror } from './SystemCalendarMirror';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
export class ReminderService { export class ReminderService {
@@ -26,6 +27,39 @@ export class ReminderService {
private static calNameCache: Map<string, string> = new Map(); private static calNameCache: Map<string, string> = new Map();
private static calNameLoadedAt: number = 0; private static calNameLoadedAt: number = 0;
/** 已镜像到系统日历的 calKey 集合缓存(避免重复排提醒),30 秒刷新一次 */
private static mirroredCalKeys: Set<string> = new Set();
private static mirroredLoadedAt: number = 0;
/**
* ⭐⭐ 取"已镜像到系统日历"的日历本 calKey 集合(0.0.8 修复双通道重复提醒)。
*
* 镜像到系统日历后,**系统日历自己会提醒**(条目的 reminderTime);
* 若 App 再为同一个本排代理提醒 → 同一条日程提醒两次。
* 因此这里返回"该由系统日历负责提醒"的本,App 侧**完全不再提醒**它们。
*
* ⚠️ 判据是"**镜像账户真实存在**"(`SystemCalendarMirror.mirroredCalKeys`),
* 而不是"用户勾选了要镜像"—— 后者只是意图,本可能还没被真正写进系统日历;
* 按意图跳过会导致"两边都不提醒"(漏提醒)。
* ⚠️ 返回空集 = 不跳过任何本(保守:宁可多提醒,不可漏提醒)。
*/
private static async mirroredKeys(context: common.Context): Promise<Set<string>> {
if (Date.now() - ReminderService.mirroredLoadedAt < 30000
&& ReminderService.mirroredCalKeys.size > 0) {
return ReminderService.mirroredCalKeys;
}
const out: Set<string> = await SystemCalendarMirror.mirroredCalKeys(context);
ReminderService.mirroredCalKeys = out;
ReminderService.mirroredLoadedAt = Date.now();
return out;
}
/** 镜像开关变化后调用,让缓存立即失效(如用户关闭镜像 → 立刻恢复 App 提醒) */
static invalidateMirrorCache(): void {
ReminderService.mirroredLoadedAt = 0;
ReminderService.mirroredCalKeys = new Set();
}
/** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */ /** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */
private static async calName(context: common.Context, calKey: string): Promise<string> { private static async calName(context: common.Context, calKey: string): Promise<string> {
if (Date.now() - ReminderService.calNameLoadedAt > 60000 if (Date.now() - ReminderService.calNameLoadedAt > 60000
@@ -131,10 +165,18 @@ export class ReminderService {
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒 // 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
const muted: string[] = await AppSettings.getMutedReminderKeys(context); const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
if (mirrored.size > 0) {
LogUtil.write(`提醒跳过 ${mirrored.size} 个已镜像到系统日历的日历本(交由系统日历提醒)`);
}
for (const e of rows) { for (const e of rows) {
if (muted.includes(e.calKey)) { if (muted.includes(e.calKey)) {
continue; continue;
} }
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复
}
if (published >= ReminderService.MAX_REMINDERS) { if (published >= ReminderService.MAX_REMINDERS) {
break; break;
} }
@@ -211,11 +253,16 @@ export class ReminderService {
const rows: LocalEvent[] = const rows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
const muted: string[] = await AppSettings.getMutedReminderKeys(context); const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
let fired: number = 0; let fired: number = 0;
for (const e of rows) { for (const e of rows) {
if (muted.includes(e.calKey)) { if (muted.includes(e.calKey)) {
continue; continue;
} }
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 交由系统日历提醒
}
const rems: number[] = e.reminders.length > 0 ? e.reminders const rems: number[] = e.reminders.length > 0 ? e.reminders
: (e.reminder > 0 ? [e.reminder] : []); : (e.reminder > 0 ? [e.reminder] : []);
for (const rem of rems) { for (const rem of rems) {
+69 -12
View File
@@ -122,7 +122,23 @@ export class SyncEngine {
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> { private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
const auth: string = DavClient.authHeader(acc.username, acc.password); const auth: string = DavClient.authHeader(acc.username, acc.password);
// 0) 刷新服务器端日历本颜色(每次同步都校正) // 0) 刷新服务器端日历本颜色(每次同步都校正)
await SyncEngine.refreshCalendarColors(acc, auth); // ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
if (colorChanged) {
try {
const all: DavAccount[] = await AccountStore.loadAll(context);
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
if (idx >= 0) {
all[idx] = acc;
await AccountStore.saveAll(context, all);
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
}
}
// 1) 推送该账号日历本下的本地修改 // 1) 推送该账号日历本下的本地修改
await SyncEngine.pushDirtyForAccount(context, acc, auth); await SyncEngine.pushDirtyForAccount(context, acc, auth);
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。 // 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
@@ -160,12 +176,12 @@ export class SyncEngine {
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag) // 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
const byUid: Map<string, LocalEvent[]> = new Map(); const byUid: Map<string, LocalEvent[]> = new Map();
// ⭐ 再按 **etag** 分组,给下面"要不要 GET 这个资源"做兜底(见 §复用判定 注释)
const byEtag: Map<string, LocalEvent[]> = new Map();
for (const row of existingRows) { for (const row of existingRows) {
const arr: LocalEvent[] | undefined = byUid.get(row.uid); SyncEngine.groupPush(byUid, row.uid, row);
if (arr === undefined) { if (row.etag !== '') {
byUid.set(row.uid, [row]); SyncEngine.groupPush(byEtag, row.etag, row);
} else {
arr.push(row);
} }
} }
const originMatch = /https?:\/\/[^/]+/i.exec(href); const originMatch = /https?:\/\/[^/]+/i.exec(href);
@@ -174,9 +190,27 @@ export class SyncEngine {
let fetched: number = 0; let fetched: number = 0;
let parseFail: number = 0; let parseFail: number = 0;
const needFetch: RemoteItem[] = []; const needFetch: RemoteItem[] = [];
let hitUid: number = 0; // 按"资源名 = uid"命中
let hitEtag: number = 0; // 按 etag 内容哈希兜底命中
for (const it of items) { for (const it of items) {
const resUid: string = SyncEngine.resUidFromHref(it.href); const resUid: string = SyncEngine.resUidFromHref(it.href);
const group: LocalEvent[] | undefined = byUid.get(resUid); let group: LocalEvent[] | undefined = byUid.get(resUid);
if (group !== undefined) {
hitUid++;
} else if (it.etag !== '') {
// ⭐ 复用判定(2026-10-15 修复):**服务器不保证"资源文件名 = ICS 里的 UID"**。
// 家书 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/', '_', $uid) 生成文件名:
// UID `movie-188@familybook` 的资源叫 `movie-188_familybook.ics`。
// 旧版只按资源名去 `byUid` 查 → 永远查不到 → 该资源**每轮都被判成"变了"**,
// 于是每次都全量 GET(实测 2184 个资源 → 单次同步 200~300 秒:卡片后台同步
// 那 9 秒窗口连一个日历本都跑不完)。
// etag 是**内容哈希**(服务端由正文 md5 得出):相等即内容一致,
// 与"资源叫什么名字"无关 —— 用它兜底既安全又与服务器实现解耦。
group = byEtag.get(it.etag);
if (group !== undefined) {
hitEtag++;
}
}
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
&& group[0].etag === it.etag) { && group[0].etag === it.etag) {
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段) // etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
@@ -187,6 +221,7 @@ export class SyncEngine {
} }
needFetch.push(it); needFetch.push(it);
} }
LogUtil.write(`日历本[${i}]「${calName}」复用判定:uid 命中 ${hitUid}、etag 兜底命中 ${hitEtag}、需补拉 ${needFetch.length}(共 ${items.length})`);
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时 // 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
const batchSize: number = 5; const batchSize: number = 5;
for (let b: number = 0; b < needFetch.length; b += batchSize) { for (let b: number = 0; b < needFetch.length; b += batchSize) {
@@ -344,8 +379,13 @@ export class SyncEngine {
/** /**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors * 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性) * 失败静默(颜色不影响数据正确性)
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
*/ */
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> { static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
let changed: boolean = false;
let entries: DavColorEntry[] = []; let entries: DavColorEntry[] = [];
try { try {
entries = await DavClient.propfindColors(acc.serverUrl, auth); entries = await DavClient.propfindColors(acc.serverUrl, auth);
@@ -363,9 +403,11 @@ export class SyncEngine {
if (found !== undefined && found.color !== '') { if (found !== undefined && found.color !== '') {
while (acc.calendarColors.length <= i) { while (acc.calendarColors.length <= i) {
acc.calendarColors.push(''); acc.calendarColors.push('');
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
} }
if (acc.calendarColors[i] !== found.color) { if (acc.calendarColors[i] !== found.color) {
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`); LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
changed = true;
} }
acc.calendarColors[i] = found.color; acc.calendarColors[i] = found.color;
} else if (found === undefined) { } else if (found === undefined) {
@@ -377,16 +419,32 @@ export class SyncEngine {
acc.calendarWritable.push('1'); acc.calendarWritable.push('1');
} }
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
let writable: string;
if (found !== undefined && found.privilegeKnown) { if (found !== undefined && found.privilegeKnown) {
// 服务器声明了权限:直接采用 // 服务器声明了权限:直接采用
acc.calendarWritable[i] = found.writable ? '1' : '0'; writable = found.writable ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
} else { } else {
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测 // 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
const w: boolean = await DavClient.probeWritable(target, auth); const w: boolean = await DavClient.probeWritable(target, auth);
acc.calendarWritable[i] = w ? '1' : '0'; writable = w ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
} }
if (acc.calendarWritable[i] !== writable) {
changed = true;
}
acc.calendarWritable[i] = writable;
}
return changed;
}
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
private static groupPush(m: Map<string, LocalEvent[]>, key: string, row: LocalEvent): void {
const arr: LocalEvent[] | undefined = m.get(key);
if (arr === undefined) {
m.set(key, [row]);
} else {
arr.push(row);
} }
} }
@@ -401,8 +459,7 @@ export class SyncEngine {
} }
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */ /** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
private static resUidFromHref(href: string): string { private static resUidFromHref(href: string): string { const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
if (segs.length === 0) { if (segs.length === 0) {
return String(Date.now()); return String(Date.now());
} }
+361 -166
View File
@@ -37,7 +37,6 @@
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { calendarManager } from '@kit.CalendarKit'; import { calendarManager } from '@kit.CalendarKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { AccountStore, BookPalette, DavAccount } from './AccountStore'; import { AccountStore, BookPalette, DavAccount } from './AccountStore';
import { EventDb, LocalEvent } from './EventDb'; import { EventDb, LocalEvent } from './EventDb';
import { IcsTime, IcsUtil } from './IcsUtil'; import { IcsTime, IcsUtil } from './IcsUtil';
@@ -80,8 +79,14 @@ export class SystemCalendarMirror {
]; ];
/** 系统日历账户 name 前缀。name 是开发者定义且 readonly,必须稳定可复现才能找到已有账户 */ /** 系统日历账户 name 前缀。name 是开发者定义且 readonly,必须稳定可复现才能找到已有账户 */
private static readonly ACCOUNT_PREFIX: string = 'synccalendar.mirror.'; private static readonly ACCOUNT_PREFIX: string = 'synccalendar.mirror.';
/** 系统日历账户 displayName 前缀,便于用户在系统日历里识别来源 */ /**
private static readonly DISPLAY_PREFIX: string = '同步日历 · '; * 系统日历账户 displayName 中「同步日历」字面量 —— **仅供"兜底识别"使用**。
* ⚠️ 0.0.8 起 displayName 变为可切换(三种格式),本常量**不再用于生成名字**,
* 只用于判断"这个名字是否含我们的归属标识"(格式 1/2 含、格式 3 不含)。
*/
private static readonly DISPLAY_MARK: string = '同步日历';
/** 旧版固定前缀(`同步日历 · `,半角点 U+00B7 带空格)——仅用于识别/迁移历史账户 */
private static readonly LEGACY_DISPLAY_PREFIX: string = '同步日历 · ';
private static readonly DAY: number = 86400000; private static readonly DAY: number = 86400000;
/** getEvents() 显式指定返回字段:identifier 只在 API20+ 的默认字段里,写死更保险 */ /** getEvents() 显式指定返回字段:identifier 只在 API20+ 的默认字段里,写死更保险 */
private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [ private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [
@@ -148,6 +153,71 @@ export class SystemCalendarMirror {
} }
} }
/* ==================== 账户显示名(0.0.8 可切换) ==================== */
/**
* ⭐ 生成系统日历账户的 displayName。
*
* 三种格式(分隔符统一为**半角点 `·`(U+00B7)+ 无空格**,尽量短):
* 1 = `同步日历·<本名>` —— 保留应用名
* 2 = `同步日历·<账号>·<本名>` —— 平板/大屏,信息最全
* 3 = `<账号>·<本名>` ★默认 —— 短、能区分账号
*
* ⚠️ **不提供"仅本名"**:用户原系统日历里可能有同名日历本,裸本名无法与之区分 → 有误删风险。
* 每个镜像账户名**至少要带一个归属标识**(`同步日历` / `账号名` / 两者)。
*
* ⚠️ displayName 上限 64 字符(系统限制)→ 超长时截断,避免 createCalendar 失败。
*/
static displayNameFor(ref: BookRef, format: number): string {
const sep: string = '·';
const book: string = ref.name !== '' ? ref.name : '日历本';
const acc: string = ref.accountName !== '' ? ref.accountName : '账号';
let out: string;
if (format === 1) {
out = `${SystemCalendarMirror.DISPLAY_MARK}${sep}${book}`;
} else if (format === 2) {
out = `${SystemCalendarMirror.DISPLAY_MARK}${sep}${acc}${sep}${book}`;
} else {
out = `${acc}${sep}${book}`;
}
// displayName 系统上限 64 字符:超长截断(保留前面的归属标识,它比本名更重要)
if (out.length > 64) {
out = out.substring(0, 64);
}
return out;
}
/** 某个 displayName 是否**含我们的归属标识**(用于兜底识别;格式 1/2 含、格式 3 不含) */
static displayNameHasMark(display: string): boolean {
return display.startsWith(SystemCalendarMirror.DISPLAY_MARK);
}
/** 某个 displayName 是否是**旧版固定格式**(`同步日历 · xxx`,用于提示用户可更换) */
static isLegacyDisplayName(display: string): boolean {
return display.startsWith(SystemCalendarMirror.LEGACY_DISPLAY_PREFIX);
}
/**
* 是否还存在"旧格式"镜像账户 —— 设置页据此给出"可更换显示名称"的弱提示(只读,不修改)。
* 判据:`name` 是我们的前缀 + `displayName` 是旧格式。
*/
static async hasLegacyDisplayName(context: common.Context): Promise<boolean> {
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const all: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of all) {
const acc: calendarManager.CalendarAccount = cal.getAccount();
if (acc.name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX) &&
SystemCalendarMirror.isLegacyDisplayName(acc.displayName ?? '')) {
return true;
}
}
return false;
} catch (err) {
return false;
}
}
/* ==================== 主入口 ==================== */ /* ==================== 主入口 ==================== */
/** /**
@@ -168,6 +238,117 @@ export class SystemCalendarMirror {
return name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX); return name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
} }
/**
* ⭐⭐ 取"**确实已经镜像到系统日历**"的日历本 calKey 集合(按镜像账户的**真实存在性**判定)。
*
* 与"用户勾选了要镜像的本"不同 —— 后者只是**意图**,本可能还没被真正写进系统日历
* (如刚勾选但没点「立即执行镜像」、或根本没开镜像开关)。
* 若按"意图"跳过 App 提醒,而系统日历里其实还没有 → **两边都不提醒 = 漏提醒**。
* 故这里只认**镜像账户真实存在**这一条。
*
* ⚠️ 返回**空集**=不跳过任何本(读不到账户时保守处理,宁可多提醒)。
* ⚠️ 未开启镜像开关时直接返回空集(省一次系统查询)。
*/
static async mirroredCalKeys(context: common.Context): Promise<Set<string>> {
const out: Set<string> = new Set<string>();
try {
if (!await AppSettings.getMirrorEnabled(context)) {
return out; // 未开启镜像 → 没有任何本由系统日历负责
}
if (!await SystemCalendarMirror.hasPermission()) {
return out; // 无权限 → 系统日历里不可能有我们的条目
}
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const all: calendarManager.Calendar[] = [];
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const c of cals) {
all.push(c);
}
// 存在镜像账户 → 其 bookId 对应的本由系统日历负责
const presentBookIds: Set<string> = new Set<string>();
for (const c of all) {
const name: string = c.getAccount().name;
if (name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
presentBookIds.add(name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length));
}
}
if (presentBookIds.size === 0) {
return out;
}
// bookId → calKey(从当前账号配置解析)
const refs: BookRef[] = await SystemCalendarMirror.listBooks(context);
for (const r of refs) {
if (presentBookIds.has(r.bookId) && r.calKey !== '') {
out.add(r.calKey);
}
}
} catch (err) {
LogUtil.write(`判定"已镜像本"失败(按未镜像处理): ${(err as BusinessError).message}`);
}
return out;
}
/* ==================== 账户身份判定(0.0.8) ==================== */
/**
* 取当前 App 里真实存在的账号 id 集合(用于识别"我们的镜像账户"的身份)。
* ⚠️ 读取失败返回**空集**,调用方据此**跳过清理**(宁可不清,也不误删)。
*/
private static async currentAccountIds(context: common.Context): Promise<Set<string>> {
const ids: Set<string> = new Set<string>();
try {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
for (const a of accounts) {
if (a.id !== '') {
ids.add(a.id);
}
}
} catch (err) {
LogUtil.write(`读取账号列表失败(清理将保守跳过): ${(err as BusinessError).message}`);
}
return ids;
}
/**
* ⭐ 某个系统日历账户是不是**本应用镜像出去的**(身份级判定)。
*
* 两道闸:
* ① `name` 以 `synccalendar.mirror.` 开头 —— 挡掉所有"别人家的账户";
* ② 从 `name` 切出 `accId`(`<accId>-<href哈希>`,取**最后一个** `-` 之前的部分),
* 并确认它在当前账号库里 —— 精准识别"账号已删的孤儿",**与显示名完全解耦**。
*
* ⚠️ 为什么不能只靠 displayName:显示名现在**可切换**(格式 3 不含"同步日历"),
* 且格式 3(`账号·本名`)可能与别的 App 的账户同名 → 只按名字判定会**误删用户数据**。
*
* @param knownIds 当前真实账号 id 集合(**空集 = 读不到账号 → 一律返回 false**,保守)
* @param allowDisplayMark 是否允许"仅 displayName 含归属标识"兜底(**仅格式 1/2 可用**)
*/
static isOurMirrorAccount(
name: string,
display: string,
knownIds: Set<string>,
allowDisplayMark: boolean
): boolean {
if (knownIds.size === 0) {
return false; // 安全阀:拿不到账号身份就不认为任何账户是我们的
}
if (name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
// ① name 前缀命中 → 切出 accId 做身份校验
const bookId: string = name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length);
const cut: number = bookId.lastIndexOf('-');
const accId: string = cut > 0 ? bookId.substring(0, cut) : bookId;
// ② accId 在库里 → 确认是我们的;
// 不在库里 → 视作"账号已删的孤儿/历史脏数据"(仍需清理)→ 也返回 true
if (knownIds.has(accId)) {
return true;
}
LogUtil.write(`镜像账户 name=${name} 的 accId=${accId} 不在账号库 → 按孤儿处理`);
return true;
}
// ③ name 不像我们 → 仅当 displayName 带归属标识、且允许兜底时,才认为是我们的
return allowDisplayMark && SystemCalendarMirror.displayNameHasMark(display);
}
/* ==================== 孤儿账户清理 ==================== */ /* ==================== 孤儿账户清理 ==================== */
/** /**
@@ -196,9 +377,18 @@ export class SystemCalendarMirror {
LogUtil.write(`清理镜像账户失败:读不到系统日历账户列表 ${(err as BusinessError).message}`); LogUtil.write(`清理镜像账户失败:读不到系统日历账户列表 ${(err as BusinessError).message}`);
return out; return out;
} }
// ⭐ 0.0.8 身份校验:拿"当前真实账号 id"当判据 —— 读不到就保守跳过,绝不误删
const knownIds: Set<string> = await SystemCalendarMirror.currentAccountIds(context);
if (knownIds.size === 0) {
LogUtil.write('⚠️ 清理跳过:读不到任何账号 id(安全阀,避免误删)');
return out;
}
// displayName 兜底**仅格式 1/2 可用**(含"同步日历",可与他人区分);格式 3 不参与兜底
const fmt: number = await AppSettings.getMirrorDisplayFormat(context);
const allowDisplayMark: boolean = aggressive && (fmt === 1 || fmt === 2);
// ① 诊断:把**全部**账户列一遍(不只是我们的),才能看出残留账户到底是什么名字 // ① 诊断:把**全部**账户列一遍(不只是我们的),才能看出残留账户到底是什么名字
for (const cal of all) { for (const cal of all) {
out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, aggressive)); out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, allowDisplayMark));
} }
LogUtil.write(`系统日历账户清单(共 ${all.length}):`); LogUtil.write(`系统日历账户清单(共 ${all.length}):`);
for (const line of out.detail) { for (const line of out.detail) {
@@ -211,10 +401,9 @@ export class SystemCalendarMirror {
const name: string = acc.name; const name: string = acc.name;
const display: string = acc.displayName ?? ''; const display: string = acc.displayName ?? '';
const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX); const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
const mineByDisplay: boolean = aggressive && // ⭐ 身份级判定:name 前缀 → 切出 accId → 必须匹配当前真实账号(详见 isOurMirrorAccount)
display.startsWith(SystemCalendarMirror.DISPLAY_PREFIX); if (!SystemCalendarMirror.isOurMirrorAccount(name, display, knownIds, allowDisplayMark)) {
if (!mineByName && !mineByDisplay) { continue; // 身份不符 → 绝不碰(可能用户原有的日历本 / 别的 App 建的)
continue;
} }
if (validNames.has(name)) { if (validNames.has(name)) {
continue; // 本次还要用,保留 continue; // 本次还要用,保留
@@ -275,18 +464,78 @@ export class SystemCalendarMirror {
return out; return out;
} }
/**
* ⭐ 删除指定的若干镜像账户(按 bookId 精确定位,用于"一键重命名"前清空旧账户)。
*
* ⚠️ 调用方**必须**已先回读(`MirrorInbound.reconcile`),否则会丢掉用户手动加的日程。
* ⚠️ 只需 `name` 精确匹配即可 —— 不需要 displayName 判定(bookId 是身份,名字已无关)。
* 每个账户独立 try/catch + "清空日程后重试"降级,与 purgeOrphans 同策略。
*/
static async removeAccountsByBookIds(context: common.Context, bookIds: string[]): Promise<number> {
if (bookIds.length === 0) {
return 0;
}
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const targets: Set<string> = new Set<string>();
for (const b of bookIds) {
targets.add(SystemCalendarMirror.ACCOUNT_PREFIX + b);
}
let removed: number = 0;
let all: calendarManager.Calendar[] = [];
try {
all = await cm.getAllCalendars();
} catch (err) {
LogUtil.write(`重命名-删除旧账户失败:读不到账户列表 ${(err as BusinessError).message}`);
return 0;
}
for (const cal of all) {
const acc: calendarManager.CalendarAccount = cal.getAccount();
const name: string = acc.name;
if (!targets.has(name)) {
continue;
}
try {
await cm.deleteCalendar(cal);
removed++;
LogUtil.write(`重命名:已删除旧账户 id=${cal.id} name=${name}`);
} catch (err) {
const e1 = err as BusinessError;
LogUtil.write(`重命名:删除账户失败,尝试清空后重试 name=${name}: ${e1.message}`);
try {
const evs: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
const ids: number[] = [];
for (const ev of evs) {
if (ev.id !== undefined) {
ids.push(ev.id);
}
}
if (ids.length > 0) {
await cal.deleteEvents(ids);
}
await cm.deleteCalendar(cal);
removed++;
LogUtil.write(`重命名:清空后删除成功 name=${name}`);
} catch (err2) {
LogUtil.write(`重命名:删除账户最终失败 name=${name}: ${(err2 as BusinessError).message}`);
}
}
// 快照按键是 bookId(与账户名无关),账户删了但快照应保留 → 重建后仍幂等,故不动快照
}
return removed;
}
/** 一个账户的诊断行:`#id [KEEP/MINE/other] name=… display=… type=…` */ /** 一个账户的诊断行:`#id [KEEP/MINE/other] name=… display=… type=…` */
private static describeCal( private static describeCal(
cal: calendarManager.Calendar, cal: calendarManager.Calendar,
validNames: Set<string>, validNames: Set<string>,
aggressive: boolean allowDisplayMark: boolean
): string { ): string {
const acc: calendarManager.CalendarAccount = cal.getAccount(); const acc: calendarManager.CalendarAccount = cal.getAccount();
const name: string = acc.name; const name: string = acc.name;
const display: string = acc.displayName ?? ''; const display: string = acc.displayName ?? '';
const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX); const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
const mineByDisplay: boolean = aggressive && const mineByDisplay: boolean = allowDisplayMark &&
display.startsWith(SystemCalendarMirror.DISPLAY_PREFIX); display.startsWith(SystemCalendarMirror.DISPLAY_MARK);
let tag: string = 'other '; let tag: string = 'other ';
if (mineByName || mineByDisplay) { if (mineByName || mineByDisplay) {
tag = validNames.has(name) ? 'KEEP ' : 'MINE '; tag = validNames.has(name) ? 'KEEP ' : 'MINE ';
@@ -344,15 +593,32 @@ export class SystemCalendarMirror {
LogUtil.write( LogUtil.write(
`镜像前清理:删除 ${purge.removed} 个孤儿镜像账户,失败 ${purge.failed} 个,清理后仍剩余 ${purge.remaining} 个`); `镜像前清理:删除 ${purge.removed} 个孤儿镜像账户,失败 ${purge.failed} 个,清理后仍剩余 ${purge.remaining} 个`);
// ⭐ 0.0.8 防护:设备「重启后尚未解锁」时(direct boot),日历数据在加密存储里读不到,
// getAllCalendars() 会返回空列表,此时 createCalendar 必然失败——真机实测表现为
// 12 个镜像账户连续 `201 CreateCalendar failed!`(2026-10-16 07:55),还可能留下半创建账户。
// 正常设备至少有一个本机日历,所以「清单为 0」只可能是日历服务未就绪 ⇒ 本轮直接跳过创建,
// 等下一次同步(解锁后)自然恢复。
let calCount: number = 0;
try {
calCount = (await cm.getAllCalendars()).length;
} catch (err) {
LogUtil.write(`镜像跳过:读不到系统日历账户列表 ${(err as BusinessError).message}`);
return res;
}
if (calCount === 0) {
LogUtil.write('镜像跳过:系统日历账户清单为 0(日历服务未就绪,如设备重启后尚未解锁),本轮不创建镜像账户');
return res;
}
const winStart: number = SystemCalendarMirror.windowStart(); const winStart: number = SystemCalendarMirror.windowStart();
const winEnd: number = SystemCalendarMirror.windowEnd(); const winEnd: number = SystemCalendarMirror.windowEnd();
const plans: Map<string, MirrorOcc[]> = new Map<string, MirrorOcc[]>(); // ⭐ 0.0.8:读取用户所选的显示名格式(仅影响**新建**账户的名字;已有账户不在此处改名)
const format: number = await AppSettings.getMirrorDisplayFormat(context);
for (const ref of refs) { for (const ref of refs) {
try { try {
const plan: MirrorOcc[] = const plan: MirrorOcc[] =
await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res); await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res);
plans.set(ref.bookId, plan); await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res, format);
await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res);
res.books++; res.books++;
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
@@ -360,8 +626,7 @@ export class SystemCalendarMirror {
LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`); LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
} }
} }
// ⭐ 每次镜像后自动导出一份对照诊断,便于"系统日历里有、App 里没有"这类问题逐条比对 // ⭐ 0.0.8:不再导出 mirror_diag.txt(诊断落盘功能已随「同步记录」一并移除)
await SystemCalendarMirror.dumpDiagnosis(context, cm, refs, plans, winStart, winEnd);
LogUtil.write( LogUtil.write(
`镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` + `镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` +
`跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` + `跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` +
@@ -602,7 +867,7 @@ export class SystemCalendarMirror {
/** /**
* ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。 * ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。
* 实际写入(syncBook)与诊断导出(dumpDiagnosis)都用它,保证诊断看到的就是真正写进去的。 * 实际写入(syncBook)以它为准,保证"计划"与"写入"完全一致。
*/ */
private static async planFor( private static async planFor(
context: common.Context, context: common.Context,
@@ -689,11 +954,12 @@ export class SystemCalendarMirror {
plan: MirrorOcc[], plan: MirrorOcc[],
winStart: number, winStart: number,
winEnd: number, winEnd: number,
res: MirrorResult res: MirrorResult,
format: number
): Promise<void> { ): Promise<void> {
// ⭐ 账户名与 identifier 一律用 **bookId**(由 href 派生、稳定),绝不用 calKey // ⭐ 账户名与 identifier 一律用 **bookId**(由 href 派生、稳定),绝不用 calKey
const cal: calendarManager.Calendar = const cal: calendarManager.Calendar =
await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref); await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref, format);
// 现有镜像条目:identifier -> Event // 现有镜像条目:identifier -> Event
const existing: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>(); const existing: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
@@ -762,145 +1028,6 @@ export class SystemCalendarMirror {
`窗口=${SystemCalendarMirror.fmt(winStart)}~${SystemCalendarMirror.fmt(winEnd)}`); `窗口=${SystemCalendarMirror.fmt(winStart)}~${SystemCalendarMirror.fmt(winEnd)}`);
} }
/**
* ⭐ 诊断导出:把"App 侧计划写入"与"系统日历里实际存在"的完整对照写到
* `${filesDir}/mirror_diag.txt`(可用 hdc 直接取出分析)。
*
* 为什么需要它:用户反馈"系统日历里有、App 里没有"这种问题靠描述很难定位,
* 必须把两侧的清单原样摆在一起逐条比。每次镜像后自动重写一份。
*/
private static async dumpDiagnosis(
context: common.Context,
cm: calendarManager.CalendarManager,
refs: BookRef[],
plans: Map<string, MirrorOcc[]>,
winStart: number,
winEnd: number
): Promise<void> {
try {
const lines: string[] = [];
lines.push(`镜像诊断 ${new Date().toLocaleString()}`);
lines.push(`窗口 ${SystemCalendarMirror.fmt(winStart)} ~ ${SystemCalendarMirror.fmt(winEnd)} ` +
`(${winStart} ~ ${winEnd})`);
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
// ⭐ 全设备日历账户清单:一眼看出有没有"本应用建的、但不属于当前选中本"的残留账户
// (历史 calKey 命名的账户、重复账户)。每次镜像都导出一份,省得再靠按钮抓。
const keepNames: Set<string> = new Set<string>();
for (const r of refs) {
keepNames.add(r.sysAccountName);
}
let mineN: number = 0;
let keepN: number = 0;
lines.push('');
lines.push(`[全设备日历账户] 共 ${cals.length} 个日历本:`);
for (const c of cals) {
let accName: string = '';
let disp: string = '';
let n: number = -1;
try {
accName = c.getAccount().name;
disp = c.getAccount().displayName ?? '';
} catch (err) {
// 忽略
}
try {
n = (await SystemCalendarMirror.queryEvents(c)).length;
} catch (err) {
// 忽略
}
let tag: string = 'other';
if (accName.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
if (keepNames.has(accName)) {
tag = 'KEEP';
keepN++;
} else {
tag = 'MINE(残留)';
mineN++;
}
}
lines.push(` [${tag}] #${c.id} acc=${accName} display=${disp} 条目=${n}`);
}
lines.push(` 小结:本应用账户 ${keepN + mineN} 个(本次保留 ${keepN},残留 ${mineN})`);
for (const ref of refs) {
const plan: MirrorOcc[] | undefined = plans.get(ref.bookId);
const list: MirrorOcc[] = plan !== undefined ? plan : [];
let cal: calendarManager.Calendar | undefined = undefined;
for (const c of cals) {
if (c.getAccount().name === ref.sysAccountName) {
cal = c;
break;
}
}
lines.push('');
lines.push(`[本] ${ref.name} | bookId=${ref.bookId} | calKey=${ref.calKey} | 账户=${ref.sysAccountName} | 账户存在=${cal !== undefined}`);
if (cal === undefined) {
continue;
}
const sysEvents: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
const sysMap: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
for (const ev of sysEvents) {
const id: string = ev.identifier ?? '';
if (id !== '') {
sysMap.set(id, ev);
}
}
lines.push(` 计划写入=${list.length} 系统日历实际=${sysEvents.length}(其中带 identifier=${sysMap.size})`);
// ① 系统日历里有、计划里没有 → 多出来的(正常会被删掉;删不掉就是残留)
const planIds: Set<string> = new Set<string>();
for (const o of list) {
planIds.add(o.identifier);
}
const extra: string[] = [];
for (const id of sysMap.keys()) {
if (planIds.has(id)) {
continue;
}
const ev: calendarManager.Event | undefined = sysMap.get(id);
if (ev === undefined) {
continue;
}
extra.push(` ${SystemCalendarMirror.stamp(ev.startTime)} ${ev.title ?? ''} [${id}]`);
}
lines.push(` ① 系统多出(${extra.length}):`);
for (let i: number = 0; i < extra.length && i < 120; i++) {
lines.push(extra[i]);
}
// ② 计划里有、系统日历没有 → 漏写
const miss: string[] = [];
for (const o of list) {
if (!sysMap.has(o.identifier)) {
miss.push(` ${SystemCalendarMirror.stamp(o.start)} ${o.title} [${o.identifier}]`);
}
}
lines.push(` ② 漏写(${miss.length}):`);
for (let i: number = 0; i < miss.length && i < 60; i++) {
lines.push(miss[i]);
}
// ③ 系统日历里该账户的**全部**条目(供逐条核对,最多 400 行)
lines.push(` ③ 系统日历条目(${sysEvents.length},最多列 400):`);
const sorted: calendarManager.Event[] = sysEvents.slice().sort(
(a: calendarManager.Event, b: calendarManager.Event): number => a.startTime - b.startTime);
for (let i: number = 0; i < sorted.length && i < 400; i++) {
const ev: calendarManager.Event = sorted[i];
lines.push(` ${SystemCalendarMirror.stamp(ev.startTime)}~${SystemCalendarMirror.stamp(ev.endTime)} ` +
`${ev.isAllDay === true ? '[全天]' : ''}${ev.title ?? ''} [${ev.identifier ?? ''}]`);
}
}
const path: string = `${context.filesDir}/mirror_diag.txt`;
const text: string = lines.join('\n') + '\n';
const file: fs.File = fs.openSync(path,
fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, text);
fs.closeSync(file.fd);
LogUtil.write(`镜像诊断已导出:${path}(${lines.length} 行)`);
} catch (err) {
LogUtil.write(`导出镜像诊断失败: ${(err as BusinessError).message}`);
}
}
/** 时间戳 → `MM-DD HH:mm` */ /** 时间戳 → `MM-DD HH:mm` */
private static stamp(ts: number): string { private static stamp(ts: number): string {
const d: Date = new Date(ts); const d: Date = new Date(ts);
@@ -998,20 +1125,30 @@ export class SystemCalendarMirror {
} }
} }
/** 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色) */ /**
* 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色)。
*
* ⭐ 0.0.8:displayName 按用户所选格式生成。
* ⚠️ **已存在的账户不会被改名**(平台无"改账户名"API;改名是破坏性操作,
* 必须由用户显式触发 `reapplyDisplayNames`)。此处仅保证**新建**时用对格式。
*/
private static async ensureCalendar( private static async ensureCalendar(
cm: calendarManager.CalendarManager, cm: calendarManager.CalendarManager,
accountName: string, accountName: string,
ref: BookRef ref: BookRef,
format: number
): Promise<calendarManager.Calendar> { ): Promise<calendarManager.Calendar> {
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars(); const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of calendars) { for (const cal of calendars) {
if (cal.getAccount().name === accountName) { if (cal.getAccount().name === accountName) {
// 每次都尝试同步一次显示名与颜色(用户可能改过日历本名/色) // 每次都尝试同步一次颜色(用户可能改过日历本色)
// ⚠️ displayName 不在此处改:改账户名需删账户重建(破坏性),由用户显式触发
try { try {
await cal.setConfig({ enableReminder: true, color: ref.color }); await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) { } catch (err) {
// 设色失败不影响镜像本身 // 设色失败不影响镜像本身,但要留痕(否则"颜色不变"无从排查)
const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败 ${accountName}: ${e.message}`);
} }
return cal; return cal;
} }
@@ -1019,12 +1156,13 @@ export class SystemCalendarMirror {
const cal: calendarManager.Calendar = await cm.createCalendar({ const cal: calendarManager.Calendar = await cm.createCalendar({
name: accountName, name: accountName,
type: calendarManager.CalendarType.LOCAL, type: calendarManager.CalendarType.LOCAL,
displayName: SystemCalendarMirror.DISPLAY_PREFIX + ref.name displayName: SystemCalendarMirror.displayNameFor(ref, format)
}); });
try { try {
await cal.setConfig({ enableReminder: true, color: ref.color }); await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) { } catch (err) {
// 同上 const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败(新建) ${accountName}: ${e.message}`);
} }
return cal; return cal;
} }
@@ -1057,14 +1195,62 @@ export class SystemCalendarMirror {
ev.location = { location: e.location }; ev.location = { location: e.location };
} }
const reminders: number[] = SystemCalendarMirror.remindersOf(e); const reminders: number[] = SystemCalendarMirror.remindersOf(e);
if (reminders.length > 0) { // ⭐⭐ 只写"提醒时刻尚未到"的提醒(0.0.8 修复"已过日程仍提醒")。
ev.reminderTime = reminders; // 原因:写进系统日历的 reminderTime 一旦其时刻已过,系统会在**任何一次覆写**时
// 立即补发一条通知 → 表现为"日程都过去了还提醒"。这类提醒已无意义,索性不写。
const future: number[] = SystemCalendarMirror.futureReminders(
reminders, startTs, e.isAllDay, Date.now());
if (future.length > 0) {
ev.reminderTime = future;
} }
// ⚠️ 这里**故意不设 recurrenceRule**:重复日程由我们自己按窗口展开成多条实例, // ⚠️ 这里**故意不设 recurrenceRule**:重复日程由我们自己按窗口展开成多条实例,
// 交给系统日历展开会因 RecurrenceRule 表达不全而出现错误的重复(09-19 事故)。 // 交给系统日历展开会因 RecurrenceRule 表达不全而出现错误的重复(09-19 事故)。
return ev; return ev;
} }
/**
* 提醒时刻的"基准时刻"(系统口径):
* - 普通日程:以**开始时刻**为基准(提前 N 分钟 = start - N);
* - 全天日程:系统文档规定以**当日 09:00**为基准(提前 N 分钟 = 当天 09:00 - N)。
*/
private static reminderBaseMs(startTs: number, isAllDay: boolean): number {
if (isAllDay) {
const d: Date = new Date(startTs);
d.setHours(9, 0, 0, 0);
return d.getTime();
}
return startTs;
}
/** 过滤出"提醒时刻还没到"的提醒(用于避免写入已过期的提醒而触发补发) */
private static futureReminders(
reminders: number[],
startTs: number,
isAllDay: boolean,
now: number
): number[] {
const base: number = SystemCalendarMirror.reminderBaseMs(startTs, isAllDay);
const kept: number[] = [];
for (const r of reminders) {
if (r > 0 && base - r * 60000 > now) {
kept.push(r);
}
}
return kept;
}
/** 提醒数组的规范化字符串(排序去重后拼接),用于比较 */
private static reminderKey(reminders: number[]): string {
const s: number[] = reminders.slice().sort((a: number, b: number): number => a - b);
const out: string[] = [];
for (const n of s) {
if (out.indexOf(String(n)) < 0) {
out.push(String(n));
}
}
return out.join(',');
}
/** 本地时区当天 0 点的时间戳 */ /** 本地时区当天 0 点的时间戳 */
private static dayStart(ts: number): number { private static dayStart(ts: number): number {
const d: Date = new Date(ts); const d: Date = new Date(ts);
@@ -1127,6 +1313,15 @@ export class SystemCalendarMirror {
if ((cur.location?.location ?? '') !== (want.location?.location ?? '')) { if ((cur.location?.location ?? '') !== (want.location?.location ?? '')) {
return 'location'; return 'location';
} }
// ⭐ 0.0.8:提醒也要比较 —— 否则用户改了提醒时间,系统日历侧**永远不会更新**
// (旧版 diffOf 不比 reminderTime,改动被判定为"无差异" → 不触发 updateEvent)。
// ⚠️ 写入侧已过滤掉"已过期"的提醒(见 toSystemEvent / futureReminders),
// 所以这里的比较也用同一口径,避免"每轮都因提醒不同而重写"。
const curRem: number[] = cur.reminderTime ?? [];
const wantRem: number[] = want.reminderTime ?? [];
if (SystemCalendarMirror.reminderKey(curRem) !== SystemCalendarMirror.reminderKey(wantRem)) {
return `reminder ${SystemCalendarMirror.reminderKey(curRem) || '(无)'} != ${SystemCalendarMirror.reminderKey(wantRem) || '(无)'}`;
}
return ''; return '';
} }
@@ -11,7 +11,7 @@ const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility { export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log // 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
LogUtil.init(this.context); LogUtil.init(this.context);
LogUtil.write('---- App 启动 ----'); LogUtil.write('---- App 启动 ----');
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听 // 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
@@ -3,10 +3,21 @@
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit'; import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit'; import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { CardDataService, CardData } from '../common/CardDataService'; import { CardDataService, CardData } from '../common/CardDataService';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
export default class EntryFormAbility extends FormExtensionAbility { export default class EntryFormAbility extends FormExtensionAbility {
/** 当前进程 pid(取不到返回 -1):用于判断卡片回调是否与主 App 同进程 */
private static pidOf(): number {
try {
return process.pid;
} catch (err) {
return -1;
}
}
/** 组装更新数据并推送到指定卡片 */ /** 组装更新数据并推送到指定卡片 */
private pushData(formId: string, dayOffset: number = 0): void { private pushData(formId: string, dayOffset: number = 0): void {
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => { CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
@@ -40,10 +51,30 @@ export default class EntryFormAbility extends FormExtensionAbility {
onUpdateForm(formId: string): void { onUpdateForm(formId: string): void {
LogUtil.init(this.context); LogUtil.init(this.context);
LogUtil.write(`系统触发卡片更新: formId=${formId}`); LogUtil.write(`系统触发卡片更新: formId=${formId}(进程 pid=${EntryFormAbility.pidOf()})`);
CardDataService.registerForm(this.context, formId); CardDataService.registerForm(this.context, formId);
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天) // ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
this.pushData(formId, CardDataService.getDayOffset(formId)); this.pushData(formId, CardDataService.getDayOffset(formId));
// ⭐ ② 再借这次系统刷新顺带跑一次后台同步(见 runOnceThrottledByCard:带节流、永不抛错)
// 同步成功后再推一次,让卡片显示刚同步回来的最新数据。
//
// ⚠️ 关键约束(依据官方 FormExtensionAbility 生命周期文档):
// "FormExtensionAbility 进程不能常驻后台,生命周期回调中无法处理长时间的任务,
// 生命周期调度完成后会继续存在 **10 秒**……可能需要 10 秒以上才能完成的业务逻辑,
// 建议拉起主应用进行处理。"
// ⇒ 本处同步**可能被进程回收中断**。这是**可接受**的,因为:
// · 同步是幂等的(基于 etag 增量比对 + dirty 标记),中断不会损坏任何数据;
// · 中断只是"这一次没同步成",下个 30 分钟卡片再刷新时会重试;
// · 正常网络下 1~3 个账号的**增量**同步通常数秒内完成,落在 10 秒窗口内。
// ⇒ 因此它是"**尽力而为地提高后台同步频率**",与 workScheduler 2 小时兜底、
// 回前台/网络恢复补同步共同构成多层保障,**不承诺 30 分钟必然同步成功**。
// (不采用"拉起主应用同步"是因为会每 30 分钟弹出一次界面,体验不可接受。)
BackgroundSyncRunner.runOnceThrottledByCard(this.context).then((): void => {
this.pushData(formId, CardDataService.getDayOffset(formId));
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片驱动同步失败: ${err.message}`);
});
} }
onRemoveForm(formId: string): void { onRemoveForm(formId: string): void {
+3 -2
View File
@@ -399,7 +399,8 @@ struct Index {
/** 自动同步:每 15 秒检查一次,到达设置的间隔(AppStorage.syncIntervalMinutes)才真正同步 */ /** 自动同步:每 15 秒检查一次,到达设置的间隔(AppStorage.syncIntervalMinutes)才真正同步 */
private startAutoSync(): void { private startAutoSync(): void {
if (this.autoSyncTimer === -1) { if (this.autoSyncTimer === -1) {
AppStorage.setOrCreate('syncIntervalMinutes', 1); // ⭐ 0.0.8:默认间隔 1 分钟 → 15 分钟(见 AppSettings.getSyncIntervalMinutes 注释)
AppStorage.setOrCreate('syncIntervalMinutes', 15);
const ctx = this.getUIContext().getHostContext(); const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) { if (ctx !== undefined) {
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => { AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
@@ -412,7 +413,7 @@ struct Index {
if (tickCtx !== undefined && !this.syncing) { if (tickCtx !== undefined && !this.syncing) {
ReminderService.tickReminders(tickCtx as common.UIAbilityContext); ReminderService.tickReminders(tickCtx as common.UIAbilityContext);
} }
const minutes: number = AppStorage.get<number>('syncIntervalMinutes') ?? 1; const minutes: number = AppStorage.get<number>('syncIntervalMinutes') ?? 15;
if (Date.now() - this.lastSyncTime < minutes * 60000) { if (Date.now() - this.lastSyncTime < minutes * 60000) {
return; // 未到设置的同步间隔 return; // 未到设置的同步间隔
} }
+222 -3
View File
@@ -11,6 +11,7 @@ import { AccountStore, DavAccount } from '../common/AccountStore';
import { ReminderService } from '../common/ReminderService'; import { ReminderService } from '../common/ReminderService';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror'; import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { MirrorRenamer, RenameResult } from '../common/MirrorRenamer';
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];
@@ -27,7 +28,7 @@ class BackupTarget {
@Component @Component
struct SettingsPage { struct SettingsPage {
@State showSystem: boolean = true; @State showSystem: boolean = true;
@State intervalMinutes: number = 1; @State intervalMinutes: number = 15; // ⭐ 0.0.8:默认 15 分钟(原 1 分钟会烧光短时任务配额)
@State backgroundSync: boolean = false; @State backgroundSync: boolean = false;
@State sysMode: string = 'display'; // display | backup @State sysMode: string = 'display'; // display | backup
@State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey) @State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
@@ -51,6 +52,12 @@ struct SettingsPage {
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌) @State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关) @State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充) @State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
// ⭐ 系统日历账户显示名格式:1=`同步日历·本名` 2=`同步日历·账号·本名` 3=`账号·本名`(默认)
@State displayFormat: number = 3;
@State displayFormatApplied: number = 3; // 已生效的格式(用于判断"应用"按钮是否可点)
@State formatRunning: boolean = false; // 正在按新格式重建镜像
@State hasLegacyNames: boolean = false; // 是否还有旧格式账户(给出"可更换"弱提示)
@State formatPreview: string = ''; // 实时预览示例
private context?: common.Context; private context?: common.Context;
aboutToAppear(): void { aboutToAppear(): void {
@@ -103,6 +110,8 @@ struct SettingsPage {
if (!ok && this.context !== undefined) { if (!ok && this.context !== undefined) {
this.mirrorEnabled = false; this.mirrorEnabled = false;
AppSettings.setMirrorEnabled(this.context, false); AppSettings.setMirrorEnabled(this.context, false);
// 权限被回收 → 镜像失效 → 提醒应交回 App 负责
ReminderService.invalidateMirrorCache();
} }
}); });
} }
@@ -118,6 +127,71 @@ struct SettingsPage {
AppSettings.getMirrorInbound(ctx).then((v: boolean): void => { AppSettings.getMirrorInbound(ctx).then((v: boolean): void => {
this.mirrorInbound = v; this.mirrorInbound = v;
}); });
// ⭐ 显示名格式:加载当前值 + 实时预览 + 检查是否还有旧格式账户
AppSettings.getMirrorDisplayFormat(ctx).then((v: number): void => {
this.displayFormat = v;
this.displayFormatApplied = v;
this.refreshFormatPreview();
});
MirrorRenamer.hasLegacyNamedAccounts(ctx).then((v: boolean): void => {
this.hasLegacyNames = v;
});
}
/** 用用户**实际的第一个账号名 + 第一个本名**拼出示例,让他所见即所得 */
private refreshFormatPreview(): void {
const ctx = this.context;
if (ctx === undefined) {
return;
}
const f: number = this.displayFormat;
SystemCalendarMirror.selectedBookRefs(ctx).then((refs: BookRef[]): void => {
if (refs.length > 0) {
this.formatPreview = SystemCalendarMirror.displayNameFor(refs[0], f);
} else {
// 没有可用本时用占位示例
const dummy = new BookRef();
dummy.name = '个人日程';
dummy.accountName = '工作';
this.formatPreview = SystemCalendarMirror.displayNameFor(dummy, f);
}
});
}
/** ⭐ 按新格式重建镜像(回读→删→重建),期间日程短暂消失几秒 */
private async applyDisplayFormat(): Promise<void> {
const ctx = this.context;
if (ctx === undefined || this.formatRunning) {
return;
}
if (this.displayFormat === this.displayFormatApplied) {
return; // 未变化,不需要重建
}
this.formatRunning = true;
try {
// 先落盘(reapplyDisplayNames 会按现有配置重建)
await AppSettings.setMirrorDisplayFormat(ctx, this.displayFormat);
const res: RenameResult = await MirrorRenamer.reapplyDisplayNames(
ctx as common.UIAbilityContext);
if (res.ok) {
this.displayFormatApplied = this.displayFormat;
this.hasLegacyNames = false;
this.getUIContext().getPromptAction().showToast({
message: `已更新系统日历中的名称(${res.mirrored} 个日历本)`
});
} else {
// 失败 → 回滚选项与配置,避免"设置显示已改、实际没改"的错觉
this.displayFormat = this.displayFormatApplied;
await AppSettings.setMirrorDisplayFormat(ctx, this.displayFormatApplied);
this.getUIContext().getPromptAction().showToast({ message: res.message });
}
} catch (err) {
const e = err as BusinessError;
this.displayFormat = this.displayFormatApplied;
this.getUIContext().getPromptAction().showToast({ message: `更新失败:${e.message}` });
}
this.formatRunning = false;
this.refreshFormatPreview();
} }
/** 重新检测通知权限状态 */ /** 重新检测通知权限状态 */
@@ -219,6 +293,51 @@ struct SettingsPage {
return this.allBooks.length - this.mirrorCandidates.length; return this.allBooks.length - this.mirrorCandidates.length;
} }
/** 已勾选的可镜像本数量(mirrorKeys 里可能残留已不在候选里的 bookId,按候选口径统计) */
private mirrorSelectedCount(): number {
return this.mirrorCandidates.filter((b: BackupTarget): boolean =>
this.mirrorKeys.indexOf(b.bookId) >= 0).length;
}
/**
* 「全选 / 全不选」要镜像的日历本。
* ⭐ 批量落盘只写一次 preferences、缓存只失效一次(与逐个 toggle 的关键区别);
* 全不选时逐本删除对应系统日历账户(与取消单个勾选同一语义)。
* 半选状态下点按 = 全选。
*/
private async toggleMirrorAll(): Promise<void> {
if (this.context === undefined || this.mirrorCandidates.length === 0) {
return;
}
const selectAll: boolean = this.mirrorSelectedCount() < this.mirrorCandidates.length;
if (selectAll) {
const ids: string[] = this.mirrorCandidates.map((b: BackupTarget): string => b.bookId);
this.mirrorKeys = ids;
await AppSettings.setMirrorBookIds(this.context, ids);
this.getUIContext().getPromptAction().showToast({
message: `已全选 ${ids.length} 个日历本,点「立即执行镜像」写入系统日历`
});
} else {
// 逐本删除系统日历账户;逐个独立 try,失败不影响其余
for (const k of this.mirrorKeys) {
try {
await SystemCalendarMirror.removeById(this.context, k);
} catch (err) {
const e = err as BusinessError;
console.error(`全不选:删除镜像账户失败 bookId=${k}: ${e.message}`);
}
}
this.mirrorKeys = [];
await AppSettings.setMirrorBookIds(this.context, []);
this.getUIContext().getPromptAction().showToast({
message: '已取消全部勾选,并清除对应的系统日历账户'
});
}
// ⭐ 勾选本集合变化 → 提醒侧"已镜像本"缓存失效并重排提醒
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
}
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */ /** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
private isBookReadonly(b: BackupTarget): boolean { private isBookReadonly(b: BackupTarget): boolean {
return !b.serverWritable || this.manualKeys.includes(b.calKey); return !b.serverWritable || this.manualKeys.includes(b.calKey);
@@ -446,13 +565,19 @@ struct SettingsPage {
} }
this.mirrorEnabled = isOn; this.mirrorEnabled = isOn;
await AppSettings.setMirrorEnabled(this.context, isOn); await AppSettings.setMirrorEnabled(this.context, isOn);
// ⭐ 镜像开关变化 → 立即失效提醒侧的"已镜像本"缓存,并重排提醒
// (开启:这些本改由系统日历提醒,App 不再重复;关闭:必须立刻恢复 App 提醒,避免漏提醒)
ReminderService.invalidateMirrorCache();
if (isOn) { if (isOn) {
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」' message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」'
}); });
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
return; return;
} }
const n: number = await SystemCalendarMirror.removeAll(this.context); const n: number = await SystemCalendarMirror.removeAll(this.context);
// 关闭镜像后,这些本的提醒要交回 App 负责
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像' message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像'
}); });
@@ -473,6 +598,9 @@ struct SettingsPage {
return; return;
} }
await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys); await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys);
// ⭐ 勾选本变化 → 提醒侧"已镜像本"集合变化:失效缓存并重排提醒
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
} }
/** /**
@@ -521,7 +649,7 @@ struct SettingsPage {
/** /**
* 「清理残留镜像账户(诊断)」按钮: * 「清理残留镜像账户(诊断)」按钮:
* ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志; * ① 读系统日历里**全部**账户(含 id / name / displayName / type)并显示在下方清单里;
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续); * ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。 * ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
*/ */
@@ -540,7 +668,8 @@ struct SettingsPage {
if (r.remaining > 0) { if (r.remaining > 0) {
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`; msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
} }
msg += '(完整账户清单见同步日志)'; // ⭐ 0.0.8:不再写同步日志 → 提示语改为指向页面下方那份清单(accountDump)
msg += '(完整账户清单见下方)';
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 }); this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
@@ -894,6 +1023,35 @@ struct SettingsPage {
.fontColor($r('app.color.text_hint')) .fontColor($r('app.color.text_hint'))
.width('100%') .width('100%')
} }
// ⭐ 全选 / 全不选:半选状态下点按 = 全选
Row({ space: 6 }) {
Text(`全选(已选 ${this.mirrorSelectedCount()}/${this.mirrorCandidates.length})`)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
Toggle({
type: ToggleType.Checkbox,
isOn: this.mirrorCandidates.length > 0
&& this.mirrorSelectedCount() === this.mirrorCandidates.length
})
.selectedColor($r('app.color.brand'))
.onChange((on: boolean): void => {
// 半选时勾选框显示为关,onChange(on=true) 走全选;全选时取消走全不选
if (on === (this.mirrorSelectedCount() === this.mirrorCandidates.length)) {
return; // 状态未变化(个别系统会重复回调),忽略
}
this.toggleMirrorAll();
})
}
.width('100%')
.padding({ top: 2, bottom: 2 })
.onClick(() => {
// 整行可点:直接按"是否已全选"翻转,比依赖 Checkbox 的 onChange 更可靠
this.toggleMirrorAll();
})
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.mirrorCandidates, (b: BackupTarget): void => { ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
Row({ space: 6 }) { Row({ space: 6 }) {
@@ -936,6 +1094,67 @@ struct SettingsPage {
.width('100%') .width('100%')
.padding({ top: 4 }) .padding({ top: 4 })
// ⭐ 系统日历中的显示名称(0.0.8):三种格式,切换需重建镜像
Column({ space: 6 }) {
Text('系统日历中的显示名称')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.hasLegacyNames) {
Text('当前名称是旧格式,你可以在下方更换显示方式')
.fontSize(12)
.fontColor($r('app.color.brand_text'))
.width('100%')
}
Flex({ wrap: FlexWrap.Wrap }) {
ForEach([
{ v: 1, label: '同步日历·本名' },
{ v: 2, label: '同步日历·账号·本名' },
{ v: 3, label: '账号·本名' }
], (opt: Record<string, string | number>): void => {
Text(opt.label as string)
.fontSize(12)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.margin({ right: 8, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.displayFormat === (opt.v as number)
? $r('app.color.brand') : $r('app.color.card_bg'))
.fontColor(this.displayFormat === (opt.v as number)
? $r('app.color.button_text') : $r('app.color.text_primary'))
.border({
width: 1,
color: this.displayFormat === (opt.v as number)
? $r('app.color.brand') : $r('app.color.shadow_color')
})
.onClick(() => {
this.displayFormat = opt.v as number;
this.refreshFormatPreview();
})
}, (opt: Record<string, string | number>): string => `fmt_${opt.v}`)
}
.width('100%')
Text(`示例:${this.formatPreview}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
Text('修改后需重新写入系统日历,期间日程会短暂消失几秒')
.fontSize(11)
.fontColor($r('app.color.text_hint'))
.width('100%')
Button(this.formatRunning ? '正在应用…'
: (this.displayFormat === this.displayFormatApplied ? '已应用' : '应用'))
.fontSize(13)
.width('100%')
.enabled(!this.formatRunning && this.displayFormat !== this.displayFormatApplied)
.onClick(() => {
this.applyDisplayFormat();
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ top: 8 })
Button(this.mirrorRunning ? '正在执行…' : '立即执行镜像') Button(this.mirrorRunning ? '正在执行…' : '立即执行镜像')
.fontSize(14) .fontSize(14)
.width('100%') .width('100%')
@@ -11,8 +11,12 @@ struct Widget2x2Card {
@LocalStorageProp('dateMd') dateMd: string = ''; @LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = ''; @LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todayCount') todayCount: number = 0; @LocalStorageProp('todayCount') todayCount: number = 0;
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
@LocalStorageProp('selectedDate') selectedDate: number = 0;
/** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */ /** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder @Builder
addButton() { addButton() {
Button() { Button() {
@@ -35,12 +39,12 @@ struct Widget2x2Card {
}) })
} }
/** 卡片"打开 App":点击日期区或今日日程数区域触发;添加按钮是 header 行的兄弟节点,其点击不会冒泡到这里 */ /** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
private openApp(): void { private openApp(): void {
postCardAction(this, { postCardAction(this, {
action: 'router', action: 'router',
abilityName: 'EntryAbility', abilityName: 'EntryAbility',
params: {} params: { openDate: `${this.selectedDate}` }
}); });
} }
@@ -61,7 +65,6 @@ struct Widget2x2Card {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.addButton() this.addButton()
} }
@@ -92,12 +95,13 @@ struct Widget2x2Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.onClick(() => this.openApp())
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
.padding(14) .padding(14)
.backgroundColor('#FFFFFF') .backgroundColor('#FFFFFF')
.borderRadius(16) .borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日(原来只有日期区/条数区可点)
.onClick(() => this.openApp())
} }
} }
@@ -23,6 +23,8 @@ struct Widget4x2Card {
@LocalStorageProp('lunarText') lunarText: string = ''; @LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todayCount') todayCount: number = 0; @LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('ongoingJson') ongoingJson: string = '[]'; @LocalStorageProp('ongoingJson') ongoingJson: string = '[]';
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
@LocalStorageProp('selectedDate') selectedDate: number = 0;
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */ /** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */
private parseOngoing(): CardItem2x4[] { private parseOngoing(): CardItem2x4[] {
@@ -57,12 +59,12 @@ struct Widget4x2Card {
}) })
} }
/** 卡片"打开 App":点击左侧日期区、今日条数区、右侧日程区触发;添加按钮是 header 行兄弟节点,不冒泡 */ /** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
private openApp(): void { private openApp(): void {
postCardAction(this, { postCardAction(this, {
action: 'router', action: 'router',
abilityName: 'EntryAbility', abilityName: 'EntryAbility',
params: {} params: { openDate: `${this.selectedDate}` }
}); });
} }
@@ -118,7 +120,6 @@ struct Widget4x2Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} }
build() { build() {
@@ -141,7 +142,6 @@ struct Widget4x2Card {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.addButton() this.addButton()
} }
@@ -173,7 +173,6 @@ struct Widget4x2Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.onClick(() => this.openApp())
} }
.layoutWeight(1) .layoutWeight(1)
.height('100%') .height('100%')
@@ -197,7 +196,6 @@ struct Widget4x2Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} }
} }
.layoutWeight(1) .layoutWeight(1)
@@ -208,5 +206,7 @@ struct Widget4x2Card {
.padding(12) .padding(12)
.backgroundColor('#FFFFFF') .backgroundColor('#FFFFFF')
.borderRadius(16) .borderRadius(16)
// ⭐ 整卡可点:左右空白处也能进 App 并定位到当前显示日
.onClick(() => this.openApp())
} }
} }
@@ -387,7 +387,9 @@ struct Widget4x4Card {
return h < 10 ? `0${h}` : `${h}`; return h < 10 ? `0${h}` : `${h}`;
} }
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡到其它点击区) */ /** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder @Builder
addButton() { addButton() {
Button() { Button() {
@@ -442,6 +444,7 @@ struct Widget4x4Card {
.borderRadius(11) .borderRadius(11)
.padding(0) .padding(0)
.backgroundColor('#F2F3F5') .backgroundColor('#F2F3F5')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
.onClick(() => this.page(act)) .onClick(() => this.page(act))
} }
@@ -462,6 +465,7 @@ struct Widget4x4Card {
.borderRadius(11) .borderRadius(11)
.padding(0) .padding(0)
.backgroundColor('#007DFF') .backgroundColor('#007DFF')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
.onClick(() => this.page('today')) .onClick(() => this.page('today'))
} }
} }
@@ -554,7 +558,6 @@ struct Widget4x4Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} else { } else {
List({ space: 4 }) { List({ space: 4 }) {
ForEach(this.parseList(), (item: LItem4) => { ForEach(this.parseList(), (item: LItem4) => {
@@ -568,7 +571,6 @@ struct Widget4x4Card {
.layoutWeight(1) .layoutWeight(1)
.scrollBar(BarState.Auto) .scrollBar(BarState.Auto)
.cachedCount(8) .cachedCount(8)
.onClick(() => this.openApp())
} }
} }
@@ -589,7 +591,6 @@ struct Widget4x4Card {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.pageBtn('›', 'next') this.pageBtn('›', 'next')
this.sideBtn() this.sideBtn()
} }
@@ -630,7 +631,6 @@ struct Widget4x4Card {
.padding({ left: 5, right: 5 }) .padding({ left: 5, right: 5 })
.borderRadius(5) .borderRadius(5)
.backgroundColor(a.color) .backgroundColor(a.color)
.onClick(() => this.openApp())
}, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`) }, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`)
if (this.w4AllDayRest() > 0) { if (this.w4AllDayRest() > 0) {
Text(`还有 ${this.w4AllDayRest()} 个全天日程`) Text(`还有 ${this.w4AllDayRest()} 个全天日程`)
@@ -690,6 +690,9 @@ struct Widget4x4Card {
.padding(12) .padding(12)
.backgroundColor('#FFFFFF') .backgroundColor('#FFFFFF')
.borderRadius(16) .borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
.onClick(() => this.openApp())
} }
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */ /** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@@ -815,7 +818,6 @@ struct Widget4x4Card {
.width('100%') .width('100%')
.height(this.w4BlockVp(b)) .height(this.w4BlockVp(b))
.constraintSize({ minHeight: 14 }) .constraintSize({ minHeight: 14 })
.onClick(() => this.openApp())
}, (b: TBlock) => `b_${r.key}_${b.eventKey}`) }, (b: TBlock) => `b_${r.key}_${b.eventKey}`)
} }
.layoutWeight(1) .layoutWeight(1)
@@ -384,7 +384,9 @@ struct Widget6x4Card {
return h < 10 ? `0${h}:00` : `${h}:00`; return h < 10 ? `0${h}:00` : `${h}:00`;
} }
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡) */ /** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder @Builder
addButton() { addButton() {
Button() { Button() {
@@ -439,6 +441,7 @@ struct Widget6x4Card {
.borderRadius(11) .borderRadius(11)
.padding(0) .padding(0)
.backgroundColor('#F2F3F5') .backgroundColor('#F2F3F5')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
.onClick(() => this.page(act)) .onClick(() => this.page(act))
} }
@@ -457,6 +460,7 @@ struct Widget6x4Card {
.borderRadius(11) .borderRadius(11)
.padding(0) .padding(0)
.backgroundColor('#007DFF') .backgroundColor('#007DFF')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
.onClick(() => this.page('today')) .onClick(() => this.page('today'))
} }
} }
@@ -549,7 +553,6 @@ struct Widget6x4Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} else { } else {
List({ space: 4 }) { List({ space: 4 }) {
ForEach(this.parseList(), (item: LItem6) => { ForEach(this.parseList(), (item: LItem6) => {
@@ -563,7 +566,6 @@ struct Widget6x4Card {
.layoutWeight(1) .layoutWeight(1)
.scrollBar(BarState.Auto) .scrollBar(BarState.Auto)
.cachedCount(8) .cachedCount(8)
.onClick(() => this.openApp())
} }
} }
@@ -584,7 +586,6 @@ struct Widget6x4Card {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
Text(`${this.dayCount} 条`) Text(`${this.dayCount} 条`)
.fontSize(10) .fontSize(10)
.fontColor('#B0B0B0') .fontColor('#B0B0B0')
@@ -629,7 +630,6 @@ struct Widget6x4Card {
.padding({ left: 6, right: 6 }) .padding({ left: 6, right: 6 })
.borderRadius(6) .borderRadius(6)
.backgroundColor(a.color) .backgroundColor(a.color)
.onClick(() => this.openApp())
}, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`) }, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`)
if (this.w6AllDayRest() > 0) { if (this.w6AllDayRest() > 0) {
Text(`还有 ${this.w6AllDayRest()} 个全天日程`) Text(`还有 ${this.w6AllDayRest()} 个全天日程`)
@@ -689,6 +689,9 @@ struct Widget6x4Card {
.padding(12) .padding(12)
.backgroundColor('#FFFFFF') .backgroundColor('#FFFFFF')
.borderRadius(16) .borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
.onClick(() => this.openApp())
} }
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */ /** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@@ -814,7 +817,6 @@ struct Widget6x4Card {
.width('100%') .width('100%')
.height(this.w6BlockVp(b)) .height(this.w6BlockVp(b))
.constraintSize({ minHeight: 16 }) .constraintSize({ minHeight: 16 })
.onClick(() => this.openApp())
}, (b: TBlock6) => `b_${r.key}_${b.eventKey}`) }, (b: TBlock6) => `b_${r.key}_${b.eventKey}`)
} }
.layoutWeight(1) .layoutWeight(1)