74 Commits
Author SHA1 Message Date
laoyang 42fb5fd120 release: 版本升级 0.2.0 → 0.2.1(待办功能小升级)
## 版本变更
- versionName: 0.2.0 → 0.2.1
- versionCode: 10200 → 10201

## 同步一致性验证(用户要求)
对比手机端本地库与服务端 CalDAV 的待办数据:

| 日历本 | 服务端 | 手机端 |
|---|---|---|
| Inbox | 测试收件箱、再测试 | 同 ✅ |
| 家庭计划 | 洗衣服、做饭、买菜 | 同 ✅ |
| 工作任务 | 写方案、研讨会 | 同 ✅ |
| 个人成长 | 健身、读书 | 同 ✅ |
| 合计 | 9 条 | 9 条 ✅ |

完成状态也一致:研讨会 `STATUS:COMPLETED`/100% ↔ `completed=1`,
其余 8 条 `NEEDS-ACTION`/0% ↔ `completed=0`。

**结论:标题、归属、完成状态三项完全一致,同步正常。**

## 产物验证
解包 HAP 读 module.json:
```
bundleName : synccalendar.yangyq.net
versionName: 0.2.1
versionCode: 10201
```
大小 4.36 MB,BUILD SUCCESSFUL,已安装到真机。
2026-11-05 16:50:05 +08:00
laoyang 8cc28c6d53 fix(日视图): 列表视图待办区渲染问题——尝试记录(未解决)
## 用户反馈
"刚才显示两遍的时候,你是能做出来的呀。显示两遍的时候,箭头是正常的。"

确认:重复渲染时(提交 2c56574),列表视图的待办区**能正常显示**,
只是显示了两份。删掉重复那次调用后,剩下那次就渲染不出来。

## 已尝试的 8 种方案(全部失败)
1. 放在根 Column 第一位(无条件)→ 不渲染
2. `if (this.scrollable)` 包裹根位置 → 不渲染
3. 放进 timelineBody 内部(Row 之前)→ 不渲染
4. 放在 else 分支(timelineBody 之前)→ 不渲染
5. 新建 AgendaTopBar 独立 @Component 放在 DayTimelineView 外层 → 不渲染
6. `flexShrink(0)` 包裹 → 不渲染
7. `clip(false)` 让内容不被裁剪 → 不渲染
8. build 根节点加 Column 包裹 → 不渲染
9. topBar 加 render 参数(双调用位 + 条件渲染)→ 不渲染

## 诊断结论(矛盾点)
- `onAppear` / `onAreaChange` 都确认被调用,且 `h=57.76`(有高度!)
- `todos=2`(数据存在)
- 但 `uitest dumpLayout` 里搜不到任何 topBar 节点,整页只有 44 个文字节点
- 时间轴 y 起点始终是 407,从不为 topBar 让位

**有高度、有数据、组件被调用,但 UI 树里没有节点。**
这个组合说明调度/绘制层出了问题,而非单纯布局计算。

## 当前代码状态
`DayTimelineView.build()`:
```
Column {
  if (scrollable) { topBar() }
  if (scrollable) { Scroll { timelineBody() } }
  else { topBar(); timelineBody() }
}
```
即:周/月视图走固定区(**已验证正常**),列表视图走 else 分支(**不显示**)。

## 影响面
- 周/月视图:功能完好(#68 已验收)
- 列表视图:待办/全天区缺失,但不崩、不重复

## 下一步建议
需要单独一轮、用最小复现工程定位 ArkUI 的 @Builder 在 ListItem 内的渲染行为,
而不是继续在大文件里盲试。
2026-11-05 16:47:12 +08:00
laoyang ab718fc00f fix(日视图): 修复列表视图待办/全天区重复渲染;折叠改为每天独立 (#68 后续)
## 修的
### 1. 列表视图重复显示两遍(已修)
`DayTimelineView.build()` 里 `this.topBar()` 写在 `if (this.scrollable)` **之前**,
`else` 分支里又调了一次 → 列表视图(scrollable=false)渲染两遍。
删掉 else 里的那次重复调用。

### 2. 折叠状态"要收都收、要展都展"(已修)
原来 `topExpanded` 是 `DayTimelineView` 的 @State,
但列表视图里多个卡片是**同一个组件的多个实例**——
用户感知到的"统一联动"来自重复渲染的那两份共用状态。
移除重复渲染后,每个卡片实例各自独立。

## 未解决的(如实记录)
列表视图下 `topBar` 在 `DayTimelineView` 内部**渲染不出来**
(诊断日志确认 `todos=2` 有数据、组件被调用,但 dumpLayout 里查不到节点,
时间轴直接从 y=407 开始)。

已尝试且无效的方案:
- 放进 `timelineBody` 内部 → 被固定高度 viewH() 挤掉
- 放在 `DayTimelineView` 外层卡片里(新建 AgendaTopBar 组件)→ 仍不渲染
- `flexShrink(0)` 包裹 → 无效
- build 根节点从 `if` 改为 `Column` 包裹 → 无效

推测根因:列表视图卡片高度由 `DayTimelineView` 的 viewH() 决定,
外层 Column 加内容后 ListItem 高度测量异常,新增部分被裁掉。

## 当前状态
- 周/月视图:固定区 + 3条折叠 + 展开箭头 —— **已验收通过**(见 #68)
- 列表视图:重复渲染已修,但待办/全天区显示不出来 —— **未解决**

## 编译
BUILD SUCCESSFUL
2026-11-05 16:37:39 +08:00
laoyang 2c565747ba feat(日视图): 全天日程与待办改为浮动固定,最多3条可展开 (#68)
## 用户诉求
「打开日程之后时间会自动滚到下面(比如下午16点),全天日程被滚上去看不见了。
想做成浮动的,直接靠在日期下面,不管下面怎么滚都不动。
但条数多会盖住视野,所以最多显示3条,下面加个向下箭头,点击展开。」

## 改造
### 结构变化
改前:
  Scroll { timelineBody() }        ← 待办区+全天区+时间轴 全在滚动里
    Column { 待办区 / 全天区 / Row(刻度+三层时间轴) }

改后:
  Column {
    topBar()                        ← 固定,不滚
    Scroll { timelineBody() }       ← 只剩时间轴
  }

### 为什么用 Column 而不是 Stack 浮动
用户原话是"浮动",但实现上用 Column 顺序布局(固定区占位、时间轴在其下)
比 Stack 叠浮更稳妥:不遮挡、不需算偏移。视觉上同样是"贴着日期下方不动"。
这样避免了用户担心的"盖住一大片视野"——固定区本身占位,不会被内容压。

### 折叠机制
- 待办 + 全天/跨天**合并**成一个列表(否则两块各自3条会变6条)
- 折叠时最多 3 条
- 超过时显示「还有 N 条 ⌄」,点击展开为「收起 ⌃」
- 待办在前、全天日程在后

### 顺带修正
`allDayH`(原用于滚动定位时把全天区高度计入偏移)现在恒为 0,
因为该区已移出 Scroll。若不改,滚动定位会多偏移整个固定区高度。

## 真机验证(5NC0225B27004718)
创建 4 条今天截止的测试待办后:

| 验证项 | 实测 |
|---|---|
| 固定区位置 | 日期行 y=707 下方,依次 783/871/958 |
| 折叠生效 | 只显示 折叠测试1/2/3,第4条隐藏 ✅ |
| 合并计数 | 「还有 3 条」(6条-3条,含待办本的其他条目)✅ |
| 展开箭头 | `[595,1030] ⌄` 向下箭头 ✅ |
| 点击展开 | 显示全部 6 条,文字变「收起」、箭头变 `⌃` ✅ |
| 时间轴在其下 | 刻度 11:00 从 y=1104 开始,未被遮挡 ✅ |

## 编译
BUILD SUCCESSFUL
2026-11-05 16:21:49 +08:00
laoyang f08a2a0dea fix(待办): 新建待办默认不带截止时间 (#55)
## 现象
新建待办时,截止时间默认被填成"今天 09:00",用户还得手动清掉。

## 根因
两处叠加:
1. `Index.openTodoCreator()` 传了 `pendingTodoDate = this.selectedDate`
2. `TodoEditPage.initPage()` 拿到这个值就 `this.hasDue = true`,
   并把截止时间设成"该日 09:00"

而 `@State hasDue` 本身的默认值是 `false` —— 逻辑上"默认有截止"是这两处
预填把它顶成 true 的。

## 语义上也不对
日程(VEVENT)是"发生在某天某时的**事**",所以默认带时间合理;
待办(VTODO)是"**要完成的事**",多数一开始并不确定哪天截止,
CalDAV 的 VTODO 里 DUE 本来就是可选字段。

## 修法
1. `openTodoCreator` 不再传 `selectedDate`,改为传 0(不预填)
2. `TodoEditPage` 去掉预填分支:`hasDue` 保持 false、`dueMs` 保持 0
3. 日期选择器仍预置一个合理初值(明天 09:00),用户点了才会真正设置

## 真机验证(5NC0225B27004718)
| 验证项 | 结果 |
|---|---|
| 截止时间显示 | `无截止时间`(原来是 `2026-11-05 09:00`)✅ |
| 「清除」按钮 | 不存在(无截止时不显示)✅ |
| 右侧箭头 `›` | 存在,整行可点 ✅ |

## 编译
BUILD SUCCESSFUL
2026-11-05 16:13:17 +08:00
laoyang f807a5d99f fix(同步): Inbox 待办同步已恢复正常,撤销上一轮的误判 (#54)
## 重要更正
上一轮(#53)我判断"群晖把 home_todo 当 home 别名"是**错的**。
原因:我当时在 App 里建的那条待办卡在未同步状态,误导了判断。

## 真实情况(本轮实测)
用户在电脑端 Inbox 新建一条「测试收件箱」待办后:
```
日历本[1]「Inbox」待办:REPORT 1 个资源,解析 1 条,新增1 更新0 删除0 不变0
```
本地库确认:
```
748 | _1 | event | Hvf        | syncal-*.ics            ← 旧脏数据
755 | _1 | todo  | 测试收件箱  | 20261105T121432-*.ics   ← 新建待办,已正确入库
```

**Inbox 的 VTODO 同步是正常的。** 之前查到空 multistatus,
是因为 App 内创建的那条在服务器上尚未落定(Put 201 但 REPORT 未立即可见)。

## 真正修好的是 #51 的问题
纯待办本跳过 event 通道(#51)防止了 Hvf 这类脏数据继续产生:
```
日历本[1]「Inbox」是纯待办本(COMP_SET=todo),跳过 event 通道
```

## 本次改动
- 移除临时 PROPFIND 诊断分支
- 保留 `listNames()` 工具方法(后续排查服务器文件清单可用)
- 保留 `reportRaw()` / `reportAll()`(通用 REPORT 能力)

## 编译
BUILD SUCCESSFUL

## 遗留
本地库 id=748(title=Hvf, kind=event, cal_key=_1)是修复前产生的脏数据,
待清理(不影响使用,但会占用 Inbox 本的一行)。
2026-11-05 12:17:38 +08:00
laoyang 8126c058fa diag(同步): 抽出 reportRaw 通用执行器,排查 Inbox 待办拉不到 (#53)
## 排查过程与结论

用户反馈:在 Inbox 新建待办后,列表里看不到。

### 诊断手段
把 `reportComponents` 的 HTTP 执行部分抽成 `reportRaw`,
新增 `reportAll`(不带 comp-filter 的全量 calendar-query)作为对照。

### 关键数据
| 查询 | 结果 |
|---|---|
| Inbox 的 VTODO REPORT | 响应 68 字符,含VTODO=false,响应数=0 |
| Inbox 的全量 REPORT | **722 个资源,全部 VEVENT=true** |
| 娱乐(home) 的 REPORT | **722 个资源** ← 与上面数字完全相同 |
| 待办创建 PUT | `home_todo/todo-1793849023185-qx4b36.ics` → **201 成功** |

### 结论
**群晖把 `home_todo` 解析成了 `home` 的别名**,两个 URL 返回同一份 722 条日程数据。
所以:
1. 待办 PUT 到 `home_todo/todo-xxx.ics`,实际写进了 home 本
2. 按 VTODO 过滤查 `home_todo` → 服务器在这个"别名本"里找不到 VTODO → 返回空
3. 待办永远同步不下来

这不是 App 的 bug,是**服务器端日历本配置问题**(Inbox 指向了错误的存储)。

## 本次改动
- `reportComponents` 拆出 `reportRaw(href, auth, body, compName)`
- 新增 `reportAll(href, auth)`:全量 calendar-query
- 移除临时诊断日志

## 待用户处理
需要在群晖 Calendar Server 后台检查 `home_todo` 这个日历本的真实路径,
或让用户改用其他待办本(如「个人成长」已验证可正常工作)。

## 编译
BUILD SUCCESSFUL
2026-11-05 11:26:37 +08:00
laoyang 051257e3be fix(日程): 居中弹层补齐背景色,之前透明看不清内容 (#52)
## 现象
时间选择框弹出来后没有背景色,"啥都看不见"。

## 根因
日程页的三个弹层(时间选择 / 重复范围 / 重复截止)原来靠 **bindSheet 自带的背景色**
(配置里的 `backgroundColor: $r('app.color.card_bg')`)。
#51 改成自绘居中弹层后,这三个 @Builder 的根 Column 只有
`.width('100%')` 和 `.padding(...)`,**没有设置 backgroundColor** →
弹层透明,背后的表单内容直接透出来,与滚轮文字糊在一起。

待办页的 dueDialog 当时写对了(有 backgroundColor),所以只有日程页出问题。

## 修法
三个弹层统一改为居中卡片样式:
  .width('86%')
  .padding(18)
  .borderRadius(16)
  .backgroundColor($r('app.color.card_bg'))

## 真机验证(5NC0225B27004718)
像素采样:
| 位置 | 实测颜色 | 期望 |
|---|---|---|
| 弹层内 y=750/900/1200/1500 | (255,255,255) 纯白 | 卡片背景 ✅ |
| 弹层按钮 y=1650 | (0,126,255) 品牌蓝 | 确定按钮 ✅ |
| 弹层外 y=200/400 | (149,150,152) 灰 | 遮罩压暗 ✅ |
| 弹层外 y=2000/2200 | (145,146,148) 灰 | 遮罩压暗 ✅ |

内白外灰,对比清晰,内容可读。
2026-11-05 11:22:43 +08:00
laoyang daa577e88e feat(待办/日程): 两页时间选择器统一居中弹出 + 待办截止行去掉「修改」字样 (#51)
## 1. 日程页时间选择器也改为居中弹出
用户要求:「新建日程那里,开始和结束时间也用这种方式弹出来吧」

与待办页保持一致:根节点改 Stack,弹层居中浮在内容之上。
移除 bindSheet(SDK 的 SheetOptions 没有 position/alignment,只能底部弹)。
三种弹层(时间选择/重复范围/重复截止)继续共用同一个 sheetMode 切换。

### 真机验证(5NC0225B27004718)
弹层 y=713~1691,中心 1202;屏幕中心 1222,偏差 20px ✅

## 2. 待办页截止时间行去掉「修改」二字
用户要求:「修改那两个字没必要,直接点那个时间框,出现选择时间的地方」

整行本来就可点,右侧改为一个 `›` 箭头作视觉暗示(与日程页「开始/结束时间」行一致)。

## 3. 修复:纯待办本的 event 通道误落库(导致 Inbox 待办消失 + 编辑报错)

### 根因
`SyncEngine` 的 event 通道用 `DavClient.reportEtags(href)`,**不区分本的 COMP_SET 类型**。
对纯待办本(如群晖 Inbox,COMP_SET=todo)也照常跑 event REPORT:
- 服务器返回了文件列表(里面其实是 VTODO 文件)
- `parseEvents` 解析出 0 条日程,**但文件仍被落库成 kind='event'**
- 结果:本地库出现"属于待办本的日程记录"(实测 id=748 title=Hvf)
- 用户新建的「测试」待办进不来,反而被这条假日程占用 cal_key

### 后果
- 待办列表看不到(列表只查 kind='todo')
- 点它编辑走日程通道 → 报「需要联网 + 日历本需可写」
- 用户反馈的「编辑待办失败」由此而来

### 修法
event 通道前判断 `acc.calendarComps[i] === 'todo'` 就跳过,
日志明确输出「是纯待办本(COMP_SET=todo),跳过 event 通道」。

### 验证
日志实测:`日历本[1]「Inbox」是纯待办本(COMP_SET=todo),跳过 event 通道` ✅

## 编译
BUILD SUCCESSFUL

## 遗留
Inbox 的 VTODO REPORT 在群晖上仍返回空 multistatus(68 字符),
「测试」待办能否同步需用户重新建一条再验证。
2026-11-05 11:14:46 +08:00
laoyang 39337a5530 feat(待办): 选择器改为居中弹层 + 对勾改矢量绘制 (#50)
## 1. 时间选择器改为屏幕居中弹出

### 用户诉求
「点完开始之后,在中间弹出一个框来选择时间,操作起来更方便」

### 为什么不用 bindSheet
查 SDK `SheetOptions`(common.d.ts:11753)确认:**没有 position / alignment 参数**,
bindSheet 只能从底部弹,无法居中。

### 做法:自绘居中覆盖层
把 build() 根节点改为 `Stack`:
  Stack(alignContent: Center) {
    Column { 顶部栏 + Scroll 表单 }   ← 原有内容
    if (sheetShow) {
      遮罩(半透明,点空白关闭)
      centerDialog()                   ← 居中卡片
    }
  }

选这条路而不是 CustomDialogController,依据是本工程 MEMORY:
系统弹层在本环境曾"静默失败"(Index.ets:1421 已记录),
首启引导页 tipsOverlay 就是改用自绘覆盖层才可靠的。

## 2. 对勾改为矢量 Path

### 问题
原来用字符 `✓`(U+2713),三个毛病:
- 字体依赖:不同设备粗细/形状不同
- 粗细不可控:fontWeight 对符号字形影响有限
- 居中不稳:字形基线导致视觉重心偏移

### 改法
用 `Path` 画折线,配 strokeWidth + 圆头圆角:
  commands('M6 12.5 L9.5 16 L16.5 8')  // 22vp 列表
  commands('M5 9.5 L7.5 12.5 L13 6.5') // 18vp 时间轴
得到粗细均匀、端点圆润、可精确居中的勾。

两处统一:待办列表勾选圈、时间轴待办区勾选圈。

## 编译
BUILD SUCCESSFUL

## 真机验证(5NC0225B27004718)
| 验证项 | 实测 |
|---|---|
| 弹层居中 | 弹层 y 750~1728,中心 **1239**;屏幕中心 1222,偏差仅 17px |
| 对比改造前 | 中心 1800(偏下 578px)→ 现在偏 17px |
| 日期/时间滚轮 | `DatePicker 2026年11月5日` + `TimePicker 09:00` 正常渲染 |
| 对勾 Path 数量 | 1 个 `[96,2013][165,2082]` |
| 字符 ✓ 残留 | 0 个 |
2026-11-05 10:55:15 +08:00
laoyang 007c112af2 fix(待办): 补上截止时间选择器和父任务选择器弹层 (#49)
## 现象
新建待办时点「截止时间 - 修改」没反应;点「父任务 - 选择」也没反应。

## 根因
迁移到独立页面(#47)时,只搬了字段 UI 和状态变量,
**两个选择器弹层本身漏写了**:
  `pickShow` / `parentPickShow` 只被赋值为 true,
  但 build() 里没有任何 UI 消费它们 → 赋值等于空操作。

对比原弹层版本:时间选择器是叠在弹层之上的一个 if 块;
迁到页面后需要改成 bindSheet,这一步遗漏了。

## 修复
按 EventEditPage 的做法补两个弹层,**共用唯一一个 bindSheet**:

⚠️ 关键约束(EventEditPage:105 记载的坑):
ArkUI 同一组件挂多个 bindSheet 时,后一个会覆盖前一个,
实测表现就是"白框一闪而过、什么都没出来"。
所以用一个 `sheetShow` + `sheetMode`:
  sheetMode='due'    → DatePicker + TimePicker + 确定
  sheetMode='parent' → 父任务列表 + 无父任务 + 取消

## 改动点
- 加 `sheetShow` / `sheetMode` 状态
- 「修改」→ sheetMode='due'
- 「选择」/整行 → sheetMode='parent'
- 根容器挂唯一 bindSheet,`bottomSheet()` 按 mode 分发
- 新增 `confirmDue()`:把 DatePicker/TimePicker 的值合成毫秒并置 hasDue=true

## 编译
BUILD SUCCESSFUL

## 静态自检
- bindSheet 实际调用 1 处(861 行;79 行是注释)
- DatePicker ×1、TimePicker ×1
- confirmDue 定义 + 调用
- sheetMode 赋值 3 处(默认值 + 两个入口)

## 未验证
真机(安装时设备离线)。需验证:时间滚轮能弹、选完确定后截止时间更新。
2026-11-05 09:47:45 +08:00
laoyang aa3187f7d9 fix(待办): 待办编辑页补齐沉浸式,与新建日程完全一致 (#47)
## 问题
用户指出:新建日程是沉浸式的(状态栏背景与页面同色),
而新建待办页顶部标题栏区域是白的,与状态栏割裂。

## 根因
EventEditPage 的根容器有两行关键设置,TodoEditPage 漏了:
  .padding({ top: 12 })
  .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])

`expandSafeArea` 让**背景**延伸到状态栏/导航条区域(只扩绘制范围、不移动子组件),
`padding top:12` 给状态栏留视觉空间。缺了前者,根容器高度被限制在安全区内,
状态栏下方露出独立色块 → 视觉断层。

## 验证(像素级对比,设备 5NC0225B27004718)
| 采样点 | 新建日程 | 新建待办(修复后) |
|---|---|---|
| 状态栏 y=0~160(x=540) | 全部 (242,243,245) | 全部 (242,243,245) |
| 标题栏区平均色 | (229,232,236) | (229,232,236) |
| 取消按钮 bounds | [50,158][174,267] | [50,158][174,267] |
| 标题 bounds | [427,180][653,246] | [427,180][653,246] |
| 保存按钮 bounds | [906,158][1030,267] | [906,158][1030,267] |

三项 UI 元素 bounds 与两处背景色采样**完全相同** → 沉浸式效果一致。

## 踩坑记录
`hdc file recv` 拉截图会失败(产物只有 30 字节);
`hdc shell cat` 拉二进制同样损坏。
改用 `hdc shell "base64 -w 0 <path>"` 再本地解码,可靠。
2026-11-04 19:29:52 +08:00
laoyang fe7945d300 refactor(待办): 待办编辑改为独立页面,与新建日程一致 (#47)
## 用户诉求
「做成跟新建日程一样,就是打开一个新的页面。左上角取消,右边保存,
所有信息拉下来。」

之前是 Index 里的底部弹层(todoEditorOverlay),即使加了内部 Scroll,
形态仍与日程页不一致。

## 改动
### 新增 pages/TodoEditPage.ets
完全对齐 EventEditPage 的结构:
- 固定顶部栏:`取消`(左)· `待办标题`(中)· `保存`(右)
- 中间整页 Scroll 表单:标题/日历本/截止时间/重复/优先级/提醒/标签/地点/
  父任务/附件/备注
- 编辑态底部「删除待办」,与日程页一致

### 传参方式
与日程页一致用 AppStorage:
  `pendingTodoId`(0=新建,>0=编辑)
  `pendingTodoDate`(新建时预填截止日)

### Index.ets 改动
- `openTodoCreator` / `openTodoEditor` 改为 `router.pushUrl` 跳转
- 删除死代码 104 行(旧弹层的两处入口逻辑体)
- 注册路由 `pages/TodoEditPage`

### 注意点
`CalSource` 没有 `href` 字段,需从账号按 `calKey` 反解;
且要按 `comps` 过滤掉纯日历本(写不进 VTODO)。

## 编译
BUILD SUCCESSFUL

## 真机验证(5NC0225B27004718)
| 验证项 | 实测 |
|---|---|
| 独立页面 | 日志 `pages/TodoEditPage finish inPage transition` ✅ |
| 顶部栏布局 | 取消 x=50、标题 x=427(居中)、保存 x=906 ✅ |
| 表单字段 | 日历本4个/截止/重复/优先级/提醒 全部渲染 |
| 滚动 | 上滑后 附件 y=1668、添加文件 y=1668、备注 y=1760 全部出现 ✅ |
| 顶部栏固定 | 滚动前后 取消/标题/保存 的 y 完全不变(122/143/122)✅ |
2026-11-04 19:26:11 +08:00
laoyang 22d67e8a9d fix(待办): 新建/编辑弹层改为「固定头 + 可滚动字段 + 固定底部按钮」(#46)
## 现象
新建待办弹层字段越来越多(日历本/标题/截止/循环/优先级/提醒/标签/地点/
父任务/附件/备注),整块 Column 一路往下铺,总高超出屏幕后**滑不动**,
底部「取消」「创建」按钮点不到,导致无法建单。

## 根因
弹层外层 `Column({ space: 12 })` 只有 `width('100%')`,**没有高度约束,也没有 Scroll**。
内容超过屏幕高度时直接溢出,超出部分既不可见也不可滚。

## 改法(参照新建日程页 EventEditPage 的做法)
1. 外层 Column 加 `.constraintSize({ maxHeight: '88%' })`
   —— 内容少时自动收缩,内容多时不再顶出屏幕
2. 标题行保留在 Scroll 之外(固定可见)
3. 所有字段包进 `Scroll` + `Column`,`layoutWeight(1)` 吃掉剩余高度
4. 「取消/创建」按钮移出 Scroll,固定在底部

结构变成:固定头 → Scroll(字段) → 固定底部按钮,与日程编辑页一致。

## 编译
BUILD SUCCESSFUL

## 未验证
真机(设备锁屏),需解锁后验证:能滚动到备注、取消/创建可点。
2026-11-04 19:17:21 +08:00
laoyang 8497cfeb3f feat(待办): 投射到日历视图 —— 日时间轴顶部显示"当天截止"待办区 (#45)
待办只有 DUE 没有 DTSTART,无法画时间轴色块;历史上 buildEventDaySet()
明确"待办不计入月视图"。本次按方案 A:在日视图时间轴顶部加「当天截止」待办区。

## 显示
- 位置:日时间轴(月/周视图选中日下方 + 列表视图每天卡片)顶部、
  全天/跨天区之上,随内容一起滚动
- 每行:勾选圈(颜色跟随日历本)+ 标题(完成态划线)
- 只有「当天截止 + 未完成 + 有截止时间」的待办才显示,按截止时间升序

## 实现
- `DayTimeline` 加 `todos` 字段
- `Index.todosDueOn(dateMs)` 筛选当天截止待办
- `rebuildDayTimeline()` / `buildAgendaTimelines()` 两处填充
- `DayTimelineView` 加 `onToggleTodo` 回调 + 待办区渲染
- 勾选直接复用 `Index.toggleTodo`(级联完成、提醒重排等既有逻辑全部生效)

## 刷新
toggleTodo 成功后除 reload this.todos,还重建 dayTimeline / agendaTimelines
—— 否则待办区是快照,勾选后不消失/不划线。

## 验证
- 编译 BUILD SUCCESSFUL
- 筛选逻辑等价验证:只显示(当天截止+未完成+有截止),按时间升序,边界正确
- 真机未验(设备离线)

## 未验证
真机:勾选圈交互、待办区随数据刷新、列表视图卡片里的待办区显示。
2026-11-04 17:44:56 +08:00
laoyang d9db8b476a feat(待办): 子任务(RELATED-TO)+ 附件(内联 base64 ATTACH)(#43 #44)
补齐待办模块最后两项协议能力。

## #43 子任务(父子结构)

### 协议
`RELATED-TO;RELTYPE=PARENT:<父uid>`,值用**父任务 UID**(不是本地 id——
父子关系是服务器资源属性,跨设备同步时 UID 才稳定)。

### 数据层
- `LocalEvent` / `RemoteEvent` / `DisplayEvent` 加 `parentUid`
- 表加 `parent_uid` 列,`toBucket`/`fromRow`/`applyRemote` 双向映射

### UI
- 列表:子任务缩进 34vp + 标题前「∟」连线符
- 父任务副行追加子任务进度「N/M」
- 编辑弹层加「父任务」行 → 覆盖层选择(候选 = 未完成顶层任务,
  排除自己及后代,防成环)
- **父任务勾选完成 → 级联完成所有未完成子任务**(用户选定的语义)

## #44 附件(内联 base64)

### 查证 + 实测(已更新 #44 工单)
- iCalendar RFC 5545 原生支持 ATTACH,两种形式:URI / **内联 base64**
- RFC 8607 托管附件需服务器特殊支持(群晖没有)
- **实测群晖**:PUT 带 `ATTACH;...;ENCODING=BASE64;VALUE=BINARY:<b64>` → 201,
  GET 回原文原样保留(4558 字符整行、含中文文件名「报告.pdf」都不丢)

### 实现(按标准协议,直接上传同步)
- 附件 = VTODO 的 ATTACH 行(内联 base64),随 .ics 一起 PUT 同步
- 不需要自己传文件贴链接
- `buildTodo` / `rewriteTodoFields` 读写 ATTACH;`parseTodos` 解析回填
- 数据层 `attachments` 存 JSON([{name,mime,base64}]),base64 直接存库零转换
- UI:弹层加「附件」区,文件选择器选文件 → 读二进制 → base64 编码 → 随待办保存

### 限制
最多 3 个附件、单文件 ≤5MB(内联 base64 会让 .ics 膨胀,限制防爆)。

### 折行决策
**不折行**。原因:① 工程现有 SUMMARY/DESCRIPTION/LOCATION 一贯整行不折;
② 群晖实测 4558 字符整行也能存;③ RFC 折行是 SHOULD 非 MUST,一致性优先;
④ 折行若按 75 octets 硬切会把 UTF-8 中文名(「报告.pdf」)拦腰切断成乱码。

## 兼容性
`rewriteTodoFields` 新增 `touchParent` / `touchAttach` 哨兵:
编辑只改标题时传 false,服务器上**其他客户端写的 RELATED-TO / ATTACH 原样保留**;
只有用户实际编辑了父任务/附件才 touch=true 重写。

## 验证
- 编译 `BUILD SUCCESSFUL`
- 协议往返(Python 等价复刻):生成→解析 无损,附件 base64 还原正确
- 服务器端到端:PUT(201) + GET 原样返回 RELATED-TO + 中文文件名 ATTACH

## 未验证
真机 UI(设备离线)。需人工验:子任务缩进、级联完成、文件选择器上传、
附件随待办同步到另一客户端。
2026-11-04 17:19:43 +08:00
laoyang b623a876dd fix(待办): 修正行高异常(我误读了测量单位,绕了三轮)
## 现象
待办列表每一条都非常高,几乎占满半屏。

## 根因
做 #42(编辑入口扩大热区)时,我给内容区 Column 加了 `.height('100%')`。
在 ListItem 里,百分比的参照系不是"行内容高度",而是被拉伸的可用高度 ⇒
单条 ListItem 被撑到 **1790px(573vp)**。

## 修正
删掉 `.height('100%')`,热区改由外层 Row 的 `padding(10)` 承担 ——
行高恢复由内容自然决定。

同时把待办列表从 `ListView + ListItemGroup + ForEach(Column)`
重构为**扁平 List**(大标题/分组头/条目摊平成一维数组),
因为原先改成 List 后分组渲染需要 ListItemGroup;扁平化后
分组视觉完全保留(分组头就是一行普通 ListItem),结构更可控。

## ⚠️ 我这轮绕了远路,记录下来
**误把 px 当 vp。** 屏幕 1080px ÷ 3.125 密度 ⇒ 1vp = 3.125px。

| 测量 | 换算 | 当时我的判断 | 实际 |
|---|---|---|---|
| 1790px | 573vp | 异常 ❌ | 对,确实异常 |
| 176px | **56vp** | 以为异常,继续改 | **正常**(两行文字+padding)|
| 117px | 37vp | 以为异常 | 正常(探针单行)|
| 275px | 88vp | 以为"修好了" | 我强行加 height(88) 造成 |

于是白白做了三轮"优化"(加 constraintSize、改删除按钮固定高、
给 ListItem 加 height(88)),每次都让行高更高,最后全部回退。

教训:**dump 出来的 bounds 是 px,改代码用的是 vp,必须先换算再判断**。

## 编译
`BUILD SUCCESSFUL`

## 真机验证
全部待办行统一 **56.3vp**(176px):未完成 1 条、已完成 6 条一致;
分组头 21.8vp、大标题 15.4vp,层级清晰。
2026-11-04 08:44:27 +08:00
laoyang 413900bbce feat(待办): 提醒 / 左滑删除 / 优先级标签地点 / 编辑热区 / 卡片刷新 (#38 #39 #40 #41 #42)
一次性完成待办模块的 5 项缺口。

## #38 待办提醒
`ReminderService` 原本只服务 `kind='event'`,待办完全不提醒。

- `EventDb.queryRemindableTodos`:查未完成待办,**额外要求 `completed=0`**
  (否则做完的待办到期还会弹)
- `refreshReminders` 合并日程 + 待办两路查询,文案按 kind 分支:
  待办是「将在 X 分钟后到期」/`该待办已到期`,日程仍为「开始」
- `IcsUtil.buildTodo` 写 VALARM;`rewriteTodoFields` 编辑时整段重建 VALARM
  (逐行改 TRIGGER 容易漏 ACTION/DESCRIPTION)
- 待办弹层加提醒芯片(不提醒 / 5 / 15 / 30 分钟 / 1 小时 / 1 天,多选)
- 三个写操作后调 `refreshReminders`,保证改完立刻重排

协议要点:VTODO 的 VALARM `TRIGGER` 相对 **DUE**(RFC 5545 §3.8.6.3),
所以 `TRIGGER:-PT5M` 就是"到期前 5 分钟",无需额外写 RELATED=END。

## #39 待办列表左滑删除
`swipeAction` 只能挂 ListItem,而原结构是 `Scroll + Column + ForEach`。

- 外层 Scroll → List,分组改用 `ListItemGroup`(保留"色点+本名+条数"分组头)
- 每个 ListItem 挂 `swipeAction({ end: ... })`,滑出红底「删除」
- 点击复用现有 `askDeleteTodo`(二次确认)→ `doDeleteTodo`(软删 + 服务器 DELETE)
- 未完成 / 已完成两个清单都支持

## #40 优先级 / 标签 / 地点
- 表加 `priority`(INTEGER) / `categories`(TEXT);`location` 本来就有
- PRIORITY 存 **iCal 原生值**(1 最高 … 9 最低,0 未设置),写回零转换
- ICS:`parseTodos` 读 PRIORITY/CATEGORIES,`buildTodo` 与 `rewriteTodoFields` 写
- 新增 `todoExtras()` 供新建与改写共用,避免两处生成结构不一致
- `rewriteTodoFields` 用 `priority=-1` 做"不改"哨兵(0 是合法语义:清空)
- 弹层加优先级芯片(高/中/低)、标签输入、地点输入

## #41 重复待办打勾推进
核查结论:**已实现且完整**。`toggleTodo` 已按 `recurRule !== ''` 分流到
`completeRecurringRound`,且该方法会保留"基准→DUE 偏移"、复位 STATUS 后推进。
本次未改逻辑,仅补了完成后刷新卡片与提醒。

## #42 编辑入口热区
内容区 `Column` 原来高度只到文字高度,行内上下留白点不到。
加 `.height('100%')` + `justifyContent(Center)` 撑满行高。
勾选圈仍是独立 onClick(ArkUI 不冒泡),点圈只切完成态、不进编辑。

## 顺带修复(共性遗漏)
`toggleTodo` / `saveTodoEdit` / `saveNewTodo` / `doDeleteTodo` 四处
改完库只刷 `this.todos`,**都没推送桌面卡片** —— 卡片停在旧数据。
四处统一补 `CardDataService.pushToAllForms`。

## 验证
- 编译 `BUILD SUCCESSFUL`
- 真机 dump 确认弹层新 UI 全部渲染:优先级(高/中/低)、
  提醒(不提醒/提前5分钟/15/30/1小时/1天)、标签、地点
- ICS 生成逻辑用等价实现校验:PRIORITY、CATEGORIES(逗号转义 \,
  LOCATION、VALARM(TRIGGER:-PT5M 位于 VTODO 内、结构闭合)全部通过

## 未验证
端到端「填字段→创建→查服务器 ICS」未跑通:`uitest inputText` 按坐标输入时
多个输入框会累积到同一个(把标签/地点内容追加进了标题框),是自动化限制而非代码问题。
需人工建一条带各字段的待办,确认服务器端 ICS 与上述结构一致。
提醒能否实际弹出依赖代理提醒权限(此前有配额为 0 的记录)。
2026-11-04 08:23:42 +08:00
laoyang c0b9b44ca2 fix(粘贴): URL 提取按括号配对裁剪,修复高德链接被截断导致地址丢失
## 现象(用户实测)
粘贴高德分享链接后,识别出的地点"只有一半,只有一部分"。

## 根因
高德分享链接里的 POI 名称含**未编码的半角括号**:
    ...%E9%9D%92%E5%B2%9B%E8%8F%9C(黄岛珠江路店)%2C武夷山路118号...
                              ↑ 就是这个 ( 和 )

而 URL 提取正则是 `/https?:\/\/[^\s"'<>()【】]+/i`(当时还排除了半角 `)`),
于是一到第一个 `)` 就停:

    提取长度 221 / 原文 456      ← 只拿到不到一半
    __p 切成 4 段(应为 5 段)
    名称 = "船歌·鱼水饺青岛菜(黄岛珠江路店"   ← 括号不闭合
    地址 = 空                                ← 第 5 段整个丢失

## 为什么当初排除括号
本意是防止把 URL 后跟着的中文括注一起吞掉,如
`看这个 https://x.com/a(路线图在这里)`。
但规则太粗暴,误伤了 URL 内部的合法括号 —— RFC 3986 允许 `(` `)` 出现在 query。

## 修法
1. 正则放宽:只排除空白 / 引号 / 尖括号 / **全角**括号(全角括号不会被编码进 URL,
   出现在明文里必然是用户自己加的中文括注)
2. 新增 `trimUrlTail()`:从左扫描数括号,`depth < 0` 说明遇到多余的右括号,
   那里才是真正的 URL 边界
3. 再裁掉末尾可能粘上的中文标点(。,、;!?)

## 验证(用用户提供的真实 URL 跑等价逻辑)

修复后:
    提取长度 780 / 原文 780     ← 完整
    __p 段数 5
      [0] B02140WITR
      [1] 35.95086086125046
      [2] 120.19152268767353
      [3] 船歌·鱼水饺青岛菜(黄岛珠江路店)          ← 括号完整
      [4] 武夷山路118号中南·海湾新城(珠江路金石国际广场旁) ← 地址回来了

边界场景:
    "https://x?a=1(路线图)"  →  https://x?a=1        ✅ 中文括注被裁掉
    "https://x?a=1) 后面有字"  →  https://x?a=1        ✅ 多余右括号被裁掉

同时确认另外两处 `split(',')` 都用了 `slice(2).join(',')` 拼回,
地址内部含逗号时不会被切错。

## 编译
`BUILD SUCCESSFUL`

## 未验证
真机端到端(写剪贴板 → 点粘贴 → 看结果)需人工操作:
hdc 没有可用的剪贴板写入工具(`paste` 是 Linux 文本合并命令,非剪贴板服务)。
解析逻辑本身已用真实 URL 在等价实现上验证通过。
2026-11-04 07:36:51 +08:00
laoyang b660a986dc fix(卡片): 打勾后刷新所有卡片,解决"切换卡片仍是旧数据"
## 问题
在 A 卡片上完成一个待办,划到 B 卡片,B 里还是那条待办;
必须在 B 上再完成一个,B 才刷新。

## 根因
`completeTodoFromCard(formId, localId)` 两处推送都只针对 `formId`:

    this.pushDataExcludingTodo(formId, localId);
    this.pushData(formId);

每张卡片是**独立的 formId 实例、各持一份数据**,`updateForm` 只作用于传入的那一个。
所以其它卡片完全收不到更新,直到它们各自被触发(用户点了别的待办)才重拉。

## 解法
改用 `CardDataService.pushToAllForms()`,它会遍历所有已注册 formId 逐个推送。

两个关键点:

**① 乐观更新必须保留**
点勾要立刻有视觉反馈。`pushToAllForms` 会重新查库,此刻库里还是未完成状态,
直接推会让该条又冒出来 ⇒ 看起来"点了没反应"。
所以给 `pushToAllForms` 增加可选参数 `excludeTodoId`,先推一次"排除该条"的数据
(对所有卡片生效)。

**② 时序**
    点击 → pushToAllForms(excludeId)   所有卡片立即移除该条
         → setCompleted(GET+PUT)      真正落库推服务器
         → pushToAllForms()             所有卡片推真实数据
                                        成功:该条已完成,不在未完成列表
                                        失败:该条重新出现,用户能看出没成功

## 顺带修掉一个隐患
`completeTodoFromCard` 从 void 改为 async Promise 后,`onFormEvent`(同步回调)
里的调用必须兜住 reject —— 否则未捕获的 Promise 会让 FormExtension 进程异常
(该进程生命周期只有 10 秒,异常会直接导致本次卡片更新丢失)。已补 `.catch`。

## 未改的地方(有意为之)
`onAddForm` / `onUpdateForm` / 翻页(`onFormEvent` 的 prev/next/today)仍只推单卡:
- 翻页只该影响被翻的那张卡,不该把所有卡片拽到同一天
- 系统定时刷新由各卡片各自的 formId 触发,天然独立
数据最终一致性由"同步完成 → pushToAllForms"兜底。

## 编译
`BUILD SUCCESSFUL`

## 未验证(需桌面上有卡片)
本机当前没有注册的卡片(formIds 为空),无法触发 `onFormEvent` 实测。
逻辑改动明确(单卡推送 → 全卡推送),但"切换卡片是否立即同步"需在
添加 ≥2 张待办卡片后实测确认。
2026-11-04 07:10:28 +08:00
laoyang 8b8b69b287 style(粘贴/待办卡片): 粘贴按钮与「地图」同款外观;待办卡片对齐 App 内样式
## 1. 粘贴按钮改成与「地图」一样

原来只用系统默认样式,与旁边的「地图」按钮风格割裂。
安全控件支持 fontSize / fontColor / backgroundColor / borderRadius / padding
(见 security_component.d.ts),所以可以完全对齐:

  13 号字 · brand_text 色 · card_bg 底 · 圆角 12 · 高 44

与「地图」并排、同高、同样留白。文字内容由系统枚举决定
(`PasteDescription` 只有 `PASTE` 一个值,即「粘贴」)。

真机实测两个按钮并排:
  PasteButton  [680,1802][837,1940]  文字='粘贴'
  Text(地图)   [862,1802][1018,1940]  文字='地图'
同高同排,13 号字。

## 2. 待办卡片列表对齐 App 内样式

用户指出:卡片里的待办样式(灰勾选框 + 色条)与 App 内待办列表不一致,
两套设计没必要。App 内 `Index.todoRow` 的样式是:

  - 勾选圈 **颜色跟随日历本**(未完成=空心环 2px,已完成=实心+✓)
  - 标题 15 号,已完成加删除线
  - 副行 12 号 = 截止时间 · 日历本
  - 行背景 card_bg,圆角 12,1px 边框

卡片侧改为同一套结构,只按卡片尺寸把号数收小:

| 卡片 | 圈 | 标题 | 副行 |
|---|---|---|---|
| 4×4 / 6×4 | 18 | 13 | 10 |
| 2×4 | 16 | 12 | 9 |

去掉原来的色条和灰色勾选框 —— 用日历本色圆圈,与 App 内视觉一致。

数据侧给 `TodoCardItem` 增加 `subText`(「截止时间 · 日历本」拼好的副行文字),
由数据层统一拼接,避免三张卡片各拼一遍导致不一致。

## 一致性校验
逐行 diff 确认 4×4 与 6×4 的 `todoRow` **完全一致**(归一化类型名后逐字符相等);
2×4 结构相同,仅尺寸小一档。

## 编译
`BUILD SUCCESSFUL`

## 未验证
粘贴按钮已确认渲染与布局(dump 坐标/文字),视觉观感需上机目视;
待办卡片需删除桌面旧卡片后重新添加才能看到新样式。
2026-11-03 23:23:25 +08:00
laoyang b8d5aa630c fix(卡片/粘贴): 5 项反馈修复 —— 粘贴按钮、卡片数据、卡片加号、卡片滚动、列表统一
## 1. 粘贴按钮文字看不清(EventEditPage)

根因不是"忘了设文字"——空参时系统本就给默认样式(LINES 图标 + PASTE 文案)。
真正原因是我**给了一个和系统文字颜色太接近的背景色**(card_bg 近乎白)。

查 SDK 类型定义 `paste_button.d.ts`,错误码 2 明确列出:
  「字体或图标颜色与组件背景颜色相近」「文字超出组件背景区域」等
安全组件创建时会做样式校验,对比度不足会让文字灰淡难辨、甚至授权失败。

同时发现 icon/text/buttonType **只能走构造参数**,不是链式属性
(`.text()` 会编译报 "Property 'text' does not exist")。

改为:
```
PasteButton({
  icon: PasteIconStyle.LINES,      // 该枚举只有 LINES
  text: PasteDescription.PASTE,    // 该枚举只有 PASTE
  buttonType: ButtonType.Capsule
})
```
并**去掉自定义背景色**,交给系统默认样式,只保留尺寸与圆角。

## 2. 待办卡片显示 0 条(CardDataService)

`pushToAllForms()` 是同步完成后刷新所有卡片的主路径,
但它只调 `buildCardData()`(日程),**从未调 `buildTodoCardData()`** ⇒
推给卡片的 binding 里 todoCount / todoListJson 全是默认值。

而 EntryFormAbility.pushData() 里合并了待办 —— 两条路径不一致,
所以表现成"有时对、有时 0"。

修:抽出 `mergeTodoInto()` 供两处共用;待办数据与 dayOffset 无关,
整个 pushToAllForms 只构建一次。

真机日志:`待办卡片数据刷新:未完成 5 条…列表输出 5 条`(原为 0)。

## 3. 待办卡片右上角 + 点了没反应(EntryAbility + Index)

待办卡片发的是 `widgetAction: 'addTodo'` / `'openTodo'`,
但 EntryAbility 只判断 `raw === 'add'`,Index 也只处理 `'add'`
⇒ 参数被静默丢弃。

修:两端都补上 addTodo / openTodo。
  addTodo  → 切待办 Tab + 打开新建待办
  openTodo → 切待办 Tab

## 4. 日程 2×4 卡片右侧可滚动,15 条(CardDataService + Widget4x2)

数据侧 `slice(0, 2)` → `slice(0, 15)`
视图侧 `Column + ForEach` → `List + ListItem`(虚拟化 + 滚动惯性 + 回弹)

真机日志:`进行中 15 条`,确认上限生效。

## 5. 待办 4×4 / 6×4 列表样式统一,全部可滚动

按用户选择,**统一成 4×4 样式**(勾选框 + 色条 + 标题 + 副行「截止时间 · 日历本」)。

6×4 原来是「标题 + 独立截止时间列 + 独立日历本列」,窄卡片里会把标题挤没,
改为与 4×4 完全相同的结构。已用逐行 diff 校验:两卡 todoRow **36 行完全一致**。

`MAX_TODO_ITEMS` 20 → 15,与日程卡片口径一致(所有待办卡片都已可滚动,
不必再按卡片尺寸给不同上限)。

## 编译
`BUILD SUCCESSFUL`

## 未验证
真机仅验证了「待办卡片数据不再为 0」与「日程 15 条上限」两项。
粘贴按钮视觉、卡片内滚动、卡片 + 跳转、两卡样式一致性需上机目视确认。
2026-11-03 23:14:43 +08:00
laoyang 2bb620a6ed fix(日历本): 按 COMP_SET 类型分别列出 —— 待办只给待办本,日程只给日历本
上一个提交把"全部可写本"铺开,导致新建待办时连纯日历本(娱乐/生活/工作/家庭)
也列出来,新建日程时又把纯待办本列出来。用户选错类型时服务器会拒绝写入,
或写进去但永远不在对应列表显示。

## 依据
`DavAccount.calendarComps[i]` 早就在库里存着服务器 COMP_SET 声明:
  'event'   只有 VEVENT
  'todo'    只有 VTODO
  'both'    两者都支持
  'unknown' 未声明(旧数据)

但 `CalSource` 没透传它,界面层拿不到类型,只能按"可写"一刀切。

## 改动
- `CalSource` 增加 `comps` 字段
- `CalendarDataService.loadSources` 从 `acc.calendarComps[i]` 透传(空串归 'unknown')
- 待办侧(`Index.openTodoCreator`):排除 `comps === 'event'`
- 日程侧(`EventEditPage.CalendarDataBridge.loadWritableDavSources`):排除 `comps === 'todo'`

**'unknown' 两端都保留**:旧数据没有 COMP_SET 信息,武断排除会让老用户
升级后发现日历本"消失"。

## 真机验证(5NC0225B27004718)

服务器实际类型(PROPFIND 实测):

| 本 | COMP_SET | 待办列表 | 日程列表 |
|---|---|---|---|
| 娱乐 / 生活 / 工作 / 家庭 | VEVENT | 不显示 | 显示 |
| Inbox / 家庭计划 / 工作任务 / 个人成长 | VTODO | 显示 | 不显示 |

改后实测:
- 新建待办 → 只列「群晖:Inbox / 家庭计划 / 工作任务 / 个人成长」4 个 ✅
- 新建日程 → 只列「群晖 · 娱乐 / 生活 / 工作 / 家庭」4 个 ✅

两端的本数量都从 8 降到 4,与服务器声明完全一致。

## 编译
`BUILD SUCCESSFUL`
2026-11-03 23:04:00 +08:00
laoyang ca97ec7fcd feat(待办): +号职责感知 + 新建待办(所有可写日历本铺开选择)
在「待办」视图下点 + 新建待办,其余视图仍新建日程。
新建时把全部可写 CalDAV 日历本铺开供选择,交互与新建日程一致。

## 原状
`TodoService` 只有 改/删/设重复/打勾,**没有新建方法**;
`saveTodoEdit` 开头 `if (... || this.todoEditId <= 0) return;` 直接挡掉新建。
+号固定 `addEvent()`,在待办页点它却跳到日程编辑页,与当前语境不符。

## 改动

### IcsUtil.buildTodo(新增)
从零构造 VTODO 资源。不能复用 `rewriteTodoCompletion`——后者是在服务器原文上
打补丁(保留未知字段),前提是原文已存在;新建时没有原文。

字段取舍:
- UID / DTSTAMP / SUMMARY 必写
- DESCRIPTION 为空则不写
- DUE 可选;只有日期时写 `DUE;VALUE=DATE`
- STATUS 固定 NEEDS-ACTION(完成态由 setCompleted 切换)
- 不写 PRIORITY,避免凭空替用户设优先级

⚠️ 时间格式必须与现有待办写入保持一致:本工程 `rewriteTodoSchedule` 用的是
`DUE:` + `fmtIcsTime(dueMs, false)`,即**浮动本地时间、不带 Z**。
若这里写成 UTC,同一字段出现两种格式,服务器上会显示成早 8 小时的待办。

### TodoService.createTodo(新增)
**先 PUT 服务器、成功后才落库**——与 updateTodo 的顺序相反。
更新时本地已有行可回滚;新建若先落库再 PUT 失败,会留下"本地有、服务器没有"
的孤儿待办,用户看着它存在却永远同步不出去。

同时校验:标题非空、href 非空、账号可定位、日历本可写(只读本提前拦下,避免 403)。

### Index.ets
- `todoCreating` 状态区分新建/编辑
- `openTodoCreator()`:收集全部可写 dav 本;默认选中 Inbox(名字含
  inbox/收件箱 者优先),找不到用第一个 —— 少点一步但仍可改
- `saveNewTodo()`:走 createTodo
- +号分流:`mode === 'todo'` → 新建待办,否则新建日程
- 弹层标题「新建待办」/按钮「创建」,新建时隐藏"删除"入口
- 日历本区:新建时 Flex 铺开色块胶囊(48% 宽,选中染本色调),
  编辑时保持原来只读显示

`CalSource` 没有 href 字段,`createTodo` 需要的 URL 由 calKey(`<accountId>_<index>`)
反解出账号与索引后从 `account.calendarHrefs[index]` 取,映射存在 `todoBookHrefs`。

## 真机验证(5NC0225B27004718)

待办视图点 + → 弹层标题「新建待办」、8 个日历本全部铺开、
按钮显示「创建」。填标题后点创建:

```
PUT .../synccalendartest/home_todo/todo-1793717941310-bl6ffh.ics → 201
待办新建成功 id=749 → todo-1793717941310-bl6ffh.ics
```

- 默认落到 `home_todo`(Inbox)✅ Inbox 智能匹配生效
- 服务器端复查该资源返回 **200** ✅ 真的创建了
- App 列表即时刷新:未完成 5→6 项,出现「群晖:Inbox(1)」✅

测试数据已删除(DELETE → 204),未在服务器留残留。

## 编译
`BUILD SUCCESSFUL`,本次改动无新增警告。
2026-11-03 23:00:07 +08:00
laoyang 09e2373965 docs(README): 更新到 0.2.0 现状,补「自行编译上架需申请哪些权限」
README 停留在 0.1.0 且有多处与代码不符,本次逐项核对后更新。
所有结论均以 module.json5 / AppScope / SettingsPage.ets / release profile 实测为准。

## 修正的过时内容

- 版本号 0.1.0 (10000) → **0.2.0 (10200)**
- 待办(VTODO)描述错误:「只读展示,不参与推送」
  → 实际 0.2.0 已由 `TodoService` 实现新建/编辑/打勾/删除全量双向推送,
     且写回时先 GET 原 .ics 原地改写,保留服务端未知属性
- 设置项一览与界面对不上
  → 按 SettingsPage.ets 实际分组重写为「系统日历 / 通用 / 数据 / 关于」四组,
     补上此前漏掉的「系统日历中的显示名称」「清除系统日历数据」「日历本只读·静音」

## 新增

### 📝 最近更新
补 v0.2.0 与 v0.1.0 两个版本条目,让版本变化可追溯。v0.2.0 记录:
待办双向同步、3 项同步核心修复(删除 404 / 防复活 / 404 物理清库)、反馈渠道迁移。

### 🔐 权限清单与用途(独立小节)
用表格列清 5 个权限的类型、用途、申请时机。之前只在技术要点里一行带过,
读者无法判断哪些要额外动作。

### 🔑 自行编译并上架:权限要怎么处理
这是本次重点。回答「fork 后自己上架需要申请哪些权限」:

1. **必须自己重走的**:READ_WHOLE_CALENDAR(ACL)、PUBLISH_AGENT_REMINDER。
   并明确一个大坑:**ACL 绑定 bundleName**,本项目已获批的 ACL 只对
   `synccalendar.yangyq.net` 有效;fork 后改了包名(几乎必然要改)就立即失效,
   必须用新包名重新申请。很多人会误以为代码里声明了就自动继承。

2. **ACL 怎么验证落地**:给出 openssl 解 p7b + 读 allowed-acls 的命令。
   ACL 不在 module.json5 里,而在签名 Profile 里——这正是我自己这次判断失误的地方,
   写进文档避免后来者重蹈。

3. **不想申请 ACL 的替代方案**:删掉权限,日历经 READ_CALENDAR 仍可读本应用写入的日历,
   只是看不到其他来源。社区实践过的合规绕行。

4. **其他审核注意点**:图标 alpha 通道要求、审核员无 CalDAV 账号需 Demo 模式、
   隐私政策与 module.json5 必须一致。

## 未改动
- 构建/签名章节(与当前 build-profile.json5 一致)
- 隐私、功能特性正文、相关链接(链接已是新的 Feedlite 地址)

README 353 → 479 行。
2026-11-03 09:55:06 +08:00
laoyang bcaa578ebe docs(验证): 补记 9a67fce 的真机验证结果(3 条修复全部通过)
9a67fce 的提交说明里写着「真机未验证」,现已完成验证并全部通过。
本提交仅补充文档,不改代码。

## 设备与环境
- 设备: 5NC0225B27004718 (HarmonyOS)
- 服务器: DAViCal 1.1.9 @ nas.yangyq.net/caldav.php/synccalendartest

## 关键发现:该服务器天然测不出此 bug

DAViCal 的命名规则是「uid 做 URL 编码后直接当文件名」:

  uid    : 20261102T153543-10c5d3cb@169.254.186.221
  文件名 : 20261102T153543-10c5d3cb%40169.254.186.221.ics

猜的名字 == 真名,同步 746 条后 remotePath 全部等于 uid 猜测值,
无论修复是否生效都看不出差异。必须造「文件名 ≠ UID」的资源才能验出。

## 陷阱资源

直接 PUT 一个刻意不匹配的资源:
  文件名 : trap-file.ics
  UID    : trap-uid-with-special-chars@example.com

服务器实测:
  tfxnz/trap-file.ics                                    → 200 存在
  tfxnz/trap-uid-with-special-chars%40example.com.ics    → 404 不存在

⇒ 旧代码删除时必然打到 404。

## 验证结果

### 1. remotePath 存真名 ✅
  实际存储: trap-file.ics
  旧代码会: trap-uid-with-special-chars%40example.com.ics

### 2. 删除打到正确地址并成功 ✅
  DELETE .../tfxnz/trap-file.ics → 204
  日志: 推送删除 uid=trap-uid-with-special-chars@example.com → .../trap-file.ics

### 3. 服务器端确实删掉 ✅
  trap-file.ics → 404
  REPORT tfxnz 资源数 → 0

### 4. 本地物理清库 ✅
  本地库 trap 行数 → 0

### 5. 防复活白名单未误伤 ✅
  日志: 新增0 更新0 删除0 不变1
  同 uid 始终 1 行,未插重复

## 完整同步结果(746 行)
  home      716    lvirlu      9    ycbco       7    cojiujq     7
  tfxnz       3    zlqlzm      2    tdotbsnl    2

## 踩坑记录:拉库必须连 WAL

首次拉库读到 735 行、remotePath 全等于 uid 猜测值,差点误判修复失效。
真实原因是 WAL 未合并:

  sync_calendar.db      344 KB
  sync_calendar.db-wal  428 KB   ← 最新数据全在这里

只拉主库会看到过期快照,必须连 -wal / -shm 一起拉。

## 清理
陷阱数据已彻底清除(服务器 404、本地 0 行),测试日历无残留。

详见工单 #8。
2026-11-03 09:06:02 +08:00
laoyang 9a67fce296 fix(同步): 修复删除 404、远端覆盖本地修改、404 残留无法清理 #6
采纳社区反馈(小褚的 fork)中的 3 处高危修复。均为纯逻辑修复,
不改 UI 架构、不动状态管理范式。

## 1. remotePath 用服务器真实文件名(删除/推送全链路)

### 缺陷
上传/删除 URL 里的资源名来自 `remotePath`,而它一直被硬编码成
`encodeURIComponent(uid) + '.ics'` —— **猜的**。服务器不保证文件名等于 UID:
家书 CalDav.php 用 `preg_replace('/[^A-Za-z0-9._-]/', '_', $uid)` 生成文件名,
UID `movie-188@familybook` 的真实资源叫 `movie-188_familybook.ics`。
猜错 ⇒ DELETE 打到不存在的路径 ⇒ 404 ⇒ 服务器上删不掉(用户看到"删了又回来");
PUT 同理会写到另一个文件名下,造成同一事件两个资源。

### 修法(三条链路都要覆盖)
1. `RemoteEvent` 新增 `remotePath` 字段
2. **GET 补拉**:`r.remotePath = fileName`(从 REPORT 的 href 末段取)
3. **etag 未变复用**(常态路径,之前漏了):`rowToRemote` 增加第三个参数,
   传当前 href 的真实文件名。不补这里,前一条等于白做 ——
   因为大部分资源 etag 不变、根本不走 GET。
4. `applyRemote` 落库:优先用 `r.remotePath`,为空才退回按 uid 猜
   (本地新建等服务器上还没有资源的场景)。
5. 更新分支同步 `found.remotePath`:服务器上资源改名后要跟着改,
   否则后续 DELETE/PUT 仍打旧路径。

## 2. applyRemote 加 dirty/deleted 白名单(防复活)

### 缺陷
原来只有 `if (e.dirty) continue`(dirty 行不入 keyed 索引)。
**只排除出索引不够**:远端数据 `keyed.get(key)` 拿不到 → 走"新增"分支
→ 又插一行。结果同一事件在本地有两行(用户改的 + 远端旧版),
用户看到"改完又变回去"或"出现两条"。

### 修法(补两层)
- `dirtyKeyed`:dirty 行单独索引,远端同 key 出现时直接 `continue`,
  保留本地版本,不新增不覆盖。
- `dirtyDeletedUids`:本地已标记删除的 uid 集合,远端该 uid 的任何版本
  一律忽略。**按 uid 而非 key** —— 删重复系列时本地只留主事件标 deleted,
  但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住。

(`markDeleted` 同时置 `deleted=1` 和 `dirty=1`,故 `dirty && deleted` 可同时覆盖。)

## 3. DELETE 404/410 物理清库

### 缺陷(真正的坑在 pushSeries)
`DavClient.deleteRemote` 已把 404 视作成功,但**系列删除路径**仍踩坑:
```ts
if (master.deleted) {
  try { await DavClient.deleteRemote(url, auth); }
  catch (err) {
    ...401 抛出...
    LogUtil.write(`推送删除重复系列失败:...`);
    return;            // ⭐ 404/410 也走到这里 → 直接 return
  }
  await EventDb.purgeByUid(context, calKey, uid);   // ← 永远执行不到
```
一旦 URL 拼错(正是上面第 1 条 bug 造成的)、或服务器上资源本就不在,
DELETE 返回 404 → 进 catch → **return,不清本地**。
用户看到日程还在、怎么删都删不掉、每次同步都重复失败。

### 修法
两条路径(单条 + 系列)都区分状态码:
- 401 → 抛出(凭据失效,后续每条都会失败)
- **404 / 410 → 服务器上本就没有,视为删除成功,继续清理本地**
- 其它 → 真失败,保留待推送

## 验证状态

- 编译:`BUILD SUCCESSFUL`
- 静态交叉核查:
  - `rowToRemote` 唯一调用处已补第三参数
  - `getDirty` 只查 `dirty=1`,deleted 行(双置)仍会被推送,无漏推
  - `purge` / `purgeByUid` 均为 `store.delete`,确认是物理删除
  - 剩余 3 处 `encodeURIComponent(uid)+'.ics'`(MirrorInbound 247、
    SystemCalendarImport 99、EventEditPage 1088)属**本地新建**场景,
    此时服务器上尚无资源,用 uid 作初始文件名是正确行为;
    PUT 一旦成功,该 URL 即成为资源真名,故无需再改。
- ⚠️ **真机未验证**:设备当前未连接(hdc list targets 返回空),
  需连上后按下列步骤验证。

## 待真机验证项
1. 新建日程 → 同步 → 在应用内删除 → 服务器端确认已消失
   (重点验服务器实际文件名 ≠ uid 的账号,如家书)
2. 修改日程标题 → 不手动同步 → 等自动同步 → 标题应保持修改后的值
3. 删除重复日程整个系列 → 服务器端确认整族消失,本地无残留
4. 若服务器上资源已被外部删除,应用内删除该日程应能正常清掉本地行
2026-11-03 07:36:06 +08:00
laoyang 23b5d4cb9e docs(README): 修正权限描述 —— 移除已删的 LOCATION,标注 READ_WHOLE_CALENDAR 为受限权限需 ACL 2026-11-02 22:59:31 +08:00
laoyang 37062cc75b feat(0.2.0): 待办同步、事件标记、反馈系统迁移、版本升 0.2.0 #5 #6
## 版本
versionCode 10200 / versionName 0.2.0(原 10000 / 0.1.0)

## 新增
- **VTODO 待办完整支持**:同步、展示、勾选完成、编辑、循环待办。
  关键修复:待办此前**从未被同步过** —— 同步主循环只处理 VEVENT,
  `DavClient.reportTodos()` 写了却零调用(死代码)。现已在
  `SyncEngine.syncAccountInner` 接入,并对未知类型本也拉一次
  (服务器多半不声明 supported-calendar-component-set,宁多拉不漏)。
  整段包 try/catch,单本失败不影响日程同步。
- **日历本类型图标**:`supported-calendar-component-set` 解析出
  event/todo/both/unknown,账号页在颜色前显示对应 SVG 图标。
  unknown 按日历对待(服务器不声明时不能武断判成待办)。
- **问题反馈入口**:设置页新增卡片,指向 FeedLite
  (https://feedlite.yangyq.net/?sw=synccalendar)。

## 改进
- 月视图事件标记:农历下方浅红点,每日期最多一个
- 选中/今天改为正圆底,周视图复用月视图 `dayCellCircle`
- 「今天」按钮状态化(在不在今天,视觉可辨)
- 待办页签按需显示(只看 todos.length,不依赖可能缺失的 comps)
- 新建非今天日程默认 09:00(统一 DEFAULT_START_HOUR)

## 修复
- 首屏农历不变色:外层 ForEach key 缺数据版本 → 整树复用不重绘
- 翻月闪跳:async 未等待 + @State 同栈不可读 → 改 rebuildPagesFor(y,m) 显式传参
- 翻月后整月无选中:选中日未随月迁移,且钳制导致日号漂移 → 加 preferredDay
- 周视图颜色过浅/选中不变白/星期标签丢失:复用组件时未设 inMonth

## 官网
- sf.html 新增「三、待办清单」章节(原三~八顺延为四~九)
- index.html 版本号 V0.1.0 → V0.2.0

## 工单
关闭 #3 #4 #5 #6;新建 #7 记录「VTODO 无法镜像到系统待办」的技术限制。

## 未做(技术限制)
镜像到系统日历「待办」页签:鸿蒙未开放待办 API,不可行。见 #7。
2026-11-02 22:58:30 +08:00
laoyang c39a48e95f fix(待办): 标签改为只看数据事实,不依赖可能缺失的 comps 元数据 #6
## 现象
上一版把 VTODO 同步接进同步循环后,待办数据**确实拉回来了**
(日志:家庭计划3条 / 工作任务2条 / 个人成长2条),
但待办标签仍然不显示。

## 根因:判定挂在元数据上,而元数据拿不到
`shouldShowTodoTab()` 是 `hasTodoBook() && todos.length > 0`。
`hasTodoBook()` 读 `acc.calendarComps`,而这个字段来自服务器的
`supported-calendar-component-set`。

实测:日志里**完全没有**"日历本组件类型 ..."这条记录,
说明该解析路径根本没执行 / 服务器不返回这个属性 ⇒ `calendarComps` 恒为空
⇒ `hasTodoBook()` 恒 false ⇒ 明明有待办也不显示标签。

## 修法:改看事实
直接 `return this.todos.length > 0;`

因为 `CalendarDataService.loadTodos` 内部**已经按 visibleKeys(用户勾选的
日历本)过滤**过了,所以 `todos.length > 0` 本身就等价于
"用户勾选范围内确实有待办"。用户一个待办本都没勾时,这个值必然是 0 ——
`hasTodoBook()` 原本想防的场景天然被覆盖,它是多余的。

## 原需求仍然满足
用户要求"没勾选任何待办本 → 标签不显示":
没勾选 ⇒ loadTodos 按 visibleKeys 过滤后为空 ⇒ 标签不显示。✓
"勾选了但没待办数据 → 也不显示":0 条 ⇒ 不显示。✓

## comps 还留着,但只给图标用
账号页用 comps 区分日历/待办图标 —— 那里判断错了只是图标不准,
不影响功能可用性;功能判定不能挂在这种"可能缺失"的元数据上。

## 验证(真机)
日志:
  日历本[5]「家庭计划」REPORT 返回 0 个资源 → 待办同步:3 条
  日历本[6]「工作任务」REPORT 返回 0 个资源 → 待办同步:2 条
  日历本[7]「个人成长」REPORT 返回 0 个资源 → 待办同步:2 条
界面 dump:顶部标签 同步日历|今天|月|周|列表|**待办**|2026年11月
待办标签存在 = True ✓

(另注意:这几个本 VEVENT 为 0、只有 VTODO,正是纯待办本。)

## 教训
功能可见性判定必须挂在**数据事实**上,不能挂在"可能缺失的元数据"上。
服务器不保证返回 supported-calendar-component-set,用它做开关会制造假阴性。
2026-11-02 22:43:10 +08:00
laoyang ffaffff9d6 fix(同步): 待办从未被同步 —— 取消勾选后重新勾选也拉不回来 #6
## 现象(用户反馈)
1. 账号设置里取消勾选待办本 → 保存同步 → 待办标签消失(这一步是正常的)
2. 再进去重新勾选 → 保存并同步 → **待办没回来,标签也不显示**

## 根因:VTODO 从来就没被同步过
同步主循环 `syncAccountInner` 全程只处理 VEVENT:
- `DavClient.reportEtags(href)`:REPORT 的 comp-filter 只写了 `VEVENT`
- `EventDb.queryByCalKey(context, calKey, 'event')`:只查 kind='event'
- `EventDb.applyRemote(..., isTodo)`:这个 isTodo 参数**从未传过 true**

而 `DavClient.reportTodos()`(拉 VTODO)虽然早就写好了,
**全项目零调用** —— 是一段死代码。

因果链:
· 取消勾选待办本 → `pruneStaleHrefs` 清掉不属于已选本的 todo 行
· 重新勾选 → 同步只拉 VEVENT,不拉 VTODO
· 本地库没有待办数据 → `loadTodos` 返回空 → 标签不显示

待办功能"能读能改"(勾选完成、设置循环都能用),但**数据来源一直是空的**,
只有以前残留的行在撑着。一旦被清掉就再也不回来。

## 修法
在同步循环里对每个日历本额外拉一次 VTODO:
  `reportTodos(href)` → 逐个 GET 原文 → `IcsUtil.parseTodos` → `applyRemote(..., true)`

⭐ 为什么对 unknown 类型的本也拉:
   服务器若没声明 supported-calendar-component-set,我们分不清哪个本是待办本。
   待办量远小于日程,多拉一次的代价很小;宁可贵一点,
   也不能让用户的待办再次"消失"。

⭐ 失败隔离:
   待办同步整段包在 try/catch 里,单本失败只记日志、不影响日程同步。
   很多服务器对 VTODO 查询返回 403 或不支持该 REPORT,
   不能让这种情况把整个账号同步拖垮。

## 影响面
`applyRemote` 内部按 `kind` 隔离(existing 只查同 kind),
且自带 removed 处理 ⇒ 不会误删日程行,也不会漏删已消失的待办。
2026-11-02 22:39:01 +08:00
laoyang 24cd51970c style(账号): 日历本类型图标改用 SVG 线性图标 #6
用户反馈字符图标(▦ / ✓)不好看。

## 为什么字符图标不行
- `▦` 在多数系统字体里缺字形,会 fallback 成方框或模糊图案
- `✓` 与 `▦` 笔画粗细、视觉重量完全不同,并排放不协调
- 13 号字时对齐依赖 font metrics,跨设备会飘

## 换成一对自绘 SVG
`ic_event_book.svg`:日历轮廓 + 两个日期格(填充)
`ic_todo_book.svg` :清单三条 + 右下角勾

两者**共用同一套笔触**:stroke-width 2、round linecap、24×24 viewBox,
渲染成 16vp 时视觉重量一致。颜色分别为灰 #666666 与绿 #00B34A,
沿用项目里已有的"待办用成功色"约定。

## 校验方式
本地无 SVG 渲染工具(无 cairosvg/rsvg/inkscape),改用脚本按坐标
+ 描边半宽计算每个元素的包围盒,确认全部落在 24×24 内不被裁切:
  日历:6 个元素 全部在界内
  待办:5 个元素 全部在界内
这是静态校验,实际观感仍需真机确认。

## 混合本(both)
显示待办图标(它确实能存待办),不再塞两个图标挤一行。
2026-11-02 22:35:25 +08:00
laoyang 09cb0f9664 feat(账号/待办): 日历本区分日历与待办类型,待办标签两级判定 #6
用户要求两点:
1. 添加/修改账号时,在颜色前面显示图标区分「日历本 / 待办本」
2. 没勾选任何待办本 → 待办标签不显示;选了待办本但没数据 → 也不显示

## 协议层:supported-calendar-component-set
CalDAV 用这个属性声明集合支持哪些组件:
    <C:supported-calendar-component-set>
      <C:comp name="VEVENT"/>   ← 纯日历本
      <C:comp name="VTODO"/>    ← 纯待办本
    </C:supported-calendar-component-set>

DavClient 改动:
- PROPFIND 请求加该属性(两处:propfindColors / propfindCalendars)
- 新增 `parseComps()` 归一成四类:event / todo / both / **unknown**
- `DavColorEntry` 与 `DavCalendarEntry` 都加 comps 字段

⭐ 为什么保留 unknown:
   很多服务器(含自研实现)压根不返回这个属性。若武断判成"不是待办本",
   用户原本能用的日历会被错误分类。unknown 一律按**日历**对待,保持兼容。

## 存储
DavAccount 增加 `calendarComps: string[]`,与 calendarHrefs 一一对应。
JSON 存储天然支持;旧数据读出来是空数组 → 全部 unknown → 视为日历。

## 界面
EditCalendarRow 在颜色圆点**前面**加类型图标,用户先看类型再看颜色:
  · 待办本 → ✓ 配成功色
  · 混合本 → ▦✓
  · 日历/unknown → ▦

## 待办标签两级判定
1. `hasTodoBook()`:被选中的日历本里存在 comps 为 todo/both 的
   —— 一个都没选 ⇒ 必然没待办,直接不显示
2. `todos.length > 0`
   —— 选了待办本但没数据,也不显示

比只看条数更准:没勾待办本时,同步可能拉到残留数据,
只看条数会误显示;第一级在"还没同步"时就能给出确定答案,不会闪一下。

## 待确认(需真机日志)
服务器是否真的返回 supported-calendar-component-set。
诊断日志已加:`日历本组件类型 <名称> → <类型>`。
若全是 unknown,说明服务器没声明 ⇒ 图标会退化成日历图标,
此时需要改用其它判据(如按本内是否含 VTODO 探测)。
2026-11-02 22:32:51 +08:00
laoyang 24e33ed5c9 fix(月视图): 翻月闪跳 —— 改为显式传参重建,不依赖 @State 即时可读 #6
用户反馈:11 月滑到 12 月时,先闪回 11 月,然后才跳到 12 月。

## 根因(两层)

### 第一层:异步时序
`reloadEvents()` 是 async(内部 await 读库),调用只是**启动**。
原代码:
    this.reloadEvents();                    // 只启动
    this.swiperController.changeIndex(1);   // 立刻回弹
回弹那一刻 monthPages 还是旧数据,中间页仍是上个月 ⇒ 闪回。

修:把 `rebuildPages()` 提到 changeIndex **之前**同步执行。

### 第二层:@State 赋值在当前 tick 内不可读
改完第一层后仍会闪。日志证据:
    onChange index=2 当前=2026-11
    同步重建完成 中间页=2026-11     ← 明明先赋了 displayMonth=12

说明 `this.displayMonth = v` 之后、同一个同步调用栈内读取它,
**不一定拿到新值**(@State 走可观察代理,写入与读取之间有调度)。

修:新增 `rebuildPagesFor(year, month)`,把月份**作为参数显式传入**,
内部不再读 `this.displayXxx`。这样无论 @State 何时生效都算得对。

## 附带修正:诊断日志本身有 bug
之前用 `monthPages[1][0].dateMs` 当作"中间页月份",
但月历第一格常属于**上个月**(12月的第一格是 11/30),
日志就写成"中间页=11月",让我误判成"月份没变",白查了一轮。
改用记录**传入参数**后真相立刻清楚:入参全程都是 2026-12,逻辑一直是对的。

## 真机验证
  11月 → 左滑 → 2026年12月,选中圆存在,无闪回。
日志确认:
  TRACE_PARAM 重建入参=2026-12(同步重建)
  onChange index=1 guard=true → 被 guard 正确拦下
  TRACE_PARAM 重建入参=2026-12(数据重建)
诊断日志已在提交前移除(grep 0 处)。

## 教训
1. @State 赋值后**不要在同一同步栈内立刻读它**做计算,要用局部变量/参数传递。
2. 写诊断日志时,验证"我关心的那个值"必须用可靠来源。
   用 `数组[0]` 代表整体属性是常见陷阱 —— 这次就因此浪费了一轮排查。
2026-11-02 22:25:00 +08:00
laoyang 6305b4a7f0 fix(月视图): 翻月后选中态丢失,整月没有一天被选中 #6
用户反馈:月视图滑动切换月份后会"跳一下",整月没有任何一天被选中。
期望:切到下个月后,**同样的日号**保持选中(如 11/2 → 12/2)。

## 根因
handleSwiperChange 只改了 displayYear/displayMonth,**完全没动 selectedDate**。
于是 selectedDate 还停在上个月,而新月份所有格子的 dateMs 都变了,
`cell.dateMs === this.selectedDate` 永远为 false ⇒ 整月无选中。

## 修法
翻月后把 selectedDate 迁到新月份的同一天;该月没有这天时钳到月底
(1/31 → 2/28,与 incForFreq 的钳制策略一致)。

## 顺带修掉日号漂移
单纯的"钳制"有个副作用:1/31 → 2/28 → 3/28 → 4/28 …
日号被永久改成 28,翻到有 31 天的月份也回不去了。

加 `preferredDay` 记录**用户主动选中的日号**(点日期时更新,翻月时不更新):
  偏好31号:1/31 → 2/28(钳制显示) → 3/31(回到偏好) → 4/30 → 5/31
"月末"的语义得以保留。

## 验证
脚本 18 项全通过(跨年、闰年 2/29、大月小月、连续翻月、往前往后)。
真机实测:
  11月2日 → 左滑 → 12月,选中圆存在(144×144)
  → 再左滑 → 2027年1月,圆内日期仍是"2"

## 教训
"只改显示状态、不改选中状态"是日历类视图的高频 bug。
翻页/翻月时,凡是与"当前选中项"绑定的视觉(高亮、背景),
都要检查选中项本身有没有跟着迁移 —— 否则 diff 时匹配不上就全空白。
2026-11-02 22:16:08 +08:00
laoyang d8ee2aa2be fix(周视图): 星期标签移到圆外,圆径回到 46vp 与月视图一致 #6
用户反馈:上一版圆太大,把"一"(星期标签)也圈进去了,不应包进来。

## 结构调整
原来(错):
    Stack{ 圆(54vp), Column{ 星期+日期+农历+点 } }
    —— 为了让圆包住"一"把圆径撑到 54vp,结果星期也被圈进去

现在(对):
    Column
      ├ Text(星期)        ← 在圆外
      └ Stack{ 圆(46vp), Column{ 日期+农历+点 } }

圆只包 日期(72)+农历(27)+点(11)+间隙 ≈ 116px,46vp(138px) 够用,
圆径回到与月视图完全一致。

## 过程说明(走了弯路)
这轮我先用脚本做局部替换,结果括号层级错乱,改了三版都没编译过:
  · 版1:找不到锚点(注释文字叠加,锚点不匹配)
  · 版2:补闭合时多写了一个 `}`,报 "Declaration or statement expected"
  · 版3:`Stack` 不支持 `justifyContent`,报 "Property 'justifyContent' does not
         exist on type 'StackAttribute'"

最后**整体重写整个 dayCellCircle 函数**(按行号范围一次性替换)才干净。

## 结论
多层嵌套(Column > Stack > Column)的 ArkUI 结构,靠脚本做局部增删很容易
括号错位。**这类改动应当整块替换而不是局部 patch**,一次写对更省时间。
2026-11-02 22:11:21 +08:00
laoyang ddaf41fee8 fix(周视图): 圆径按内容高度适配,修"上露星期、下漏圆点" #6
用户反馈:周视图圆的上沿包进了"一"(星期标签),下沿却把事件点漏在外面。

## 根因
周条加了星期标签后,内容实际高度约 147px:
  星期 10px字号 ≈ 28
  日期框 24vp    ≈ 72
  农历 9px字号   ≈ 27
  事件点 3.5vp   ≈ 11
  3 个间隙       ≈ 9
  合计           ≈ 147px

而圆固定 46vp = 138px ⇒ **内容比圆高 9px**。
Stack 的 alignContent 默认居中,导致内容上下对称溢出:
上面露出星期标签,下面漏掉事件点。

## 修法
圆径按内容取,不搞一刀切:
  · 周条(showWeekLabel=true,内容≈147px)→ 54vp(≈162px)
  · 月视图(无星期标签,内容≈118px)      → 46vp(≈138px)
同时 Stack 显式 alignContent(Alignment.Center),避免内容高于圆时对齐跑偏。

## 验证(真机 dump 实测)
  圆范围 y 495~664(169px)
  星期"一"  y 497~534  圆内 ✓
  日期"2"   y 537~612  圆内 ✓
  农历"廿四" y 615~648  圆内 ✓
  事件点×7  y 651~662  圆内 ✓

## 教训
Stack 里"固定尺寸的装饰元素"(圆)+ "不定高的内容"(文字块)组合时,
圆径必须 >= 内容高度,否则内容一定溢出。
这类问题看截图不容易发现(因为溢出部分常被裁掉或重叠),
**必须 dump 出两者的 bounds 做数值比对**才准。
2026-11-02 22:08:29 +08:00
laoyang 2cc3b1ba42 fix(周视图): 修复复用后颜色过浅、星期标签丢失 #6
用户反馈:周视图复用月视图后,日期/文字颜色太浅(灰),选中也不变白。

## 根因:weekStripDays 从未设置 inMonth
dayCellCircle 的颜色分支是:
    .fontColor(!cell.inMonth ? text_hint(灰) : ...)
但 weekStripDays() 只设了 dateMs/day/isToday/lunar/hasEvent,
**inMonth 一直是默认值 false** ⇒ 周条每天都被当成"非本月",全部走灰色分支。

修法:weekStripDays 里显式 `cell.inMonth = true`
(周条 7 天都是有效日期,没有"月外"概念)。

## 顺带补回丢失的星期标签
复用 dayCellCircle 时,原来周条自己那行 WEEK_LABELS(一/二/三…)被一并删掉了,
现在周条没有星期。
做法:dayCellCircle 加参数 `showWeekLabel`(默认 false),
周条传 true。月视图的星期在表头单独一行、不在格子里,所以默认不显示。

## 农历提亮
实测采样(JPEG,最暗 25%):
  周条农历 rgb(175~183)  ← text_hint(#4D000000)
  月历日期 rgb(27~54)    ← text_primary(#182431)
周条里农历紧挨日期数字,对比过强会显"糊"。
周条农历改用 text_secondary(约 rgb(120~150))、字号 8→9;
月视图农历保持 text_hint,不破坏已有观感。

## 教训
复用组件时必须核对**数据契约**:dayCellCircle 依赖 inMonth 决定颜色,
而 weekStripDays 从没提供这个字段。
"能编译、能显示"不等于"语义正确" —— 默认值 false 这种静默错误最难发现。
2026-11-02 22:06:15 +08:00
laoyang 22db069bee style(周视图): 选中/今天的圆放大到 42vp #6
用户反馈:周视图的圆不够大。

## 实测数据(设备 dump)
  周条每列 Stack = 133 × 192 px
  原来圆       = 113 × 113 px(36vp)

周条每列有 192px 高,圆只占 113px,显得空;而且比月视图的 138px 还小,
两个视图的"选中"视觉分量不一致。

## 关键约束:宽度是硬上限
列宽 133px ≈ 44vp。给 52vp(≈156px) 会**横向溢出被裁切**。
所以取 42vp(≈126px),留 7px 余量。

## 字号同步放大
大圆里字太小会失衡,一起调:
  日期 14 → 16、日期框 30 → 34
  农历 8 → 9、事件点 3.5 → 4

⚠️ 教训:改尺寸前先 dump 量格子实际宽高,别拍脑袋。
   这次如果直接按"感觉不够大"给 52,会被裁成方角,又得返工。
2026-11-02 21:59:30 +08:00
laoyang 4f19c9482d style(月/周视图): 今天也变大圆、周视图统一正圆、待办标签按需显示、今天按钮状态化 #6
用户提的四个细节,一次改完。

## 1. 今天的淡蓝底也要变大圆
原来 `isToday` 的淡蓝底贴在日期数字上(24×24 小圈),而选中态是 46vp 大圆。
同一天"选不选中"看起来像两种不同的东西。
现在两者尺寸统一:选中=实蓝圆,今天=淡蓝圆,都是 46vp。
日期数字自身的背景一律透明,避免"大圆里套小圈"。

## 2. 周视图选中态也变大圆
周条原来只有 30×30 的小圈,跟月视图 46vp 的大圆不一致。
同一个"选中"在两个视图里长得不一样,会让人以为是不同状态。
改用 Stack + 36vp 正方形 + 50% 圆角(周条比月历窄,36 够用且不压邻居)。

## 3. 没有待办就不显示「待办」标签
判定用 `this.todos.length > 0`。
todos 已按"可见日历本"过滤,所以这个判断等于"用户实际能看到几条待办"。
否则用户点进去一片空白,会以为功能坏了。

## 4. 「今天」按钮要能看出"在不在今天"
· 已在今天 → 透明底 + 蓝框蓝字(无需跳转)
· 不在今天 → 蓝底白字(可点,点了回今天)
新增 isOnToday() 判定:selectedDate === startOfDay(now)。

## 为什么圆一定要用 Stack
Column 是 layoutWeight(1) 撑满的**矩形**,圆角给再大也只是"圆角方块/胶囊"。
只有独立的正方形容器取 50% 圆角才是真圆。
这一点这轮改了两遍才对(第一版铺在 Column 上,得到 149×133 的方块)。
2026-11-02 21:56:25 +08:00
laoyang b41eaabc41 style(月视图): 选中态改为正圆蓝底 #6
用户要求:选中时的蓝色背景做成正圆,把日期、农历、事件点都包进去。

## 为什么第一版不圆
上一版把蓝底铺在最外层 Column 上。Column 是 layoutWeight(1) 撑满格子的
**矩形**(149×133),无论圆角给多大,只能是"圆角方块"或"胶囊",
变不成正圆。

## 这一版
新增 dayCellCircle,结构改为 Stack 叠放:
  ├─ 底层:选中时一个 46vp × 46vp 的正方形容器,borderRadius 50% → 真正的圆
  └─ 上层:原有内容(日期数字 / 农历 / 事件点),背景透明

尺寸 46vp ≈ 138px 的依据:
  · 内容实际高约 121px(日期框 75 + 农历 29 + 点 11 + 间隙)
  · 格子宽 149px
  ⇒ 138 能包住全部内容,又不超出格子宽度

选中时日期数字、农历、事件点统一转白(与日期同色),
因为浅红压在深蓝底上会发灰。

⚠️ 踩坑记录:改这个之前最好先量一下尺寸。我是从 dump 里读到
   "日期 599~674、农历 677~706、点 709~720" 才确定 46vp 这个值的,
   拍脑袋给尺寸很容易包不住或溢出格子。
2026-11-02 21:51:34 +08:00
laoyang e92daf5297 style(月视图): 农历恢复原色,下方加浅红小圆点 #6
用户反馈:农历整体染红不好看(数据本身是对的,确实每天都有日程),
要求改成"农历下面一个浅红色的点,不要太红、不要太刺眼"。

## 改动
- 农历文字恢复原色(灰 / 选中日仍是提示灰),不再染红
- 农历**下方**单独一行放一个 3.5vp 小圆点
- 颜色新增 `soft_red`,不用之前的 event_mark(#D93025)
  · 浅色 #F2938A —— 带粉的浅红,是"柔和提示"而非"警报"
  · 深色 #C97A72 —— 黑底上浅粉会发灰,用稍沉的暖红
- 无事件时圆点透明但仍占位,保证有/无事件行高一致,整月不跳

## ⚠️ 两个必须处理的布局约束
1. **圆点必须独立成行**,不能塞进农历那一行的 Row 里。
   上一版就错在这:农历带 layoutWeight(1) 撑满整行,圆点被挤到行尾,
   视觉上贴着下一个日期。独立成行后天然居中在格子下方。

2. **格子高度已被占满**(实测 175px 全用尽),直接加一行会被顶出格子。
   所以把 Column space 从 2 收到 1、日期框 26→24,换出约 4px 给圆点。

周视图同步改成同一套标记,避免"月历一个样、周条另一个样"。
2026-11-02 21:31:52 +08:00
laoyang 8ca1eb76e0 fix(月视图): 首屏不变色 —— 真正根因是外层 ForEach key 不变 #6
上一版只改内层 key 没修好,因为问题不在内层。

## 真正根因
外层 ForEach 的 key 是:
    (cells) => String(cells[0].dateMs)     // "这页第一天"
重建前后这个值**完全相同**(还是那个月的 1 号)。
⇒ ArkUI 判定"这一项没变" ⇒ **不重新执行 itemGenerator**
⇒ 里面的 chunkCells(cells) 不重算
⇒ 内层 ForEach 收到的还是**旧的 cells 数组**,旧数组里 hasEvent 仍是 false

内层 key 再怎么改都没用 —— 内层压根没被重新调用。

## 修法
新增 `@State monthDataVersion`,每次 rebuildPages 后自增,拼进外层 key:

    (cells) => `${cells[0].dateMs}_v${this.monthDataVersion}`

key 一变 → 外层重建 → 内层重新执行 → 颜色正确。

为什么不用"把整页 hasEvent 拼成 key":一页 42 格,key 会长很多,
且会参与 diff 比较,有性能代价。版本号是 O(1),语义也更清楚。

## 同时补了诊断日志
buildMonthCells 里输出:事件条数 / 有事件日期数 / 本页命中格数 / 当前版本号。
用来判断问题卡在哪一层:
  · "有事件的日期 0 天" → 数据层没算对
  · "命中 X 格" 但界面没变色 → 渲染/key 问题

## ⚠️ 教训(两层 ForEach 嵌套时)
这类 bug 我第一轮判断错了:只看到最内层 key 缺 hasEvent 就改了,
没意识到**外层 key 不变会让整棵子树被复用**,内层改什么都没用。
排查嵌套 ForEach 的刷新问题时,要从**最外层**往里查,
每一层的 key 都必须随数据变化。
2026-11-02 17:12:55 +08:00
laoyang d49b041a2c fix(月视图): 首屏农历不变红,切视图才生效 #6
用户反馈:刚打开应用时,月视图里有事件的日期农历没变红;
切到周视图再切回月视图,才变红。

## 根因:ForEach 的 key 不完整
月视图的 key 是:
    (cell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`
**没有包含 hasEvent。**

首屏时序:
  1. aboutToAppear → rebuildPages(),此时 events 为空,hasEvent 全 false
  2. reloadEvents() 拉回数据 → rebuildPages() 再次重建,hasEvent 变 true
  3. 但每个格子的 key 完全没变(dateMs 和选中态都没变)
  ⇒ ArkUI 判定"这些项没有变化",**复用旧组件、跳过重绘**,颜色停在旧值

切视图能恢复,是因为切回来时整棵子树被重新创建,绕过了 diff。

## 修法
把影响渲染的**全部**状态纳入 key:hasEvent / isToday / 是否选中。
key 变了 → ArkUI 视为新项 → 重建该格子 → 颜色正确。

## 顺带加固
中间层"每周"的 key 原来是 `String(idx)`(0..5),跨月翻页时序号永远重复,
会让 ArkUI 误判为同一周而复用旧节点。改为用该周首日的 dateMs 做 key。

## 为什么不改成 @Observed / @ObjectLink
monthPages 是二维数组(MonthCell[][]),buildMonthCells 返回新数组、
chunkCells 又一次切片,改成可观察对象要动整条构建链路,
牵涉面大且容易引入其它 diff 问题。用 key 是最小、最稳的改法。

⚠️ 通用教训:ArkUI 的 ForEach 用自定义 key 时,**key 必须覆盖所有影响渲染的
   字段**。漏一个字段,该字段更新后界面就不刷新 —— 而且这类 bug 只在
   "同一项的数据变了、key 没变"时出现,首次渲染永远是正常的,很容易漏测。
2026-11-02 17:10:38 +08:00
laoyang fa3dbc6157 style(月视图): 有事的日期标记色改为红色 #6
用户要求先看红色效果,蓝色太不显眼。

## 新增 event_mark 语义色
不直接复用现有的 `error`(#E02020):
  error 是语义色,到处被"失败/危险"复用。把日程标记挂上去,
  以后想调错误提示的红就会连带改掉日历标记,两件事被耦合。
  所以单独加一个 event_mark。

色值(农历只有 8px,小字对对比度要求高):
  · 浅色 #D93025 —— 比 #E02020 更沉,压在白底/浅灰底上更清晰,
    也不像"出错"那么刺眼
  · 深色 #FF6B60 —— 黑底上要亮一点才够,纯红会显脏

## 改动范围(3 处)
  · 月视图(非宽屏)农历文字
  · 月视图(宽屏)补位小圆点
  · 周视图农历文字

选中日仍用 button_text(白):那天日期数字坐在深红底上,
农历必须白色,否则红字压深底看不清。
2026-11-02 17:06:21 +08:00
laoyang ff62cf7f00 fix(月视图): 事件标记改为农历文字变色,修圆点位置错乱 #6
用户反馈:圆点位置很怪,离农历很远、落在两个日期中间。

根因:圆点塞在农历行的 Row 里,而农历用了 layoutWeight(1) 撑满整行,
      圆点被挤到格子最右侧;格子宽 133px 远大于文字宽,
      视觉上就成了"贴着下一个日期"。

采纳用户方案:把标记合并到农历文字本身
  · 有事件 → 农历字用主题色并加粗
  · 无事件 → 保持原来的 hint 灰

好处:不再额外占空间、位置天然跟着文字走、也不会像满屏圆点那样显乱
("点太多显得乱"正是上一版被砍掉的原因)。

对比度:选中日的日期数字坐在深色底上,农历若仍用主题蓝会看不清,
所以选中日改用 button_text(白),与原圆点方案的策略一致。

周视图同步统一为农历变色:两种视图用两套标记会让人以为是不同含义。
宽屏(wideLayout)农历被省略,保留一个小圆点补位(此时格子窄,圆点居中,无位置问题)。
2026-11-02 16:58:56 +08:00
laoyang 788171d1b2 feat(待办): 支持循环任务(标准 CalDAV / VTODO RRULE)#5
只按标准 CalDAV 实现,不依赖家书的自定义机制。
群晖等标准服务器:RRULE 写在 VTODO 上,由客户端负责展开。

## 核心语义决策
循环待办「勾完成」= 这一轮做完了,推进到下一周期,而不是结束。
若照搬非循环逻辑(置 COMPLETED 就完结),用户每勾一次循环任务它就永久结束,
循环规则等于白设。所以:
  · 非循环 → 置 COMPLETED,永久结束
  · 循环   → DUE 推到下一周期 + 状态复位 NEEDS-ACTION

## 关键修复:DTSTART 与 DUE 必须分开
原 parseTodos 把 DUE 和 DTSTART 都写进 startTime,后出现的覆盖先出现的。
但循环下两者语义完全不同:
  · DTSTART = RRULE 的重复基准
  · DUE     = 这一条的截止时间
混在一起会导致重复基准被截止时间覆盖,算出来的发生日全错。
现在拆成独立字段,数据库补 dtstart 列(rrule 列已有)。

## 顺带修掉两个既有 bug(测试连带查出)
rewriteTodoCompletion 与 rewriteTodoFields 里,插入阶段的块序号
vtodoIdx 在遇到 END:VTODO 时被重置成 -1 ⇒ 每块都从 0 重新数,
多 VTODO 文件里永远匹配不到目标块,字段加不上(静默失败,
表现为「点了没反应」)。三处一并修正,vtodoIdx 改为全局块序号。

## 实现
- IcsUtil.incForFreq:按 FREQ 推周期,处理月末溢出
  (1/31 + 1月 → 2/28 钳制,不跳到 3 月;2/29 + 1年 → 2/28)
- IcsUtil.freqOfRrule / rewriteTodoRecurrence
- TodoService.setRecurrence / completeRecurringRound
- 写回时同时给 DTSTART + DUE:RFC 5545 里 VTODO 的 DTSTART 是 OPTIONAL,
  但缺它时各客户端对 RRULE 基准理解不一致,显式给能消除歧义
- 待办行显示「↻ 每周」标记(否则循环任务和普通待办长得一样,
  用户勾完发现「又冒出新的」会困惑)
- 编辑弹框加 每天/每周/每月/每年 选择,再点一次取消

## 验证
scripts/test-ics-todo-recurrence.js:24 项全通过
(月末钳制、闰年、字段保留、取消循环、多 VTODO 定位、结构完整性)。
编译 BUILD SUCCESSFUL。

## 说明
服务器若不支持 RRULE(如群晖 Calendar 规格里未提及循环任务),
则循环只在客户端可见,同步到其它客户端会退化为普通任务。
2026-11-02 16:53:30 +08:00
laoyang 9066a12bbc feat(待办): 按日历本分组 + 编辑/删除 + 修整行点击误完成 + 周视图圆点 #5 #6
## 1. 修 bug:整行点击都会勾选完成
上一版把 onClick 挂在最外层 Row 上,点标题/点截止时间/点空白都会改状态。
改为:
  · 只有左侧圆圈 → 勾选/取消完成
  · 其余内容区 → 打开编辑弹框(不改变完成状态)

## 2. 待办按日历本分组
用户:待办应按日历本分类,未完成里也能按家庭/生活分开,要有区分度。
未完成/已完成 两大段内部,再按日历本聚合:
  · 分组标题前带该本颜色圆点(这是区分度的来源)
  · 组标题显示 本名(数量)
  · 组内按截止时间升序,无截止时间排最后
  · 组之间按组内最早截止时间排序(最紧急的本在前)
CalendarDataService.loadTodos 补 d.calName(原来只带 calKey,分组标题没名字)。

## 3. 待办编辑弹框
点内容区弹出,可改:标题 / 备注 / 截止时间(含日期+时间选择器,可清除),
另有删除(二次确认)。只读本禁用编辑并提示。

⚠️ 用**整页覆盖层**而不是 bindSheet:ArkUI 同一组件只能挂一个 bindSheet,
Index 已用 detailShow 挂了日程详情,再挂会被覆盖(EventEditPage 顶部记过此坑)。

写入仍走「GET 原文 → 改字段 → PUT」,只动 SUMMARY/DESCRIPTION/DUE,
**不动 STATUS/COMPLETED**(改内容不该重置完成状态),
并保留 PRIORITY/CATEGORIES/X-* 等本地没存的字段。

## 4. 周视图圆点
weekStripDays 原来没算 hasEvent;补上与月视图**共用同一份**事件日期集合
(buildEventDaySet),口径完全一致。
颜色:用主题色 brand(选中日换白),**不取日历本的颜色** ——
圆点只表示这天有安排,若取本色会误导成只有那个本有日程。

## 验证
scripts/test-ics-todo-fields.js:19 项全通过
(保留 PRIORITY/CATEGORIES/X-扩展、特殊字符转义、多 VTODO 只改目标、
缺字段补齐位置、结构完整性)。编译 BUILD SUCCESSFUL。

## 未做
子任务(父子层级 / RELATED-TO):需先确认服务器是否支持,另开工单。
2026-11-02 15:47:03 +08:00
laoyang 98c4b6a96f feat: 待办可勾选完成 + 月视图事件圆点 #5 #6
== #6 月视图事件标记(每日期最多一个圆点)==
应用市场反馈希望有事件的日期带标记。此前该功能存在但被移除,
原因是**按日历本逐个渲染**:3 个本 3 个点,15 个本满屏都是点。

- MonthCell 只加**一个布尔** hasEvent,不存每本一个的集合
- 用 buildEventDaySet() 一次遍历把有事件的日期收进 Set,
  渲染时 O(1) 查(避免 42 格 × N 事件的 O(n²),本机近 3000 条数据会卡)
- 判定口径与 eventsOfDate 一致(区间相交)→ 跨天事件覆盖的每一天都有点
- 待办不计入(无时间点语义,混进来点会失去意义)
- 视觉用主题色 5vp 小点而非纯红:42 格里 30 个红点会吵,
  正是用户嫌乱的同类问题;选中日深底下换白色保证对比度
- 无事件时用等高透明占位,避免行高跳动

== #5 待办勾选完成 ==
先回答关键问题:**系统未开放待办 API**。
本机 SDK 检索 @ohos.calendarManager 全文,todo/VTODO/task 零命中,
Calendar/Event 只处理 VEVENT ⇒ 第三方应用无法读写系统「待办」页签。
故只能在本应用内实现,并已在待办页注明不会出现在系统待办中。

现状是**只读**(点击提示请在源端修改),本次补上写回:

- TodoService.setCompleted():GET 原文 → 改状态字段 → PUT 回写
- EventDb: queryById / setTodoCompleted
- DavClient.getRawStrict():失败**抛错**(原 getRaw 失败返回 HTTP 4xx
  字符串,若当 ICS 内容 PUT 回去会把待办覆盖成一句错误信息)
- IcsUtil.rewriteTodoCompletion()

⚠️ 两个关键设计:
1. **不重建 VTODO,只在原文上改字段**
   VTODO 有 SUMMARY/DESCRIPTION/DUE/PRIORITY/CATEGORIES/X-* 等字段,
   本地库只存了少数几个。重建后 PUT 会把没存的字段全部抹掉(丢数据)。
2. **两遍处理,与字段顺序无关**
   RFC 5545 不规定字段顺序。单遍实现若 STATUS 出现在 UID 之前,
   matched 仍为 false → 状态改不到 → 用户点完成没反应。
   改为第 1 遍定位目标 VTODO 块、第 2 遍在块内改写。
   插入补字段时按**块序号**定位而非行号(改动会删掉旧 COMPLETED 行,
   行号不再对齐,会让字段落到 END:VCALENDAR 之后,部分服务端直接 400)。

同步维护三个字段(按 RFC 5545,缺一个各客户端显示就不一致):
STATUS / COMPLETED / PERCENT-COMPLETE。

UI:顶部新增「待办」标签(月/周/列表/待办),未完成在前、已完成在后,
点击勾选即写回;写回失败本地状态不变(避免界面与服务器不一致)。

验证:scripts/test-ics-todo.js 覆盖 26 项(状态在 UID 前/后、取消完成、
多 VTODO 只改目标、缺 STATUS 补字段、折行、结构完整性),全部通过。
编译 BUILD SUCCESSFUL。
2026-11-02 14:48:43 +08:00
laoyang 1cc35f147d fix(新建日程): 非今天默认 09:00,今天仍用当前时刻 #4
应用市场反馈:「新建非今天的日程时,默认时间固定在当天 9 点更好」。

根因:原来日期取所选日,但**时分固定取 now.getHours()**,
与所选日期无关。所以下午三点建明天的日程会得到「明天 15:00」。

改为按日期分支:
  · 今天   → 当前时刻(今天 09:00 往往已过,「现在就记一笔」才是常态)
  · 非今天 → 09:00(现在几点对别的日子没有参考价值)

顺带统一:toggleAllDay 关闭全天时原来硬编码 9 点,改为引用
DEFAULT_START_HOUR 常量,两处口径一致。
2026-11-02 14:48:28 +08:00
laoyang 199b6c177f feat(清理): 卸载残留治理 —— 显式清除入口 + 孤儿兜底加强 #3
鸿蒙没有「应用即将被卸载」的回调(网传的 preUninstallHook /
uninstallDataPolicy 在本机 SDK 中均不存在),而系统日历数据属于
沙箱外,卸载时不会被系统清理 ⇒ 用户卸载后会永久残留镜像账户
(还会继续提醒)。本次提供显式入口 + 状态兜底。

1. AccountStore.isEmptyVaultConfirmed()
   区分「账号库真的空」与「loadAll 吞异常返回空」。
   依据:loadAll 内部把所有异常 catch 后返回 [],调用方无法从返回值
   辨别,而孤儿清理一旦误判就会把用户真实在用的镜像全删掉。

2. SystemCalendarMirror.purgeOrphanAccountMirrors()
   原来 accounts.length === 0 一律跳过(安全阀),导致卸载重装后
   旧镜像永远清不掉。改为三态:
     ① 有账号           → 只清 accId 不匹配的(原逻辑)
     ② 空 + 确认空白     → 全清(卸载/重置后的残留)
     ③ 空 + 拿不准       → 仍跳过(读异常,不能误删)
   全清模式下不再因「解析不出 accId」跳过脏账户,否则清不干净。

3. SettingsPage 新增独立卡片「清除系统日历数据」
   - 「彻底清除系统日历镜像数据」:二次确认后 removeAll,
     并关闭镜像开关(否则下一轮同步立刻重建,用户以为没清掉),
     镜像本消失后把提醒交回 App(避免漏提醒)
   - 零结果提示区分「没权限」与「本来就没有」,用户要的是确认清干净
   - 卡片放在镜像开关之外:关着镜像时也能清残留(正是典型场景)
   - 原「清理残留镜像账户(诊断)」一并从镜像卡片内移入本卡

只删系统日历镜像;CalDAV 账号与服务器日程完全不动。
2026-10-30 13:34:09 +08:00
laoyang 63b1a75b03 docs: README 更新至 0.1.0,补齐 0.0.7~0.1.0 缺失内容
README 的版本信息此前停在 0.0.6(versionCode 600),已滞后 4 个版本。

- 版本行:0.0.6/600 -> 0.1.0/10000
- 补「地点搜索补全」「一键导航」「必填项校验强提醒」(0.1.0)
- 新增「CalDAV 地址自动发现」小节(0.0.7:.well-known 发现 + 人话错误提示)
- 隐私段落:明确「不申请定位权限」,补充华为地图服务说明与
  AES-256-GCM / HUKS 凭据加密细节
2026-10-28 23:18:48 +08:00
laoyang d993dd3d4b 移除定位权限:地理编码不需要 LOCATION(AGC 隐私标签审核)
AGC 以「未如实填写隐私标签」退回本次提审。核查后发现根因是
申请了两个用不到的敏感权限,与隐私标签无法自洽。

依据(本机 SDK API 26,targetSdk 26.0.0):
- @ohos.geoLocationManager.d.ts 中 getCurrentLocation / getLastLocation /
  on('locationChange') 均有 @permission APPROXIMATELY_LOCATION;
- 而 getAddressesFromLocationName(地址→坐标)与 getAddressesFromLocation
  (坐标→地址)**没有 @permission**,只有 @syscap ...Location.Geocoder。
- 本应用从不调用 getCurrentLocation,本就不读设备实时位置,只在「一键导航」
  时把用户填写的地址换算为坐标。

改动:
- module.json5:删除 LOCATION、APPROXIMATELY_LOCATION(现仅 5 个权限)
- Index.ets:删除 ensureLocationPermission(),openInAmap() 直接调
  geocodeAddress();同步清掉随之无用的 abilityAccessCtrl / bundleManager 导入
- string.json:删除 perm_location、perm_location_approx
- AppSettings.ets:更新权限一致性注释,警示后续勿再照旧文档写错政策

验证:assembleHap BUILD SUCCESSFUL;解包 hap 与 .app 内 module.json 均只剩
5 个权限、LOCATION 零命中;真机 hdc install -r 成功,aa start 无崩溃。

注意:华为旧文档 @ohos.geolocation(API 9 已废弃)仍写"需要权限 LOCATION",
那是老接口,勿据此判断——应以本机 .d.ts 为准。
2026-10-28 23:14:08 +08:00
laoyang b7bc4b5403 chore: 版本号 0.0.9 -> 0.1.0(versionCode 10000)
0.0.9 已上架,开启 0.1.0 开发周期。
versionCode 按 major*1e6 + minor*1e4 + patch*100 计 = 10000。

同步更新官网 deploy/site:index.html / sf.html / uh.html 的版本标识,
_404.tpl.html 改后重跑 _gen404.py 重新生成 404.html。
2026-10-21 14:12:52 +08:00
laoyang 96335728f5 feat(编辑页): 必填项校验失败改为强提醒(Toast + 字段标红 + 滚回定位)
原来校验失败只写 statusMsg,而它渲染在表单最底部("删除日程"按钮上方)。
用户在顶部点"保存"时那行红字远在屏幕之外,观感就是"点了没反应"。

新增 showFormError(msg, field) 作为唯一错误出口,同时做三件事:
- Toast:系统级浮层,用 bottom:'70%' 抬到屏幕偏上方,不受滚动位置影响,必定可见;
- 字段标红:标题输入框 / 时间卡片 / 日历本区域加 error 色边框,并在紧邻位置给出红字说明;
- 滚回出错位置:scrollEdge(Top) + 对标题 requestFocus('titleInput')。

配套:
- 标题 placeholder 改为「标题(必填)」,静态说明必填属性;
- 用户开始输入标题 / 选中日历本后自动撤掉红框与状态行,不会"改了还一直红着";
- 日历本未选也走 showFormError(此前只写状态行);
- 底部状态行仅在 errorField==='' 时显示,避免与就地提示重复;
- 保存异常分支补 Toast:该分支紧接着 router.back(),状态行来不及被看到。
2026-10-21 10:17:46 +08:00
laoyang a32c049199 fix(地点): 候选改为 bindPopup 浮层,根治输入框跳动与选完复现
旧方案把候选做成内联列表,虽放在输入框上方,但:
  1. 候选高度变化(搜索中 / 出结果 / 条数变化)会改变表单高度,输入框随之反复移动,
     再靠 scrollBy 事后纠正,表现为输入框来回跳好几次;
  2. 选中候选后 applyPlace 程序回写 text 会触发 TextInput.onChange,
     把 placePicked 重置为 false 并重新发起搜索,导致候选框消失后又冒出来一次。

改为:
- 候选用 .bindPopup($$placeOpen, { placement: Top }) 挂在输入框上,
  不参与布局,出现与消失完全不移动输入框;
- 移除全部 scrollBy 对齐逻辑(keyboardPadVp / ensurePlaceVisible / 键盘监听),
  键盘避让交由 KeyboardAvoidMode.RESIZE 的默认光标可见行为;
- 用 lastAppliedText 精确识别「程序回写 text 造成的 onChange」,不再发搜索;
- runSuggest 期间浮层持续展开(搜索中 → 结果),避免收起再弹造成闪烁;
- 抽出 closePlacePopup() 作为统一收起入口。
2026-10-21 09:03:31 +08:00
laoyang 6ab7bcfb76 fix(地点): 修掉 px/vp 单位混用的对齐 bug,输入框稳定钉在键盘上方,候选向上生长
- ensurePlaceVisible: getRectangleById 返回 px,scrollBy 收 vp,必须换算(上一版输入框被推飞的真因)
- 去掉 keyboardPadVp 动态底部留白:数学上不需要,只会在手动滚到底时露出大块空白
- 聚焦期间候选条数变化(加载中/有结果/无结果)每次都重新钉一次,避免输入框上下跳
- 提交请求前先收起旧候选,统一由一次对齐收口
- 新增 onBlur:失焦即收起候选,不留残影
2026-10-21 08:52:13 +08:00
laoyang 8e0c08ccfb 修复:地点候选列表被软键盘挡住
现象:地点输入框在表单第 7 位(标题/全天/时间/重复/提醒/日历本之后),
键盘弹起后候选列表正好落在键盘后面,看不到也点不到。

根因:ArkUI 默认 KeyboardAvoidMode.OFFSET 只保证"光标可见",
它不会为输入框下方新插入的候选列表预留空间;页面也不主动滚动。

修法(三层):
1) setKeyboardAvoidMode(RESIZE):把页面高度压到键盘上沿,
   Scroll 可视区随之变矮(OFFSET 是整页上移,会把表单底部顶出屏幕)。
2) 主动滚动 ensurePlaceVisible():用 getComponentUtils().getRectangleById()
   实测「输入框行顶边 + 行高 + 间距 + 候选列表高度」得到整块底边,
   与「窗口高度 - 键盘高度」比较,差多少就 scrollBy 多少。
   不直接 scrollEdge(Bottom):那会把地点字段顶出屏幕顶部。
3) 兜底:avoidAreaChange 可能还没回调(先弹键盘、后展开候选),
   此时按窗口高度 42% 保守估算键盘高度 —— 多滚一点只是页面上移,无害。

顺带:
- 候选列表 Column → List + constraintSize(maxHeight: 216),
  6 条候选内部可滚,不再占掉大半个屏幕;24px 行高分隔线改用 List.divider。
- 上限 8 条 → 6 条。

⚠️ 踩到的 API 坑(本 SDK):
- KeyboardAvoidMode 必须从 @kit.ArkUI 导入(不是全局);
- UIAbilityContext **没有** window 属性,要用 window.getLastWindow(ctx);
- getRectangleById 在 ui.getComponentUtils() 上,**不在** UIContext 上;
- avoidAreaChange 回调参数是 AvoidAreaOptions(含 type 字段),
  不是老的 AvoidArea,需先判 type === TYPE_KEYBOARD。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 真机。
2026-10-21 08:26:16 +08:00
laoyang 8be539d8a3 新增:地点输入补全(鸿蒙 Map Kit / Site Kit)
需求:新建/修改日程时,地点输入几个字即可用地图 API 补全详细地址。

选型:鸿蒙系统内置 Map Kit,**不引入高德**。理由(详见
Caldav开发/地点输入补全-地图API选型.md):
1) 配额:高德「输入提示」个人认证仅 100 次/日、企业 1000 次/日(官方 FAQ),
   是全 App 共用额度。按 350ms 防抖、单次输入 4~8 请求算,个人额度约等于
   15~25 个用户输入一次地点;企业额度一天也烧完。华为侧「POI 搜索/地理编码
   当前不收费」,QPS 3000(官方论坛口径,HarmonyOS 5.0+),量级差 6 个数量级。
2) 合规:高德 Web 服务 Key 必须配 IP 白名单、不得出现在客户端;App 直连必然
   被反编译提取 → 额度被盗刷、账单算自己、Key 可能被冻结。唯一安全做法是自建
   服务端代理,会把「App 本体独立好用」的产品定位破坏掉。
3) 商用:高德个人认证仅限"研发/测试/个人兴趣",上架商店属企业认证场景。
4) Site Kit 全部接口**无需任何权限**(d.ts 无 @permission),完全不需要
   申请 LOCATION,契合"权限只在用户主动点该功能时申请"的硬约束。

实现:
- 新增 common/PlaceSuggest.ets(纯服务类):
  query()=queryAutoComplete、search()=searchByText(补全失败兜底)、
  pickOnMap()=sceneMap.chooseLocation(系统地图选点)、isUnavailable() 供界面降级。
  · 请求序号 seq 丢弃过期结果(边打字边搜时后发先至);
  · 异常一律返回空数组、绝不抛给调用方 —— 补全坏了日程照样能存;
  · 1002600004/1002600003 判定"能力未开通"→ 置 unavailable,只提示一次,
    之后不再反复打网络。
- EventEditPage:地点行改为 TextInput + 「地图」按钮 + 内联候选列表。
  · 输入停止 350ms 防抖;回车立即搜一次;
  · 候选**内联展开**,不新增 bindSheet(硬约束:单页只能一个 bindSheet,
    本页已用于时间/重复选择);
  · 选中后写入完整地址(formatAddress) 并收起,placePicked 防止再次自动弹出;
  · aboutToDisappear 清防抖定时器。
- LOCATION 的 ICS 转义无需改动:IcsUtil.escape() 已处理 , ; \ 换行,
  地图地址里的逗号不会破坏格式。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 真机。
⚠️ 功能待 AGC 开通「地图服务」+ 重签 Profile 后才生效(否则抛 1002600004);
   未开通时界面显示「地图服务不可用,可直接手动输入地点」,不影响其他功能。
2026-10-21 07:59:21 +08:00
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
laoyang ed26da8dc4 0.0.7:CalDAV 地址自动发现 + 人话错误提示 + 只读公开日历
背景:用户反馈(Nextcloud/内网)——DAVx5 用 CalDAV 地址能完整读取,换本应用
读不出日历;用"分享链接"绑定时提示服务器返回 405。

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

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

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

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

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

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

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

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

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

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

注:applyEfficiencyResources 在当前 SDK 不可用,未采用。
2026-10-10 12:49:11 +08:00
54 changed files with 10947 additions and 761 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": {
"bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq",
"versionCode": 600,
"versionName": "0.0.6",
"versionCode": 10201,
"versionName": "0.2.1",
"buildVersion": "1",
"icon": "$media:layered_image",
"label": "$string:app_name"
+319 -19
View File
@@ -2,12 +2,41 @@
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
- 应用市场送审版本:**0.0.2** | 仓库当前:**0.0.3**(versionCode 300)
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
- 应用市场送审版本:**0.2.0** | 仓库当前:**0.2.0**(versionCode 10200)
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://feedlite.yangyq.net/?sw=synccalendar>
- 开源协议:Apache License 2.0
---
## 📝 最近更新
### v0.2.0(2026-11)
**待办(VTODO)从只读升级为完整双向同步**
- 新增 `TodoService`:新建 / 编辑 / 打勾完成 / 删除待办,全部推送回服务器
- 打过勾会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT
- 修改前先 GET 原始 `.ics`,原地改写而不是整份重建 —— 保留服务器上本应用不认识的属性
- 日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
**同步核心修复(3 项高危)**
- **删除 404**:`remotePath` 改为保存服务器真实资源文件名。此前一直用
`encodeURIComponent(uid) + '.ics'` 去猜,遇到「文件名 ≠ UID」的服务器
(如用 `preg_replace` 改名的家书)会 DELETE 到不存在的路径 → 404 → 服务器上删不掉。
修复覆盖三条取值链路:REPORT 补拉、etag 未变的复用路径、以及 unchanged 分支的存量回填。
- **防复活**:`applyRemote` 增加 `dirtyKeyed` / `dirtyDeletedUids` 白名单。
此前只把 dirty 行排除出索引,远端数据查不到就走"新增"分支又插一行,
导致同一事件出现两条、或"删了又回来"。
- **404/410 物理清库**:删除请求返回 404/410 时视为服务器上已不存在,
继续清理本地行而不是直接 return —— 否则用户会看到"怎么删都删不掉"的日程。
**反馈渠道迁移**
- 从自建 PHP 论坛迁至多软件共用的 **FeedLite**,按软件分栏管理
### v0.1.0(首个送审版本)
- CalDAV 全链路同步、月/周/列表视图、重复日程展开、系统日历镜像、桌面卡片、后台上层同步
---
## ✨ 功能特性
### CalDAV 同步
@@ -15,7 +44,10 @@
- 完整协议链路:**PROPFIND** 发现日历本 → **REPORT** 全量拉取 → **ETag** 增量比对 → **PUT / DELETE** 双向推送
- 一个账号可挂载多个日历本,按需勾选同步范围;支持在线编辑账号、重选日历本、删除账号
- 解析 **VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID**,重复日程的**单次覆盖实例**也能正确还原
- 支持 VTODO 待办同步(只读展示,不参与推送)
- 完整 **VTODO 待办**支持:新建 / 编辑 / 打勾完成 / 删除,均可双向推送回服务器
- 待办与日程分列:日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
- 打勾完成会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT 回服务器
- 修改时先 GET 原始 `.ics` 再原地改写,保留服务器上本应用不认识的属性
- 「重建日程数据」可强制整表重拉,用于修复提醒丢失等本地数据问题
### 日历视图与交互
@@ -32,10 +64,19 @@
### 日程编辑与提醒
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
- **地点搜索补全**:在「地点」栏输入关键词即可列出匹配地点(华为 Map Kit / Site Kit),点一下填入完整地址;也可点「地图」进入系统地图界面搜索、拖动图钉选点。**不申请任何定位权限**,手动输入同样可用
- **一键导航**:日程详情中点击「导航 ›」,用系统地理编码把地点地址换算为坐标后唤起高德地图;未安装则回退网页版。**不申请定位权限**(`getAddressesFromLocationName` 无需该权限),不会读取设备实时位置
- **必填项校验强提醒**:未填标题等必填项时,以 Toast + 输入框标红 + 自动滚回定位三管齐下提示,不会出现"提示在屏幕外看不到"的情况
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
### CalDAV 地址自动发现
- 只填服务器域名也能用:按标准 **`/.well-known/caldav`** 自动发现端点,并在常见路径(Nextcloud、群晖等)中依次尝试
- 发现时只认 `resourcetype` 含 `calendar` 的集合,避免把 DAV 根或分享链接误当成日历
- 失败时给出**人话错误提示**(如"正确形式通常为 `https://域名/remote.php/dav/calendars/用户名/`"),而不是抛原始状态码
### 系统日历镜像(可选增强,默认关闭)
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
@@ -55,11 +96,15 @@
### 后台同步与数据备份
- **自动同步间隔**:App 在前台时按设定间隔自动同步
- **后台持续同步**:申请后台长时任务(`dataTransfer`),退到后台也按间隔继续同步,通知栏显示常驻通知
- **系统级定时同步**:通过 WorkScheduler 扩展能力在条件满足时触发一轮同步
- **前台自动同步**:App 在前台时按设定间隔自动同步(可设 1 / 5 / 15 / 30 分钟)
- **后台自动同步**:开启后由系统定时唤起同步,退到后台也会继续(受系统省电策略限制,**最短约 2 小时一次**)
- **回到前台 / 网络恢复补同步**:切回应用、或从断网恢复到有网时,**立即补同步一次**,保证你打开日历时数据总是最新的
- **应用数据备份/恢复**:接入 Backup 扩展能力,换机时可恢复账号与本地数据
> 说明:HarmonyOS 出于省电考虑,**不允许普通应用在后台常驻并高频轮询**
> (长时任务 `dataTransfer` 超过 10 分钟无进度即被系统取消,延迟任务存在小时级下限)。
> 本应用遵循系统机制,采用「前台按间隔同步 + 后台定时唤起 + 回前台/网络恢复即时补同步」的分层组合。
### 界面与适配
- **深色模式**:全程使用资源引用(`$r('app.color.*')`),深浅两套色板一一对应,跟随系统自动切换
@@ -69,7 +114,27 @@
### 设置项一览
系统日历处理方式(仅显示 / 备份到 CalDAV)· 镜像到系统日历 · 选择要镜像的日历本 · 回写开关 · 默认视图 · 日程显示方式 · 自动同步间隔 · 后台持续同步 · 通知权限 · 混合显示系统日历 · 日历本只读 / 静音 · 重建日程数据 · 重置账号库 · 软件特性 · 使用帮助 · 隐私政策与用户协议
**系统日历**
- 镜像到系统日历(含「选择要镜像的日历本」)
- 把系统日历的改动回写
- 系统日历中的显示名称(三种命名格式可选)
- 混合显示系统日历(关闭后只显示 DAV 账号的日程)
- 清除系统日历数据(卸载前清理镜像数据,防止残留孤儿账户)
**通用**
- 默认视图(月 / 周 / 列表)
- 日程显示方式(竖向时间轴 / 列表)
- 自动同步间隔(1 / 5 / 15 / 30 分钟)
- 后台自动同步
- 通知权限
**数据**
- 重建日程数据(强制整表重拉)
- 重置账号库
- 日历本只读 / 静音(逐本设置)
**关于**
- 软件特性 · 使用帮助 · 问题反馈 · 隐私政策与用户协议
---
@@ -77,10 +142,12 @@
我们遵循"**数据自持、最小必要**"原则:
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机)
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机,且以 **AES-256-GCM** 加密存储,密钥由系统 HUKS/TEE 保管、不可导出)
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒、点击地点导航)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
- **不申请定位权限**:本应用从不读取设备实时位置。「一键导航」所需的坐标由系统地理编码服务按**你填写的地址文本**换算,故 `module.json5` 中不含 `LOCATION` / `APPROXIMATELY_LOCATION`
- 地点关键词检索由 HarmonyOS 系统能力**华为地图服务**(Map Kit / Site Kit)提供,仅用于检索,不用于广告、画像或统计分析
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
---
@@ -89,10 +156,88 @@
- **架构**:`SyncEngine` 同步引擎 + `DavClient`(HTTP / WebDAV)+ `EventDb`(relationalStore 本地库)+ `CalendarDataService`(多源合并展示)
- **镜像**:`SystemCalendarMirror`(出站写入)+ `MirrorInbound`(入站回写)+ `MirrorSnapshot`(变更快照)
- **后台调度**:`BackgroundSyncRunner`(后台同步唯一执行体)+ `BackgroundSyncService`(延迟任务/短时任务)+ `NetworkSyncWatcher`(网络恢复补同步)
- **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
- **农历**:`LunarUtil`,1901–2099 压缩表推算
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER / KEEP_BACKGROUND_RUNNING / LOCATION(仅地点导航时)
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER
- ⚠️ **LOCATION 已于 0.1.0 移除**:`geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,地理编码不需要定位权限;应用也从不调用 `getCurrentLocation`。
隐私政策/隐私标签里**不得**再声称读取位置。
## 🔐 权限清单与用途
| 权限 | 类型 | 用途 | 何时申请 |
|---|---|---|---|
| `ohos.permission.INTERNET` | 系统授权 | 访问你填写的 CalDAV 服务器 | 安装即授予 |
| `ohos.permission.READ_CALENDAR` | user_grant | 读取本应用写入系统日历的日程 | 打开「混合显示」时 |
| `ohos.permission.READ_WHOLE_CALENDAR` | **受限(需 ACL)** | 读取系统日历中**非本应用创建**的日程,实现混合展示 | 同上 |
| `ohos.permission.WRITE_CALENDAR` | user_grant | 把 CalDAV 日历本镜像到系统日历 | 开启「镜像到系统日历」时 |
| `ohos.permission.PUBLISH_AGENT_REMINDER` | 系统授权 | 发送日程提醒通知 | 保存带提醒的日程时 |
> 所有 user_grant 权限都**只在用户主动操作对应功能时**才弹窗,启动 / 首启 / 同意隐私政策时一律不申请。
> 拒绝后功能默认关闭,可随时在系统设置或本应用设置页重新开启。
---
## 🔑 自行编译并上架:权限要怎么处理
> 如果你 fork 本项目、修改后想上架华为应用市场,**仅修改 `module.json5` 是不够的**。
> 下面这些权限需要额外动作,否则审核会被驳回、或安装后权限静默失效。
### 一、必须自己去申请的(本项目无法代劳)
以下权限绑定在**华为开发者账号**上,与本仓库代码无关。你必须用自己的账号重新走一遍:
| 权限 | 需要做什么 | 审核周期 | 被拒风险 |
|---|---|---|---|
| `READ_WHOLE_CALENDAR` | 在 **AGC → 我的应用 → 权限管理** 提交 **ACL 申请**,说明为什么必须读取非本应用创建的日程 | 数个工作日 ~ 2 周 | 中。用途说明不充分会被驳回 |
| `PUBLISH_AGENT_REMINDER` | 属受限开放权限,需在 AGC 申请 | 数个工作日 | 低 |
**本项目已获批的 ACL 只对 `bundleName = synccalendar.yangyq.net` 生效。**
一旦你改了 `bundleName`(几乎必然要改,否则会与本项目冲突),ACL 立即失效,必须用新包名重新申请。
### 二、ACL 是怎么落地的
ACL **不写在 `module.json5` 里**,而是由 AGC 在审核通过后签发进 **签名 Profile(`.p7b`)**。
想确认手上这份 Profile 有没有带 ACL,可以这样查:
```bash
# 解出 profile 明文(p7b 本质是 PKCS#7 包裹的 JSON)
openssl smime -verify -inform DER -in your.release.p7b -noverify -out profile.json
# 查看 allowed-acls
python3 -c "import json;d=json.load(open('profile.json'));print(d.get('acls'))"
```
正常应输出类似:
```json
{"allowed-acls": ["ohos.permission.READ_WHOLE_CALENDAR"]}
```
**若 `acls` 为空或缺失,说明该 Profile 未获批 ACL** —— 此时即使 `module.json5` 声明了权限,
装到普通用户手机上也会被系统拒绝,功能静默失效。
### 三、不想申请 ACL 怎么办
如果你不想走 ACL 流程(例如只是自用、或嫌审核慢),可以把 `READ_WHOLE_CALENDAR` 从
`module.json5` 中删掉,代价是:
- 「混合显示系统日历」只能看到**本应用自己写入**的日历(`READ_CALENDAR` 足够)
- 看不到华为账号自带日历、其他 App 写入的日历
功能仍然可用,只是视图里少了其他来源的日程。这是一种被社区实践过的合规绕行方案。
### 四、其他与审核相关的注意点
- **应用图标**:AGC 要求分层图标的后景图(`background.png`)为 **100% 不透明、无圆角**(规范 7.1.1.4.3)。
本项目 `AppScope/resources/base/media/background.png` 已满足;若你要换图,务必重新校验 alpha 通道。
另外 `app_icon.png` 目前含透明像素,某些场景(如应用内展示)不受影响,但如要提交商店素材建议先转为不透明。
- **Demo / 免账号体验**:审核员通常没有你的 CalDAV 账号,无法测试核心功能,这是常见的驳回原因。
建议提供一个「一键载入演示数据」入口,或在上架备注中提供测试账号。
- **隐私政策一致性**:隐私政策里声明的权限、数据流向必须与 `module.json5` 完全一致。
本项目已移除 LOCATION,隐私政策中**不得**再出现"读取位置"之类表述。
### 目录结构
@@ -118,7 +263,7 @@ SyncCalendar/
│ ├─ entryability/ 主能力
│ ├─ entrybackupability/ 应用数据备份恢复
│ ├─ entryformability/ 桌面卡片
│ └─ syncwork/ 后台定时同步(WorkScheduler)
│ └─ syncwork/ 后台定时同步(WorkScheduler 扩展能力)
└─ resources/ base / dark 两套资源与颜色
```
@@ -126,14 +271,169 @@ SyncCalendar/
## 📦 构建与运行
1. 使用 **DevEco Studio** 打开工程根目录
2. 连接鸿蒙真机(需 HarmonyOS NEXT)或模拟器
3. 编译安装后进入「≡ → 账号管理」,添加 CalDAV 服务器地址、用户名和密码
4. 选择要同步的日历本,等待首次同步完成
5. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
### 一、准备工程
> 上架包构建:`hvigorw assembleApp --mode project -p product=default -p buildMode=release`
> (需要在 `build-profile.json5` 里配置自己的签名,该文件不入库)
1. 安装 **DevEco Studio**(建议 5.0 及以上,需配套 HarmonyOS NEXT SDK)
2. 用 DevEco Studio 打开工程根目录,等待自动同步(`ohpm install`)完成
3. 连接鸿蒙真机(需 HarmonyOS NEXT)或模拟器
### 二、首次构建:先补上签名配置(重要)
> **为什么需要自己配?**
> 鸿蒙应用在编译安装与上架时都必须签名。工程根目录的 **`build-profile.json5`** 里记录了
> 签名文件的**绝对路径与加密口令**,属于机器相关且敏感的凭据,因此该文件**被 `.gitignore` 排除、不在仓库中**。
> 你从仓库克隆下来的代码默认**不含任何签名信息**,直接编译会失败。下面是补全方法。
#### 1. 需要准备哪些文件
签名一共涉及 **4 个文件**,全部放在仓库**之外**的某个目录里(例如 `D:\DevEcoSign\`):
| 文件 | 格式 | 说明 |
|---|---|---|
| 密钥库 | `.p12` | 存放你的私钥与证书链,**最关键、绝不能泄露** |
| 证书 | `.cer` | 与密钥库配套的应用证书 |
| Profile | `.p7b` | 授权档案,决定该包能装到哪些设备 / 能否上架 |
| (口令) | — | 密钥库口令、私钥口令(在生成时自己设定,请务必记牢) |
#### 2. 这些文件怎么申请、怎么得到
有两种途径,按你的目的选一种:
**途径 A:调试用(自用真机安装)—— 全自动,推荐**
DevEco Studio 可以一键生成并自动完成签名配置,无需手工操作:
1. 打开 `File → Project Structure → Project → Signing Configs`
2. 勾选 **`Automatically generate signature`**(自动生成签名)
3. 按提示登录你的**华为开发者账号**(需先在 [AppGallery Connect](https://developer.huawei.com/consumer/cn/) 完成实名认证)
4. 等待自动完成——DevEco 会替你:生成 `.p12` 密钥库与 `.cer` 证书 → 向华为**申请调试 Profile(`.p7b`)**
并自动注册当前设备 → 把 `signingConfigs` 写入 `build-profile.json5`
5. 生成的文件默认落在 `C:\Users\<你>\.ohos\config\` 下
> 这是最省事的方式,**只要一个已实名认证的华为开发者账号**即可,不需要额外审批。
**途径 B:上架用(发布到应用市场)—— 需在 AGC 后台申请**
若要打**上架包**(`.app`),必须使用**发布证书 + 发布 Profile**,需手动申请:
1. 登录 [AppGallery Connect](https://developer.huawei.com/consumer/cn/),进入
`用户与访问 → 证书管理`,创建并下载:
- **发布证书(`.cer`)**——需先在本机用 `keytool` 或 DevEco 生成一个 `.p12` 密钥库,
再上传其生成的 **CSR 文件**申请证书;
- **发布 Profile(`.p7b`)**——在 `证书、APP ID 和 Profile → Profile` 中新建,
类型选 **发布(Release)**,关联上面的证书与你的 App ID。
2. 记住创建密钥库时设定的**密钥库口令**与**私钥口令**。
> 官方文档入口:AppGallery Connect → 帮助中心 → **「配置应用签名」** / 「证书管理」。
#### 3. 在哪个文件里填写
在**仓库根目录**新建 `build-profile.json5`(就是被忽略的那份),填入你的签名信息。
可直接复制仓库中的模板 **`build-profile.example.json5`** 改名为 `build-profile.json5` 后使用,格式如下:
```json5
{
"app": {
"signingConfigs": [
{
// 调试配置:真机安装用这项
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "D:/DevEcoSign/your.p12", // ← 改成你的密钥库绝对路径
"storePassword": "<你的密钥库口令>", // ← 自动生成时是一段密文
"keyAlias": "your_key_alias", // ← 密钥别名(生成时指定)
"keyPassword": "<你的私钥口令>", // ← 同上
"signAlg": "SHA256withECDSA",
"profile": "D:/DevEcoSign/yourDebug.p7b", // ← 调试 Profile
"certpath": "D:/DevEcoSign/yourDebug.cer" // ← 配套证书
}
},
{
// 上架配置:打 .app 包用这项(真机安装勿用)
"name": "release",
"type": "HarmonyOS",
"material": {
"storeFile": "D:/DevEcoSign/your.p12",
"storePassword": "<你的密钥库口令>",
"keyAlias": "your_key_alias",
"keyPassword": "<你的私钥口令>",
"signAlg": "SHA256withECDSA",
"profile": "D:/DevEcoSign/yourRelease.p7b", // ← 发布 Profile
"certpath": "D:/DevEcoSign/yourRelease.cer" // ← 发布证书
}
}
],
"products": [
{
"name": "default",
"signingConfig": "default", // 用哪套签名:debug 用 "default",上架改 "release"
"targetSdkVersion": "26.0.0",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": { "caseSensitiveCheck": true, "useNormalizedOHMUrl": true }
}
}
],
"buildModeSet": [ { "name": "debug" }, { "name": "release" } ]
},
"modules": [
{ "name": "entry", "srcPath": "./entry",
"targets": [ { "name": "default", "applyToProducts": [ "default" ] } ] }
]
}
```
**关键位置说明:**
| 位置 | 作用 |
|---|---|
| `signingConfigs[].material.*` | 四个签名文件的**绝对路径**与两个口令 |
| `products[0].signingConfig` | 选哪套签名:真机安装填 `"default"`,打上架包填 `"release"` |
> ⚠️ **`build-profile.json5` 已在 `.gitignore` 中,请勿提交**。
> 若用「途径 A」自动生成,DevEco 会自动创建并填好该文件,你通常无需手写。
> 注意:`entry/build-profile.json5` 是**模块级**普通构建配置(不含密钥),它在仓库中是**需要保留**的,两者不要混淆。
#### 4. 一个更省事的替代做法
如果不想手写,也可以直接在 DevEco Studio 里操作:
`File → Project Structure → Project → Signing Configs`,勾选 `Automatically generate signature`
并登录华为开发者账号——DevEco 会自动把 `build-profile.json5` 生成好,随后直接编译即可。
### 三、编译运行
1. 点击 DevEco Studio 的 **Run ▶**,或执行:
```bash
hvigorw assembleHap --mode module -p product=default
```
2. 安装后进入「≡ → 账号管理」,添加 CalDAV 服务器地址、用户名和密码
3. 选择要同步的日历本,等待首次同步完成
4. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
### 四、打上架包(App Pack)
应用市场上架需要提交 **`.app`(App Pack)**,而不是 `.hap`(`.hap` 仅用于真机侧载):
```bash
hvigorw assembleApp --mode project -p product=default -p buildMode=release
```
- 产物:`build/outputs/default/SyncCalendar-default-signed.app`
- **前提**:请先把 `products[0].signingConfig` 改为 `"release"`(用发布证书 + 发布 Profile),
否则会因签名不匹配而无法通过审核。
- 校验签名(可选,`-inForm zip` 不可省,否则会误报 `can not find codesign block`):
```bash
java -jar hap-sign-tool.jar verify-app -inForm zip \
-inFile SyncCalendar-default-signed.app \
-outCertChain out.cer -outProfile out.p7b
```
出现 `verify-app success` 即表示签名有效。
---
@@ -166,7 +466,7 @@ SyncCalendar/
| 软件特性 | <https://synccalendar.yangyq.net/sf.html> |
| 隐私政策 | <https://synccalendar.yangyq.net/upa.html> |
| 用户服务协议 | <https://synccalendar.yangyq.net/usa.html> |
| 问题反馈 | <https://synccalendar.yangyq.net/feedback/> |
| 问题反馈 | <https://feedlite.yangyq.net/?sw=synccalendar> |
网站备案:**鲁ICP备17014997号-6A**
+90
View File
@@ -0,0 +1,90 @@
/**
* build-profile.json5 模板(示例)
*
* 用法:
* 1. 把本文件复制为同目录下的 build-profile.json5
* 2. 按注释把 <尖括号> 内容替换成你自己的签名信息
* 3. 不要提交生成后的 build-profile.json5(它已在 .gitignore 中)
*
* 4 个签名文件(.p12 / .cer / .p7b ×2)需要放在仓库之外的任意目录,
* 这里用 D:/DevEcoSign/ 作为示例路径,请改成你自己的真实路径。
*
* 如何获取这些文件、在哪里填写,详见 README.md 的「📦 构建与运行 → 二、首次构建:先补上签名配置」。
*
* 最省事的替代方案:用 DevEco Studio 打开工程 →
* File → Project Structure → Project → Signing Configs →
* 勾选 "Automatically generate signature" 并登录华为开发者账号,
* IDE 会自动生成并填好本文件,无需手工编辑。
*/
{
"app": {
"signingConfigs": [
{
// ── 调试配置:真机安装 / 本地运行用这一项 ──
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "D:/DevEcoSign/<你的密钥库>.p12",
"storePassword": "<密钥库口令>",
"keyAlias": "<密钥别名>",
"keyPassword": "<私钥口令>",
"signAlg": "SHA256withECDSA",
"profile": "D:/DevEcoSign/<你的调试Profile>.p7b",
"certpath": "D:/DevEcoSign/<你的调试证书>.cer"
}
},
{
// ── 上架配置:打 .app 上架包用这一项(真机安装请勿使用)──
"name": "release",
"type": "HarmonyOS",
"material": {
"storeFile": "D:/DevEcoSign/<你的密钥库>.p12",
"storePassword": "<密钥库口令>",
"keyAlias": "<密钥别名>",
"keyPassword": "<私钥口令>",
"signAlg": "SHA256withECDSA",
"profile": "D:/DevEcoSign/<你的发布Profile>.p7b",
"certpath": "D:/DevEcoSign/<你的发布证书>.cer"
}
}
],
"products": [
{
"name": "default",
// 用哪套签名:真机安装填 "default";打上架包前改成 "release"
"signingConfig": "default",
"targetSdkVersion": "26.0.0",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
}
}
}
],
"buildModeSet": [
{
"name": "debug"
},
{
"name": "release"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
}
]
}
@@ -57,6 +57,14 @@ export class DavAccount {
calendarColors: string[] = [];
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
calendarWritable: string[] = [];
/**
* ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应:
* 'event' 纯日历本(只有 VEVENT)
* 'todo' 纯待办本(只有 VTODO)
* 'both' 混合本
* 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容)
*/
calendarComps: string[] = [];
itemCount: number = 0;
lastSyncTime: string = '';
}
@@ -69,6 +77,14 @@ export class CalSource {
source: string = 'dav'; // 'dav' | 'system' | 'local'
visible: boolean = true;
writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑
/**
* 本支持哪些组件(来自服务器 COMP_SET 声明):
* 'event' 纯日历本(新建日程可选,新建待办不可选)
* 'todo' 纯待办本(反过来)
* 'both' 两者都可
* 'unknown' 服务器未声明 → 两边都按可用处理(旧数据兼容)
*/
comps: string = 'unknown';
}
/** 与 UI 无关的调色板 */
@@ -474,6 +490,9 @@ export class AccountStore {
if (parts.length >= 12) {
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
}
if (parts.length >= 13) {
acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';');
}
return acc;
}
@@ -565,6 +584,7 @@ export class AccountStore {
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : [];
a.calendarComps = o['calendarComps'] !== undefined ? o['calendarComps'] as string[] : [];
a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
if (a.id === '') {
@@ -699,4 +719,45 @@ export class AccountStore {
return false;
}
}
/**
* ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。
*
* 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`,
* 于是调用方无法从返回值区分两种情况:
* ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**);
* ② 密文存在但解不开,或读取过程抛错(临时故障)。
*
* 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理,
* 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。
*
* 判定口径(三者同时成立才算"确认空白"):
* - 偏好里**没有**账号密文(`data_b64` 为空);
* - 旧版明文存储也没有账号;
* - 读取过程没有抛异常。
*
* @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过
*/
static async isEmptyVaultConfirmed(context: common.Context): Promise<boolean> {
try {
const store: preferences.Preferences = await Vault.prefs(context);
const data: string = await store.get(VAULT_DATA, '') as string;
if (data !== '') {
// 有密文:只有能解出"0 个账号"才算确认空白;解不开一律视为拿不准
const plain: string | null = await Vault.decrypt(context, data);
if (plain === null) {
return false;
}
const arr: Array<Record<string, Object>> = JSON.parse(plain) as Array<Record<string, Object>>;
return arr.length === 0;
}
// 无密文:再看旧版明文存储是否还有残留账号
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
return legacy === null || legacy.length === 0;
} catch (err) {
const e = err as BusinessError;
console.error(`[AccountStore] isEmptyVaultConfirmed 判定失败(按"拿不准"处理): ${e.message}`);
return false;
}
}
}
+99 -6
View File
@@ -20,6 +20,13 @@ export class AppSettings {
private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey
private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳
// ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(用于节流)。
// 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1),
// 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。
private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at';
/** ⭐ 诊断开关:卡片驱动同步的时间预算(毫秒)。0/未设置 = 用内置默认(见 BackgroundSyncRunner)。
* 留给真机排查用 —— 不用重编译就能做"9 秒 vs 长窗口"对照实验。 */
private static readonly KEY_CARD_BUDGET_MS: string = 'card_budget_ms';
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list'
@@ -27,6 +34,9 @@ export class AppSettings {
private static readonly KEY_MIRROR_KEYS: string = 'mirror_cal_keys'; // 要镜像的 DAV 日历本 calKey 列表
private static readonly KEY_MIRROR_BOOK_IDS: string = 'mirror_book_ids'; // ⭐ 要镜像的日历本**稳定标识** bookId(由服务器 href 派生,不随序号变)
private static readonly KEY_MIRROR_INBOUND: string = 'mirror_inbound'; // 是否把系统日历的改动回写 CalDAV(默认关)
// ⭐ 系统日历账户的**显示名格式**(1/2/3,默认 3=`<账号>·<本名>`)。
// 因平台无"改账户名"API,切换格式需删账户重建(用户显式点「应用」才执行)。
private static readonly KEY_MIRROR_DISPLAY_FORMAT: string = 'mirror_display_format';
private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**——
@@ -35,8 +45,13 @@ export class AppSettings {
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / LOCATION / APPROXIMATELY_LOCATION(均前台),
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)与"添加/修改/删除日历活动"(WRITE_CALENDAR)。
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(均前台)。
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)。
// ⚠️ 0.1.0 起已**移除 LOCATION / APPROXIMATELY_LOCATION**:实测本 SDK(API 26)的
// `geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,
// 地理编码不需要定位权限;且应用从不调用 getCurrentLocation,本就不读实时位置。
// ⇒ 隐私政策/隐私标签里都**不得**再声称"读取设备精确位置或大致位置"。
// ⇒ 若将来真要用实时定位,必须同时改 module.json5、本注释、upa.html 与 AGC 隐私标签。
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
/** 隐私政策网址 */
static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html';
@@ -46,11 +61,20 @@ export class AppSettings {
static readonly FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html';
/** 使用帮助网址 */
static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.html';
/**
* ⭐ 问题反馈网址。
*
* 换到 FeedLite(多站点共用一套反馈平台),`?sw=` 指定本项目站点标识。
* 旧地址 synccalendar.yangyq.net/feedback/ 是自建 PHP 论坛,
* 里面还有 3 条历史反馈(含一条完整 PR 贡献),服务暂不删除。
*/
static readonly FEEDBACK_URL: string = 'https://feedlite.yangyq.net/?sw=synccalendar';
/** 文档标题(内置浏览器标题栏用) */
static readonly PRIVACY_TITLE: string = '隐私政策';
static readonly AGREEMENT_TITLE: string = '用户服务协议';
static readonly FEATURES_TITLE: string = '软件特性';
static readonly HELP_TITLE: string = '使用帮助';
static readonly FEEDBACK_TITLE: string = '问题反馈';
/**
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
@@ -293,6 +317,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 时期)落库的残缺数据。
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
@@ -577,15 +662,23 @@ export class AppSettings {
}
}
/** 自动同步间隔(分钟),默认 1 分钟 */
/**
* 自动同步间隔(分钟),默认 15 分钟。
*
* ⭐ 0.0.8:默认值由 1 分钟改为 15 分钟。原因(真机日志 2026-10-16 17:16–17:20 实测):
* 1 分钟间隔会让「App 打开期间」几乎不间断地跑"12 个日历本全量比对 + 出站镜像 + 提醒刷新",
* 5 分钟内连跑 5 轮后触发系统提示「短时任务超时(配额用尽),后台同步将被挂起」——
* 反而把真正的后台同步通道一起拖垮。改为 15 分钟后,前台同步仍足够及时,且不再烧配额。
* 用户仍可在设置页自行改成 1 / 5 / 15 / 30 分钟 或 1 小时。
*/
static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number;
return v > 0 ? v : 1;
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 15) as number;
return v > 0 ? v : 15;
} catch (err) {
return 1;
return 15;
}
}
@@ -0,0 +1,260 @@
// entry/src/main/ets/common/BackgroundSyncRunner.ets
// ⭐ 后台同步的**唯一**执行体:延迟任务 / 短时任务 / 网络恢复 / 回到前台 都调它。
//
// 设计要点(2026-10-10 定时同步方案调研结论):
// - 不再依赖"进程常驻"(系统不允许,dataTransfer 长时任务会被强制取消)
// - 而是让"最合适的机制在最合适的时机把数据对齐"
// - 本类只负责"跑一次完整同步",幂等(基于 etag 全量比对 + dirty 标记)
//
// ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。
import { common } from '@kit.AbilityKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore';
import { SyncEngine } from './SyncEngine';
import { CardDataService } from './CardDataService';
import { ReminderService } from './ReminderService';
import { SystemCalendarImport } from './SystemCalendarImport';
import { SystemCalendarMirror } from './SystemCalendarMirror';
import { MirrorInbound } from './MirrorInbound';
import { AppSettings } from './AppSettings';
import { LogUtil } from './LogUtil';
/** 一次后台同步的结果(供日志/埋点,不做 UI) */
export class BackgroundSyncResult {
ok: number = 0;
attempted: number = 0;
failed: number = 0;
}
export class BackgroundSyncRunner {
/**
* ⭐ 卡片的系统定时刷新间隔(与 form_config.json 的 updateDuration=1 保持一致,单位 30 分钟)。
* ⚠️ 两处必须同步修改:改这里也要改 form_config.json,否则节流窗口对不上。
*/
static readonly CARD_TICK_MS: number = 30 * 60 * 1000;
/** 距上次同步不足该时长则跳过(略小于卡片周期,避免系统刷新漂移导致被误跳过) */
private static readonly CARD_MIN_GAP_MS: number = 25 * 60 * 1000;
/**
* 卡片进程的软超时(**未申请到短时任务时**):官方规定 FormExtensionAbility
* 生命周期回调后进程**仅存续 10 秒**,超过就被回收(异步任务被强制中止)。
* 这里主动在 9 秒收手,让"这一次同步"**干净地放弃**而不是被硬杀在半途。
*/
private static readonly CARD_SOFT_TIMEOUT_MS: number = 9 * 1000;
/**
* 申请到短时任务后的软超时:短时任务单次最长 3 分钟(低电量 1 分钟),
* 但实际时长以 `actualDelayTime` 为准 → 留 15 秒余量提前收手,保证能干净地
* `cancelSuspendDelay`(否则超时不取消会被系统强制终止进程、并浪费当日配额)。
*/
private static readonly CARD_EXT_SOFT_RESERVE_MS: number = 15 * 1000;
/**
* 卡片驱动同步可用的**总时长上限**:即使申请到短时任务也不超过这个值。
* 理由:短时任务**单日总配额仅 10 分钟**,30 分钟一次的话每次平均只能分到约 20 秒,
* 全用光会让"退后台那一次同步"(EntryAbility.onBackground 申请)无配额可用。
*/
private static readonly CARD_MAX_TOTAL_MS: number = 60 * 1000;
/** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */
private static cardTransientId: number = 0;
/** 当前进程 pid(取不到返回 -1)。用来判断卡片回调是否与主 App 同进程。 */
private static pidOf(): number {
try {
return process.pid;
} catch (err) {
return -1;
}
}
/**
* ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。
*
* 为什么用它:系统给普通应用的后台调度很受限 ——
* `workScheduler` 的周期被"应用活跃分组"压制到 **≥2 小时**;
* 而服务卡片配置了 `updateDuration: 1`(**单位 30 分钟**),系统会**每 30 分钟**
* 触发一次 `onUpdateForm()`,且不受活跃分组压制(每天上限 50 次)。
* 借这个时机跑一次完整同步,即可得到"约 30 分钟一次"的定期后台同步。
*
* ⚠️ 节流:系统刷新本身可能有漂移、也可能因故重入 → 用时间戳挡一道,
* 保证两次真实同步之间至少间隔 `CARD_MIN_GAP_MS`。
* ⚠️ 本方法**永不抛错**(后台路径没有 UI 承担异常)。
*/
static async runOnceThrottledByCard(context: common.Context): Promise<void> {
try {
const last: number = await AppSettings.getCardSyncAt(context);
const now: number = Date.now();
if (last > 0 && now - last < BackgroundSyncRunner.CARD_MIN_GAP_MS) {
const mins: number = Math.round((now - last) / 60000);
LogUtil.write(`卡片驱动同步:距上次仅 ${mins} 分钟,跳过(节流 ${BackgroundSyncRunner.CARD_MIN_GAP_MS / 60000} 分钟)`);
return;
}
// 先写时间戳:避免同步执行期间卡片再次刷新导致并发重入
await AppSettings.setCardSyncAt(context, now);
// ⭐ 尝试申请**短时任务**,把 10 秒窗口延长到最多 3 分钟。
// ⚠️ 官方规定 requestSuspendDelay 应在"应用处于前台"或"onBackground 回调"里调用;
// 卡片进程(FormExtensionAbility)是否符合**未知** → 故此处**容错尝试**:
// 成功则按实际时长延长,失败(抛错/时长为 0)则退回 9 秒软超时。
// ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟),
// 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。
const windowMs: number = await BackgroundSyncRunner.acquireCardWindow();
let budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS);
// ⭐ 诊断开关(preferences:card_budget_ms):>0 时**覆盖**上面算出的可用窗口。
// 用途:在真机上回答"卡片进程究竟能活多久""一次完整同步要多久",
// 从而决定卡片这条路该做多少事 —— 不必为此重新编译。
const overrideMs: number = await AppSettings.getCardBudgetMs(context);
if (overrideMs > 0) {
budget = overrideMs;
LogUtil.write(`卡片驱动同步:预算被诊断开关覆盖为 ${Math.round(budget / 1000)} 秒`);
}
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,pid=${BackgroundSyncRunner.pidOf()},可用窗口 ${Math.round(budget / 1000)} 秒)`);
// 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试)
const worker: Promise<void> =
BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {});
const guard: Promise<void> = new Promise<void>((_res: () => void, rej: (e: Error) => void) => {
setTimeout((): void => rej(new Error('本次同步时间预算用尽,主动收手')),
budget);
});
try {
await Promise.race<void>([worker, guard]);
} catch (err) {
const e = err as Error;
LogUtil.write(`卡片驱动同步提前收手:${e.message}(幂等,下轮会重试)`);
} finally {
// 无论成败都释放短时任务:① 归还当日配额 ② 避免超时不取消被系统强杀进程
BackgroundSyncRunner.releaseCardWindow();
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`卡片驱动同步异常: ${e.message}`);
}
}
/**
* 为卡片同步申请短时任务,返回**实际可用窗口(毫秒)**。
* 申请失败/异常/时长为 0 → 返回 0,由调用方退回默认的 9 秒软超时(不阻塞流程)。
*/
private static async acquireCardWindow(): Promise<number> {
try {
const info: backgroundTaskManager.DelaySuspendInfo =
backgroundTaskManager.requestSuspendDelay('正在同步日历数据', (): void => {
// 超时回调:配额即将用尽,清掉本地记录(系统会自行处理挂起)
LogUtil.write('卡片同步的短时任务即将超时');
BackgroundSyncRunner.cardTransientId = 0;
});
BackgroundSyncRunner.cardTransientId = info.requestId;
const actual: number = info.actualDelayTime ?? 0;
if (actual <= 0) {
// 拿到了 requestId 但没有实际时长 → 视为不可用
BackgroundSyncRunner.releaseCardWindow();
LogUtil.write('卡片同步:短时任务时长为 0,退回 9 秒软超时');
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
}
// 留 15 秒余量提前收手,保证能干净地取消
const usable: number = Math.max(actual - BackgroundSyncRunner.CARD_EXT_SOFT_RESERVE_MS,
BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS);
LogUtil.write(`卡片同步:短时任务已获批,实际时长 ${Math.round(actual / 1000)} 秒`);
return usable;
} catch (err) {
// 例如"未在前台/退后台时机申请"被拒 —— 退回 9 秒软超时,不影响流程
const e = err as BusinessError;
LogUtil.write(`卡片同步:短时任务申请失败(${e.code ?? ''} ${e.message}),退回 9 秒软超时`);
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
}
}
/** 释放卡片同步的短时任务(幂等) */
private static releaseCardWindow(): void {
if (BackgroundSyncRunner.cardTransientId === 0) {
return;
}
try {
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncRunner.cardTransientId);
} catch (err) {
// 取消失败无需处理
}
BackgroundSyncRunner.cardTransientId = 0;
}
/**
* 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。
* @param context 应用上下文
* @param tag 日志标签,用于区分触发来源(deferred/transient/netRecover/foreground)
*/
static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> {
const r = new BackgroundSyncResult();
// ⭐ 阶段计时:后台同步被时间预算切断时,这些 `+Xms` 就是"钱花在哪了"的直接证据。
const t0: number = Date.now();
const el = (): number => Date.now() - t0;
try {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
LogUtil.write(`后台同步[${tag}] 阶段:载入账号 ${accounts.length} 个 +${el()}ms`);
if (accounts.length === 0) {
LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
return r;
}
// 备份模式:先导入系统本地日历(幂等),再随同步推送
try {
await SystemCalendarImport.importIfNeeded(context);
} catch (err) {
// 导入失败不影响正常同步
}
LogUtil.write(`后台同步[${tag}] 阶段:系统日历导入 +${el()}ms`);
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
try {
await MirrorInbound.reconcile(context);
} catch (err) {
// 对账失败不影响正常同步
}
LogUtil.write(`后台同步[${tag}] 阶段:入站对账 +${el()}ms`);
for (const acc of accounts) {
if (acc.type !== TYPE_CALDAV) {
continue;
}
r.attempted++;
try {
await SyncEngine.syncAccount(context, acc);
r.ok++;
} catch (err) {
const e = err as BusinessError;
// "已有同步进行中"不算失败(前台可能正在同步)
r.failed++;
LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`);
}
LogUtil.write(`后台同步[${tag}] 阶段:账号 ${acc.id} 同步 +${el()}ms(ok=${r.ok} failed=${r.failed})`);
}
// 全部账号同步成功才结束"一次性全量重拉"状态
if (r.attempted > 0 && r.ok === r.attempted) {
await AppSettings.markFullRefetchDone(context);
}
await SyncEngine.settleLocalEvents(context);
LogUtil.write(`后台同步[${tag}] 阶段:本地收尾 +${el()}ms`);
await SyncEngine.pruneOrphanRows(context, accounts);
LogUtil.write(`后台同步[${tag}] 阶段:孤儿行清理 +${el()}ms`);
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。
await AccountStore.saveAll(context, accounts);
// 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点)
try {
if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) {
const mr = await SystemCalendarMirror.syncNow(context);
LogUtil.write(`后台同步[${tag}] 自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
}
} catch (err) {
// 镜像失败不影响同步
}
LogUtil.write(`后台同步[${tag}] 阶段:出站镜像 +${el()}ms`);
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
try {
await CardDataService.pushToAllForms(context);
await ReminderService.refreshReminders(context as common.UIAbilityContext);
} catch (err) {
// 卡片/提醒刷新失败不影响同步结果
}
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号,总耗时 ${Math.round(el() / 1000)} 秒`);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}(已耗时 ${Math.round(el() / 1000)} 秒)`);
}
return r;
}
}
@@ -1,58 +1,46 @@
// entry/src/main/ets/common/BackgroundSyncService.ets
// 后台同步管理:
// 1) 长时任务(DATA_TRANSFER):进程不被挂起,前台定时器在后台继续跑(需用户在设置中开启)
// 2) 延迟任务(workScheduler):App 被杀/重启后由系统按条件拉起兜底同步(最小周期 20 分钟)
// ⭐ 后台同步调度(2026-10-10 重构,依据《定时同步方案调研.md》)
//
// 为什么不再用长时任务(重要):
// 旧版申请 `dataTransfer` 长时任务想让进程常驻,但系统对 DATA_TRANSFER 有**一致性校验**——
// "超过 10 分钟未更新传输进度 → 任务被强制取消"。日历同步是"间歇跑、平时无进度",
// 本质不符合"持续传输中"的语义 → 必被取消(常驻通知消失就是这个原因),
// 且频繁触发校验失败可能被系统列入管控名单。故**彻底移除**。
//
// 现在的分层调度(每层各司其职):
// ① 前台:Index 里的 setInterval 按用户设定间隔同步(最准,已在前台实现)
// ② 退后台瞬间:**短时任务**(requestSuspendDelay)兜住"退后台后那一次"
// ③ 长期后台:**延迟任务**(workScheduler,≥2 小时,对齐系统活跃分组下限)
// ④ 网络恢复 / 回到前台:各补一次(性价比最高,见 NetworkSyncWatcher / Index.onForeground)
//
// ⚠️ 关键约束(踩过的坑,勿改):
// - workScheduler 的 repeatCycleTime **不得低于 2 小时**:系统按"应用活跃分组"压制
// (活跃≥2h / 经常使用≥4h / 常用≥24h / 极少使用≥48h / 受限·从未使用禁止调度),
// 请求更短不报错但会被压制,形同虚设。
// - WorkSchedulerExtensionAbility 单次回调**上限 2 分钟**,超时进程被杀。
// - 短时任务配额:单日 10 分钟、单次最长 3 分钟(低电量 1 分钟)→ 只做短收尾,用完必须 cancel。
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { wantAgent, common } from '@kit.AbilityKit';
import { common } from '@kit.AbilityKit';
import { workScheduler } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AppSettings } from './AppSettings';
import { LogUtil } from './LogUtil';
const WORK_ID: number = 1001;
/** 延迟任务周期:2 小时(系统"活跃分组"下限,不可再短) */
const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000;
export class BackgroundSyncService {
/** 申请长时任务(DATA_TRANSFER):申请成功后进程在后台不被挂起,失败抛错由调用方提示用户 */
static async startContinuousTask(context: common.UIAbilityContext): Promise<void> {
try {
const info: wantAgent.WantAgentInfo = {
wants: [{
bundleName: context.abilityInfo.bundleName,
abilityName: 'EntryAbility'
}],
operationType: wantAgent.OperationType.START_ABILITY,
requestCode: 0
};
const agent = await wantAgent.getWantAgent(info);
await backgroundTaskManager.startBackgroundRunning(
context, backgroundTaskManager.BackgroundMode.DATA_TRANSFER, agent);
LogUtil.write('长时任务已申请:后台持续同步开启');
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`长时任务申请失败: ${e.code} - ${e.message}`);
throw new Error(`长时任务申请失败(错误码 ${e.code})`);
}
}
/** 停止长时任务 */
static async stopContinuousTask(context: common.UIAbilityContext): Promise<void> {
try {
await backgroundTaskManager.stopBackgroundRunning(context);
LogUtil.write('长时任务已停止');
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`长时任务停止失败: ${e.code} - ${e.message}`);
}
}
/** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */
private static transientId: number = 0;
/**
* 注册延迟任务兜底同步:无论长时任务开关与否都注册。
* 条件:有任意网络即执行;周期重复;重启后自动恢复。
* 注:repeatCycleTime 各版本下限不同(20 分钟 ~ 2 小时),先用 20 分钟,
* 被系统拒绝(9700003 条件无效)时降级为 2 小时再试。
* 注册延迟任务兜底同步:周期 2 小时、重启后自动恢复(isPersisted)。
* 条件:有任意网络即执行。
* ⚠️ 周期**不得低于 2 小时**(系统按"应用活跃分组"压制:活跃≥2h / 经常使用≥4h / 常用≥24h /
* 极少使用≥48h / 受限·从未使用禁止调度)。请求更短不报错但会被压制,形同虚设。
*/
static scheduleDeferredSync(context: common.UIAbilityContext): void {
const build = (cycleMs: number): workScheduler.WorkInfo => {
const info: workScheduler.WorkInfo = {
workId: WORK_ID,
bundleName: context.abilityInfo.bundleName,
@@ -60,56 +48,105 @@ export class BackgroundSyncService {
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
isRepeat: true,
isPersisted: true,
repeatCycleTime: cycleMs
};
return info;
repeatCycleTime: DEFERRED_CYCLE_MS
};
try {
workScheduler.startWork(build(20 * 60 * 1000));
LogUtil.write('延迟任务已注册:20 分钟兜底同步');
workScheduler.startWork(info);
LogUtil.write('延迟任务已注册:2 小时兜底同步(系统活跃分组下限)');
} catch (err) {
const e = err as BusinessError;
if (e.code === 9700003) {
try {
workScheduler.startWork(build(2 * 60 * 60 * 1000));
LogUtil.write('延迟任务降级注册:2 小时兜底同步');
return;
} catch (err2) {
const e2 = err2 as BusinessError;
LogUtil.write(`延迟任务注册失败(2h): ${e2.code} - ${e2.message}`);
return;
}
}
LogUtil.write(`延迟任务注册失败: ${e.code} - ${e.message}`);
}
}
/** App 启动时统一入口:注册延迟任务 + 按设置恢复长时任务(恢复失败静默,不影响启动) */
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> {
BackgroundSyncService.scheduleDeferredSync(context);
const enabled: boolean = await AppSettings.getBackgroundSync(context);
if (enabled) {
/** 取消延迟任务(用户关闭"后台自动同步"时调用) */
static cancelDeferredSync(context: common.UIAbilityContext): void {
const info: workScheduler.WorkInfo = {
workId: WORK_ID,
bundleName: context.abilityInfo.bundleName,
abilityName: 'SyncWorkAbility',
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
isRepeat: true,
isPersisted: true,
repeatCycleTime: DEFERRED_CYCLE_MS
};
try {
await BackgroundSyncService.startContinuousTask(context);
workScheduler.stopWork(info, true);
LogUtil.write('延迟任务已取消(后台自动同步关闭)');
} catch (err) {
// 启动时恢复失败不阻塞 App:下次开关切换或重启再试
}
const e = err as BusinessError;
LogUtil.write(`延迟任务取消失败: ${e.code} - ${e.message}`);
}
}
/** 设置页开关切换:失败抛错由页面提示并回滚 UI */
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
if (enabled) {
// 先申请任务,成功才落盘设置(失败时设置页回滚开关)
await BackgroundSyncService.startContinuousTask(context);
await AppSettings.setBackgroundSync(context, true);
} else {
await AppSettings.setBackgroundSync(context, false);
/**
* ⭐ 退到后台时申请**短时任务**:让"刚退后台的这一次同步"能跑完。
* 必须在**前台或退后台 5 秒内**调用,否则申请失败。
* 单次最长 3 分钟(实际申请到的时长以返回值为准),单日总配额 10 分钟。
*/
static requestTransientSync(reason: string): void {
if (BackgroundSyncService.transientId !== 0) {
return; // 已有短时任务在跑,不重复申请(重复申请会浪费配额)
}
try {
await BackgroundSyncService.stopContinuousTask(context);
const info: backgroundTaskManager.DelaySuspendInfo =
backgroundTaskManager.requestSuspendDelay(reason, (): void => {
// 超时回调:配额用完,系统即将挂起;清状态,等下次前台/退后台再申请
LogUtil.write('短时任务超时(配额用尽),后台同步将被挂起');
BackgroundSyncService.transientId = 0;
});
BackgroundSyncService.transientId = info.requestId;
LogUtil.write(`短时任务已申请:requestId=${info.requestId},可用 ${Math.round(info.actualDelayTime / 1000)} 秒`);
} catch (err) {
// 停止失败(如任务本就不在运行)不影响设置保存
const e = err as BusinessError;
LogUtil.write(`短时任务申请失败: ${e.code} - ${e.message}`);
}
}
/** 回到前台时取消短时任务:不占用当日配额(很重要,配额只有 10 分钟) */
static cancelTransientSync(): void {
if (BackgroundSyncService.transientId === 0) {
return;
}
try {
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncService.transientId);
LogUtil.write('短时任务已取消(回到前台,归还配额)');
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`短时任务取消失败: ${e.code} - ${e.message}`);
}
BackgroundSyncService.transientId = 0;
}
/** 查询短时任务剩余时间(毫秒);无任务或无配额返回 0 */
static async remainingTransientMs(): Promise<number> {
if (BackgroundSyncService.transientId === 0) {
return 0;
}
try {
return await backgroundTaskManager.getRemainingDelayTime(BackgroundSyncService.transientId);
} catch (err) {
return 0;
}
}
/** App 启动时统一入口:按用户设置注册/不注册延迟任务兜底同步 */
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> {
const enabled: boolean = await AppSettings.getBackgroundSync(context);
if (enabled) {
BackgroundSyncService.scheduleDeferredSync(context);
} else {
LogUtil.write('后台自动同步未开启:不注册延迟任务');
}
}
/** 设置页开关切换:控制"后台自动同步"(延迟任务注册与否) */
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
await AppSettings.setBackgroundSync(context, enabled);
if (enabled) {
BackgroundSyncService.scheduleDeferredSync(context);
} else {
BackgroundSyncService.cancelDeferredSync(context);
}
}
}
@@ -13,6 +13,7 @@ import { IcsUtil } from './IcsUtil';
/** 界面展示用事件 */
export class DisplayEvent {
id: number = 0; // 本地库 id 或系统日历事件 id
uid: string = ''; // 服务器 UID(待办父子关系用它匹配 parentUid)
isSystem: boolean = false;
title: string = '';
description: string = '';
@@ -25,6 +26,21 @@ export class DisplayEvent {
color: string = '#007DFF';
completed: boolean = false; // 仅待办使用
recurring: boolean = false; // 是否为重复日程展开出的发生
/** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同:
* recurring 只说"重不重复",这里带的是"多久重复一次"。 */
recurRule: string = '';
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
dtstart: number = 0;
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
priority: number = 0;
/** 分类标签,逗号分隔(对应 CATEGORIES) */
categories: string = '';
/** 提醒提前分钟数(多选),空 = 不提醒 */
reminders: number[] = [];
/** 父任务 UID(空 = 顶层任务),对应 RELATED-TO */
parentUid: string = '';
/** 附件 JSON 数组字符串(含 base64),空 = 无附件 */
attachments: string = '';
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
}
@@ -112,6 +128,9 @@ export class CalendarDataService {
const detected: boolean = acc.calendarWritable.length > i
? acc.calendarWritable[i] !== '0' : true;
s.writable = detected && !manualKeys.includes(s.calKey);
// 透传本支持的类型(新建待办/日程时据此过滤,避免把待办写进纯日历本)
s.comps = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
? acc.calendarComps[i] : 'unknown';
sources.push(s);
}
}
@@ -341,6 +360,11 @@ export class CalendarDataService {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.color : '#9AA0A6';
};
// ⭐ 待办按日历本分组显示需要"本名";原来只带 calKey,分组标题无法显示
const nameOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.name : '';
};
try {
const rows: LocalEvent[] = await EventDb.queryTodos(context);
for (const e of rows) {
@@ -349,6 +373,7 @@ export class CalendarDataService {
}
const d = new DisplayEvent();
d.id = e.id;
d.uid = e.uid;
d.isSystem = false;
d.title = e.title;
d.description = e.description;
@@ -357,8 +382,17 @@ export class CalendarDataService {
d.endTime = e.endTime;
d.isAllDay = e.isAllDay;
d.calKey = e.calKey;
d.calName = nameOf(e.calKey);
d.color = colorOf(e.calKey);
d.completed = e.completed;
d.priority = e.priority;
d.categories = e.categories;
d.reminders = e.reminders;
d.parentUid = e.parentUid;
d.attachments = e.attachments;
d.recurRule = e.rrule;
d.dtstart = e.dtstart;
d.recurring = e.rrule !== '';
result.push(d);
}
} catch (err) {
+172 -6
View File
@@ -36,6 +36,24 @@ export class CardAllDay {
isAllDay: boolean = true;
}
/** 待办卡片单条(4x4 / 6x4 列表用,带 localId 以支持卡片内直接打勾) */
export class TodoCardItem {
/** 本地库主键 —— 卡片"打勾完成"时靠它定位要改哪一行 */
id: number = 0;
title: string = '';
/** 截止时间文字:'今天 18:00' / '明天' / '无日期' */
dueText: string = '';
/**
* 副行文字:截止时间 · 日历本(与 App 内 todoSubtitle 同款呈现)。
* 在数据侧拼好,卡片侧直接显示,避免各卡片各拼一遍导致不一致。
*/
subText: string = '';
color: string = '#007DFF';
calName: string = '';
/** 是否已完成(已完成的不出现在待办卡片列表里,此字段留作扩展) */
completed: boolean = false;
}
/** 卡片整体数据 */
export class CardData {
eventsJson: string = '[]';
@@ -62,6 +80,15 @@ export class CardData {
isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线)
selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天)
dayCount: number = 0; // 当前显示日的日程条数(含全天)
// ===== 待办卡片专用字段(isTodoCard=true 时才有效)=====
/** 待办卡片列表:TodoCardItem[] JSON(按截止时间升序,未完成在前) */
todoListJson: string = '[]';
/** 未完成待办总数(2x2 / 2x4 卡片大数字) */
todoCount: number = 0;
/** 今日到期待办数(卡片副标题用) */
todoTodayCount: number = 0;
/** 是否待办卡片(卡片侧据此切换渲染分支) */
isTodoCard: boolean = false;
}
export class CardDataService {
@@ -127,25 +154,57 @@ export class CardDataService {
}
/** App 内同步完成后调用:刷新所有已添加的卡片 */
static async pushToAllForms(context: common.Context): Promise<void> {
/**
* 把待办数据合并进日程数据对象。
*
* ⭐ 抽出来的原因: EntryFormAbility.pushData 与 CardDataService.pushToAllForms
* 是两条独立的推送路径,之前只有前者做了合并,后者漏了 ⇒
* 待办卡片永远显示 0 条(todoCount 停在默认值)。
* 两处共用同一段逻辑,避免再出现"改一处忘一处"。
*/
private static mergeTodoInto(ev: CardData, td: CardData): void {
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
}
/**
* @param excludeTodoId 乐观更新用:临时把该待办从**所有卡片**的列表与计数中排除
* (只影响卡片显示,不动数据库)。0 表示不排除。
*/
static async pushToAllForms(context: common.Context, excludeTodoId: number = 0): Promise<void> {
// 先恢复持久化的 formId(App 重启后内存注册表为空)
await CardDataService.loadPersistedFormIds(context);
if (CardDataService.formIds.length === 0) {
return;
}
// ⭐ 待办数据与 dayOffset 无关(待办不翻页),只构建一次
let todoData: CardData | null = null;
try {
todoData = await CardDataService.buildTodoCardData(context, excludeTodoId);
} catch (err) {
console.error(`待办卡片数据构建失败: ${(err as BusinessError).message}`);
}
// 各卡片可能翻到了不同日期 → 按偏移分组构建,避免把用户翻走的日期拽回今天
const cache: Map<number, formBindingData.FormBindingData> =
new Map<number, formBindingData.FormBindingData>();
const fallbackData: CardData = await CardDataService.buildCardData(context, 0);
if (todoData !== null) {
CardDataService.mergeTodoInto(fallbackData, todoData);
}
const fallback: formBindingData.FormBindingData =
formBindingData.createFormBindingData(await CardDataService.buildCardData(context, 0));
formBindingData.createFormBindingData(fallbackData);
cache.set(0, fallback);
const stale: string[] = [];
for (const formId of CardDataService.formIds) {
const off: number = CardDataService.getDayOffset(formId);
let binding: formBindingData.FormBindingData | undefined = cache.get(off);
if (binding === undefined) {
binding = formBindingData.createFormBindingData(
await CardDataService.buildCardData(context, off));
const d: CardData = await CardDataService.buildCardData(context, off);
if (todoData !== null) {
CardDataService.mergeTodoInto(d, todoData);
}
binding = formBindingData.createFormBindingData(d);
cache.set(off, binding);
}
try {
@@ -305,14 +364,16 @@ export class CardDataService {
// 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中)
data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length;
// ===== 2x4 卡片右侧:**当前时间之后最近的 2 条**日程 =====
// ===== 2x4 卡片右侧:**当前时间之后最近的若干条**日程(卡片可滚动,最多 15 条)=====
// 规则:只取"还没开始"的有时间日程(startTime >= now),
// **排除全天 / 跨天**,按开始时间升序取前 2 条。
// 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。
const upcomingNext: DisplayEvent[] = upcoming
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 2);
// ⭐ 上限 15 条:卡片可滚动,不必再为了"放得下"而截断到 2 条。
// 15 是经验值 —— 再多则用户更该进 App 看,而不是在卡片里翻。
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 15);
const ongoingItems: CardItem[] = [];
for (const e of ongoingPick) {
const dayLong: boolean = isDayLong(e);
@@ -381,4 +442,109 @@ export class CardDataService {
}
return data;
}
/**
* 卡片列表最多输出多少条待办。
* ⭐ 统一为 15:所有待办卡片都已改为可滚动(List + 虚拟化),
* 不必再为了"一屏放得下"而按卡片尺寸给不同上限。
*/
private static readonly MAX_TODO_ITEMS: number = 15;
/**
* 构建**待办卡片**数据。
*
* 与 buildCardData 的区别:不查日程,只查 VTODO;输出 todoListJson + 统计数字。
* 复用同一个 CardData 类,靠 isTodoCard 标记,卡片侧据此走不同渲染分支
*(这样 FormExtension 的推送逻辑不用区分两种卡片,一套 binding 推所有卡片即可)。
*/
/** @param excludeId 乐观更新用:临时把该 localId 从列表与计数中排除(不动数据库) */
static async buildTodoCardData(context: common.Context, excludeId: number = 0): Promise<CardData> {
const data = new CardData();
data.isTodoCard = true;
try {
LogUtil.init(context);
const now = new Date();
const weekCn: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const fullWeekCn: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
data.dateText = `今天 ${now.getMonth() + 1}月${now.getDate()}日 ${weekCn[now.getDay()]}`;
data.lunarText = LunarUtil.lunarDayText(now.getTime());
data.dateMd = `${now.getMonth() + 1}月${now.getDate()}日`;
data.weekday = fullWeekCn[now.getDay()];
data.dayOffset = 0;
data.isToday = true;
data.selectedDate = CardDataService.startOfDay(now.getTime());
const sources = await CalendarDataService.loadSources(context);
const todos: DisplayEvent[] = await CalendarDataService.loadTodos(context, sources);
// 只保留未完成;excludeId>0 时额外剔除该条(乐观更新,仅影响卡片显示)
const undone: DisplayEvent[] = todos.filter((e: DisplayEvent): boolean =>
!e.completed && (excludeId <= 0 || e.id !== excludeId));
const todayStart: number = CardDataService.startOfDay(now.getTime());
const todayEnd: number = todayStart + 86400000;
// 排序:有截止时间在前按升序;无截止时间在后(新建的在前)
const withDue: DisplayEvent[] = undone
.filter((e: DisplayEvent): boolean => e.endTime > 0)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.endTime - b.endTime);
const noDue: DisplayEvent[] = undone
.filter((e: DisplayEvent): boolean => e.endTime <= 0)
.sort((a: DisplayEvent, b: DisplayEvent): number => b.id - a.id);
const orderedList: DisplayEvent[] = withDue.concat(noDue);
const p2 = (n: number): string => n < 10 ? '0' + n : String(n);
const items: TodoCardItem[] = [];
for (const e of orderedList) {
if (items.length >= CardDataService.MAX_TODO_ITEMS) {
break;
}
const it = new TodoCardItem();
it.id = e.id;
it.title = e.title === '' ? '(无标题)' : e.title;
it.color = e.color;
it.calName = e.calName;
it.completed = e.completed;
// 截止时间文字:今天 / 明天 / 昨天 / 逾期 / 具体日期 / 无日期
if (e.endTime <= 0) {
it.dueText = '无日期';
} else {
const due = new Date(e.endTime);
const dueDay: number = CardDataService.startOfDay(e.endTime);
const diffDays: number = Math.round((dueDay - todayStart) / 86400000);
const hm: string = `${p2(due.getHours())}:${p2(due.getMinutes())}`;
// 是否为"当天 0 点整" —— 只有日期的待办,不显示 00:00
const dateOnly: boolean = due.getHours() === 0 && due.getMinutes() === 0;
const timePart: string = dateOnly ? '' : ' ' + hm;
if (diffDays === 0) {
it.dueText = '今天' + timePart;
} else if (diffDays === 1) {
it.dueText = '明天' + timePart;
} else if (diffDays === -1) {
it.dueText = '昨天' + timePart;
} else if (diffDays < -1) {
it.dueText = `逾期 ${Math.abs(diffDays)} 天`;
} else if (diffDays > 1 && diffDays <= 7) {
it.dueText = `${due.getMonth() + 1}月${due.getDate()}日${timePart}`;
} else {
it.dueText = `${due.getMonth() + 1}/${due.getDate()}${timePart}`;
}
}
// 副行:截止时间 · 日历本(本名为空时只显示截止时间)
it.subText = it.calName !== '' ? `${it.dueText} · ${it.calName}` : it.dueText;
items.push(it);
}
data.todoListJson = JSON.stringify(items);
data.todoCount = undone.length;
data.todoTodayCount = withDue.filter((e: DisplayEvent): boolean =>
e.endTime >= todayStart && e.endTime < todayEnd).length;
// 今日到期条数也塞进 todayCount,让 2x2/2x4 复用同一套大数字排版
data.todayCount = data.todoCount;
LogUtil.write(`待办卡片数据刷新:未完成 ${undone.length} 条,今日到期 ${data.todoTodayCount} 条,列表输出 ${items.length} 条`);
} catch (err) {
LogUtil.write(`待办卡片数据刷新失败: ${JSON.stringify(err)}`);
}
return data;
}
}
+137
View File
@@ -18,6 +18,15 @@ export class DavColorEntry {
color: string = ''; // 规范化后的 #RRGGBB,可能为空
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
/**
* ⭐ COMP_SET 该集合支持的组件类型(来自 supported-calendar-component-set)。
* 'event' = 纯日历本(只有 VEVENT)
* 'todo' = 纯待办本(只有 VTODO)
* 'both' = 混合本(两者都有)
* 'unknown' = 服务器未声明该属性(老实现常见)→ 按日历对待,保持兼容
*/
comps: string = 'unknown';
}
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
@@ -25,6 +34,8 @@ export class DavCalendarEntry {
href: string = ''; // 完整 URL(已补全域名)
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
color: string = ''; // 规范化后的 #RRGGBB,可能为空
/** ⭐ COMP_SET 'event' 纯日历 / 'todo' 纯待办 / 'both' 混合 / 'unknown' 服务器未声明 */
comps: string = 'unknown';
}
/**
@@ -61,6 +72,8 @@ export class DavClient {
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'<d:current-user-privilege-set/>' +
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
@@ -104,6 +117,7 @@ export class DavClient {
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
}
entry.color = color;
entry.comps = DavClient.parseComps(block);
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
if (privSet !== '') {
@@ -123,6 +137,33 @@ export class DavClient {
}
}
/**
* ⭐ COMP_SET 解析 supported-calendar-component-set,归一成四类:
* event / todo / both / unknown
*
* 为什么 unknown 要单独存在:很多服务器(含自研实现)压根不返回这个属性。
* 这时不能武断判成"不是待办本",否则用户原本能用的日历会被当成待办、
* 或者反过来把纯日历标成待办 —— 保守按"日历"对待,不破坏既有行为。
*/
static parseComps(block: string): string {
const seg: string = DavClient.extractTag(block, 'supported-calendar-component-set');
if (seg === '') {
return 'unknown';
}
const event: boolean = /name\s*=\s*["']VEVENT["']/i.test(seg);
const todo: boolean = /name\s*=\s*["']VTODO["']/i.test(seg);
if (event && todo) {
return 'both';
}
if (todo) {
return 'todo';
}
if (event) {
return 'event';
}
return 'unknown';
}
/**
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
*
@@ -136,6 +177,7 @@ export class DavClient {
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
const out = new DavCalendarDiscovery();
@@ -197,6 +239,9 @@ export class DavClient {
entry.href = href.startsWith('http') ? href : origin + href;
entry.displayName = name;
entry.color = color;
entry.comps = DavClient.parseComps(block);
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
out.entries.push(entry);
}
return out;
@@ -250,6 +295,37 @@ export class DavClient {
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
}
/**
* GET 拉取单个资源原文,**失败抛错**(与 getRaw 相反)。
*
* 为什么需要严格版:待办写回要"先 GET 原文、改字段、再 PUT"。
* 若 GET 失败却拿到一个 `"HTTP 404"` 之类的字符串并当 ICS 内容 PUT 回去,
* 会把服务器上那条待办**覆盖成一句错误信息**(数据破坏)。
* 原 getRaw 保留原行为不动(诊断用,已有调用方)。
*/
static async getRawStrict(url: string, auth: string): Promise<string> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: http.RequestMethod.GET,
header: {
'Authorization': auth,
'Content-Type': 'text/calendar; charset=utf-8',
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 10000,
readTimeout: 20000,
expectDataType: http.HttpDataType.STRING
});
if (resp.responseCode < 200 || resp.responseCode >= 300) {
throw new DavHttpError(resp.responseCode, `拉取待办原文失败,服务器返回 ${resp.responseCode}`);
}
return resp.result as string;
} finally {
httpRequest.destroy();
}
}
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
static async getRaw(url: string, auth: string): Promise<string> {
const httpRequest = http.createHttp();
@@ -389,6 +465,52 @@ export class DavClient {
return DavClient.reportComponents(href, auth, 'VTODO', '');
}
/** PROPFIND Depth:1 列出某集合下所有资源的文件名(诊断用,不做组件过滤) */
static async listNames(href: string, auth: string): Promise<string[]> {
const body: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(href, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: body,
connectTimeout: 10000,
readTimeout: 30000
});
const xml: string = resp.result as string;
LogUtil.write(`PROPFIND诊断 状态=${resp.responseCode} 响应体=${xml.length}字符`);
const out: string[] = [];
const re = /<[\w-]*:?href>([^<]+)<\/[\w-]*:?href>/gi;
let m: RegExpExecArray | null = re.exec(xml);
while (m !== null) {
const h: string = m[1];
if (h.indexOf('.ics') >= 0) {
out.push(decodeURIComponent(h.substring(h.lastIndexOf('/') + 1)));
}
m = re.exec(xml);
}
return out;
} finally {
httpRequest.destroy();
}
}
/** 不带 comp-filter 的全量 calendar-query(诊断用) */
static async reportAll(href: string, auth: string): Promise<RemoteItem[]> {
const body: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' +
'<d:prop><d:getetag/><c:calendar-data/></d:prop>' +
'<c:filter><c:comp-filter name="VCALENDAR"/></c:filter>' +
'</c:calendar-query>';
return DavClient.reportRaw(href, auth, body, 'ALL');
}
/** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */
private static async reportComponents(href: string, auth: string,
compName: string, timeRange: string): Promise<RemoteItem[]> {
@@ -399,6 +521,12 @@ export class DavClient {
`<c:comp-filter name="${compName}">` +
timeRange +
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
return DavClient.reportRaw(href, auth, body, compName);
}
/** 通用 REPORT 执行器:传完整 body,返回解析后的资源列表 */
static async reportRaw(href: string, auth: string, body: string,
compName: string): Promise<RemoteItem[]> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(href, {
@@ -425,6 +553,8 @@ export class DavClient {
}
const xml: string = resp.result as string;
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
LogUtil.write(`REPORT-诊断(${compName}) 前200字符: ${xml.substring(0, 200)}`);
LogUtil.write(`REPORT-诊断(${compName}) 含VTODO=${xml.indexOf('BEGIN:VTODO') >= 0} 含VEVENT=${xml.indexOf('BEGIN:VEVENT') >= 0} 响应数=${(xml.match(/<[\w-]*:?response[\s>]/gi) || []).length}`);
const originMatch = /https?:\/\/[^/]+/i.exec(href);
const origin: string = originMatch !== null ? originMatch[0] : '';
const items: RemoteItem[] = [];
@@ -449,6 +579,13 @@ export class DavClient {
item.ics = block.substring(start, end + 'END:VCALENDAR'.length);
items.push(item);
}
LogUtil.write(`诊断 blocks=${blocks.length} items=${items.length} xml长=${xml.length}`);
for (let bi = 0; bi < blocks.length; bi++) {
const bb: string = blocks[bi];
if (!/<[\w-]*:?response[\s>]/i.test(bb)) { continue; }
const h: string = DavClient.extractTag(bb, "href");
LogUtil.write(`诊断 block${bi} len=${bb.length} href=[${h.substring(0,50)}] VCAL=${bb.indexOf("BEGIN:VCALENDAR")>=0} VTODO=${bb.indexOf("BEGIN:VTODO")>=0}`);
}
return items;
} finally {
httpRequest.destroy();
+458
View File
@@ -0,0 +1,458 @@
// entry/src/main/ets/common/DavDiscovery.ets
// ⭐ CalDAV 地址自动发现与智能补全(0.0.7)
//
// 解决的用户问题(见《CalDAV排查指南-405与读不出日历.md》):
// 用户习惯只填域名或 DAV 根,而 Nextcloud 等要求填「日历家目录」
// (/remote.php/dav/calendars/<用户名>/)。填错表现为"读不出日历"或 405。
// DAVx5 能用是因为它内置 .well-known 自动发现 —— 本模块补上这一能力。
//
// 发现顺序(逐级降级,任一成功即停):
// ① 用户填的地址原样探测(保持既有行为,兼容已能用的用户)
// ② .well-known/caldav(RFC 6764 标准;Nextcloud/Radicale/Baïkal 均支持)
// ③ 常见候选路径拼接(remote.php/dav/calendars/<用户名>/ 等)
//
// ⚠️ 设计约束:
// - 全部探测都是 PROPFIND + Depth:0,只判断「是不是能列出日历的端点」,不做副作用;
// - 单次探测失败不抛错(网络异常吞掉继续试下一个),保证不会因某个候选不可达而整体失败;
// - 最终由调用方用 listCalendars 校验(能列出 calendar 集合才算真成功)。
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { LogUtil } from './LogUtil';
/** 发现结果:拿到一个可用的"日历集合列表"端点 */
export class DiscoverResult {
ok: boolean = false;
url: string = ''; // 最终可用的端点 URL
via: string = ''; // 来源:direct / well-known / candidate / public
readOnly: boolean = false; // 是否只读(公开日历分享);本版本仅识别,不做写入
hint: string = ''; // 失败时给用户的可操作提示
}
/** 415/405 之外:用于判断"不是 DAV 端点"的状态码集合 */
const NOT_DAV_CODES: number[] = [404, 405, 501];
export class DavDiscovery {
// ───────────────────────── 公开工具:URL 形态识别 ─────────────────────────
/** 取 origin(scheme://host[:port]) */
static originOf(url: string): string {
const m = /^https?:\/\/[^/]+/i.exec(url.trim());
return m !== null ? m[0] : '';
}
/** 规范化:去掉末尾多余斜杠、去空白 */
static tidy(url: string): string {
let s: string = url.trim();
// 去掉查询与片段(发现阶段不保留)
s = s.replace(/[?#].*$/, '');
while (s.endsWith('/') && s.length > DavDiscovery.originOf(s).length + 1) {
s = s.substring(0, s.length - 1);
}
return s;
}
/**
* ⭐ 识别「网页分享链接」——这类地址对 PROPFIND 一律 405,必须提前拦下并提示。
* 覆盖:
* - Nextcloud 日历分享页:/apps/calendar/p/<token>
* - Nextcloud 文件分享页:/s/<token>、/index.php/s/<token>
* - Nextcloud 公开日历(只读,可用):/remote.php/dav/public-calendars/<token>
*/
static classify(url: string): string {
const u: string = url.trim();
const path = u.replace(/^https?:\/\/[^/]+/i, '');
if (/^\/apps\/calendar\/p\//i.test(path)) {
return 'share_calendar'; // 日历分享页面(网页)
}
if (/^\/(index\.php\/)?s\//i.test(path)) {
return 'share_file'; // 文件分享页面(网页)
}
if (/^\/remote\.php\/dav\/public-calendars\//i.test(path)) {
return 'public_caldav'; // 公开日历(DAV,只读)
}
if (/^\/remote\.php\/dav\/public-calendars$/i.test(path.replace(/\/$/, ''))) {
return 'public_caldav';
}
return 'normal';
}
/** 分享链接 → 给用户的人话解释 */
static shareHint(kind: string): string {
if (kind === 'share_calendar') {
return '这是 Nextcloud 的「日历分享链接」(网页地址),CalDAV 客户端不能直接使用。'
+ '请在 Nextcloud 网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」,'
+ '把得到的私有链接填到「服务器地址」。';
}
if (kind === 'share_file') {
return '这是文件分享链接(网页地址),不是 CalDAV 地址。'
+ '请改用日历的「私有链接」:Nextcloud 网页版「日历 → 设置 → 复制私有链接」。';
}
return '';
}
/**
* 是否为"只可能返回 405/404 的网页地址"——用于在探测前直接拦截,省一次请求。
*/
static isWebPage(url: string): boolean {
const k: string = DavDiscovery.classify(url);
return k === 'share_calendar' || k === 'share_file';
}
/**
* DAV 根 / 站点根 → 需要补全。用于判断是否值得走候选路径。
* `/remote.php/dav` 或仅 origin 时,多半缺"日历家目录"这一层。
*/
static looksIncomplete(url: string): boolean {
const path: string = DavDiscovery.tidy(url).replace(/^https?:\/\/[^/]+/i, '').replace(/\/$/, '');
if (path === '' || path === '/') {
return true; // 只填了域名
}
if (/\/remote\.php\/dav$/i.test(path)) {
return true; // DAV 根
}
if (/\/remote\.php\/dav\/(calendars|addressbooks)$/i.test(path)) {
return true; // 少了用户名一层
}
return false;
}
// ───────────────────────── 探测原语 ─────────────────────────
/**
* PROPFIND Depth:0 探测端点:只关心"能不能列、是不是 DAV"。
* 不抛错:网络异常/状态码异常都返回可判定的结果。
*/
static async probe(url: string, auth: string): Promise<number> {
const body: string =
'<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '0',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: body,
connectTimeout: 8000,
readTimeout: 12000
});
LogUtil.write(`HTTP PROPFIND(发现) ${url} → ${resp.responseCode}`);
return resp.responseCode;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`PROPFIND(发现) ${url} 异常: ${e.code} - ${e.message}`);
return 0; // 0 = 网络层失败
} finally {
httpRequest.destroy();
}
}
/**
* ⭐ OPTIONS 探测服务器的 DAV 能力:
* - 返回 `DAV:` 响应头 → 标准 WebDAV
* - 返回 `Allow` 里含 PROPFIND/REPORT → 支持 CalDAV 所需方法
* 用于区分"这不是 DAV 端点"(如网页路由)与"服务端把方法挡了"。
*/
static async optionsCapability(url: string, auth: string): Promise<DavCapability> {
const cap = new DavCapability();
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: http.RequestMethod.OPTIONS,
header: {
'Authorization': auth,
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 8000,
readTimeout: 12000
});
cap.status = resp.responseCode;
const headers = resp.header as Record<string, string>;
if (headers !== undefined && headers !== null) {
// 头名大小写不统一,逐个大小写变体试
const dav = headers['DAV'] ?? headers['Dav'] ?? headers['dav'] ?? '';
const allow = headers['Allow'] ?? headers['allow'] ?? '';
cap.davHeader = typeof dav === 'string' ? dav : '';
cap.allow = typeof allow === 'string' ? allow : '';
cap.isDav = cap.davHeader !== '' || /PROPFIND/i.test(cap.allow);
cap.supportsPropfind = /PROPFIND/i.test(cap.allow) || cap.isDav;
cap.supportsReport = /REPORT/i.test(cap.allow);
// Allow 明确列出但两者都没有 → 大概率不是 DAV
if (cap.allow !== '' && !cap.supportsPropfind) {
cap.isDav = false;
}
}
LogUtil.write(`OPTIONS ${url} → ${resp.responseCode},DAV=${cap.davHeader || '(无)'},Allow=${cap.allow || '(无)'}`);
return cap;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`OPTIONS ${url} 异常: ${e.code} - ${e.message}`);
return cap;
} finally {
httpRequest.destroy();
}
}
// ───────────────────────── 候选路径生成 ─────────────────────────
/**
* 生成候选「日历家目录 / 集合列表」端点。
* @param base 用户填入并规范化后的地址
* @param username 用户名(用于 Nextcloud 的 calendars/<用户名>/ 等模式)
*/
static candidates(base: string, username: string): string[] {
const origin: string = DavDiscovery.originOf(base);
const out: string[] = [];
if (origin === '') {
return out;
}
const path: string = DavDiscovery.tidy(base).substring(origin.length);
// 站点可能部署在子目录(如 /nextcloud)——保留用户填的前缀,只补 DAV 相关部分
// 例:填 https://h/nextcloud → 前缀 /nextcloud
let prefix: string = '';
const idx: number = path.toLowerCase().indexOf('/remote.php');
if (idx >= 0) {
prefix = path.substring(0, idx);
} else if (path !== '' && path !== '/') {
prefix = path.replace(/\/$/, '');
}
const u: string = encodeURIComponent(username);
const push = (p: string): void => {
const full: string = origin + p;
if (out.indexOf(full) < 0 && DavDiscovery.tidy(full) !== DavDiscovery.tidy(base)) {
out.push(full);
}
};
// Nextcloud / ownCloud
push(`${prefix}/remote.php/dav/calendars/${u}/`);
push(`${prefix}/remote.php/dav`);
// Radicale(通常直接就是 /<用户名>/)
push(`${prefix}/${u}/`);
// Baïkal
push(`${prefix}/dav.php/calendars/${u}/`);
// 通用:直接在根下找
push(`${prefix}/`);
return out;
}
/**
* ⭐ 主入口:在给定端点集合上依次探测,返回第一个"能列出日历"的端点。
* 判据=PROPFIND 得 207/200。真正的"日历集合筛选"仍由 DavClient.listCalendars 完成
* (本方法只负责找到"哪个地址对",避免与既有解析逻辑重复)。
*
* @param rawUrl 用户填入的原始地址(未规范化也可)
* @param username 用户名
* @param auth Authorization 头
* @param verify 可选的校验函数:传入端点,返回是否真的能列出日历(由调用方注入 listCalendars)
*/
static async resolve(rawUrl: string, username: string, auth: string,
verify?: (url: string) => Promise<boolean>): Promise<DiscoverResult> {
const res = new DiscoverResult();
const base: string = DavDiscovery.tidy(rawUrl);
// ① 网页分享链接:直接拦下,不浪费请求
const kind: string = DavDiscovery.classify(base);
if (kind === 'share_calendar' || kind === 'share_file') {
res.ok = false;
res.hint = DavDiscovery.shareHint(kind);
LogUtil.write(`发现:识别为网页分享链接,已拦截(${kind})`);
return res;
}
const check = async (url: string, via: string): Promise<boolean> => {
const code: number = await DavDiscovery.probe(url, auth);
if (code !== 207 && code !== 200) {
return false;
}
if (verify !== undefined) {
const good: boolean = await verify(url);
if (!good) {
return false;
}
}
res.ok = true;
res.url = url;
res.via = via;
res.readOnly = (kind === 'public_caldav');
LogUtil.write(`发现成功(${via}):${url}`);
return true;
};
// ① 用户填的地址优先(保持既有行为)
if (await check(base, 'direct')) {
return res;
}
// ①b ⭐ 公开日历(只读订阅):Nextcloud 的 public-calendars 链接本身即端点。
// 此类端点通常无需认证,且只能读——后续由 SyncEngine 的写权限探测自动标记为只读。
if (kind === 'public_caldav') {
// 若用户填的是 public-calendars 根(少了 token),无法继续推断,直接给提示
res.ok = false;
res.hint = '这是「公开日历」链接但似乎不完整。请使用完整的公开日历地址'
+ '(形如 https://你的域名/remote.php/dav/public-calendars/<令牌>/),'
+ '或改用日历的「私有链接」以获得可读可写体验。';
return res;
}
// ② .well-known/caldav(RFC 6764)——标准自动发现
const origin: string = DavDiscovery.originOf(base);
if (origin !== '') {
const wk: string = await DavDiscovery.resolveWellKnown(origin, auth);
if (wk !== '') {
if (await check(wk, 'well-known')) {
return res;
}
// .well-known 给了地址但直接探测不通过 → 也纳入候选由 ③ 继续尝试其下级
LogUtil.write(`.well-known 指向 ${wk},但该地址未通过日历校验,继续尝试常见路径`);
}
}
// ③ 常见候选路径
const cands: string[] = DavDiscovery.candidates(base, username);
for (const c of cands) {
if (await check(c, 'candidate')) {
return res;
}
}
// 全部失败 → 给可操作提示(区分"不是 DAV 端点"与"能连但列不出日历")
res.ok = false;
const cap: DavCapability = await DavDiscovery.optionsCapability(base, auth);
if (cap.status === 405 || (!cap.isDav && cap.allow !== '' && !cap.supportsPropfind)) {
res.hint = '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。'
+ '常见原因是填了网页分享链接、或只填了站点的 DAV 根。'
+ 'Nextcloud 请填「日历 → 设置 → 复制私有链接」得到的地址,例如:'
+ '`https://你的域名/remote.php/dav/calendars/你的用户名/`';
} else if (cap.status === 401) {
res.hint = '服务器拒绝凭据(401),请检查用户名与密码。'
+ 'Nextcloud 若开启了二次验证,需要使用「应用密码」。';
} else if (cap.status === 301 || cap.status === 302) {
res.hint = '服务器发生了跳转,通常说明 `.well-known/caldav` 未正确配置'
+ '(常见于反向代理或子目录部署)。可请 NAS 管理员把 `.well-known/caldav` '
+ '指向 `/remote.php/dav`,或直接填写完整的「日历家目录」地址。';
} else {
res.hint = '已连接上服务器,但在该地址下未发现任何日历本。'
+ '请确认填的是「日历家目录」而不是 DAV 根,例如:'
+ '`https://你的域名/remote.php/dav/calendars/你的用户名/`';
}
return res;
}
/**
* 解析 .well-known/caldav:
* - 直接返回 207/200 → 该 URL 本身即端点
* - 返回 301/302/307/308 → 读 Location 头作为端点
* - 返回 401 时也读 Location(Nextcloud 常在认证后跳转)
*/
static async resolveWellKnown(origin: string, auth: string): Promise<string> {
const url: string = `${origin}/.well-known/caldav`;
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '0',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',
connectTimeout: 8000,
readTimeout: 12000
});
LogUtil.write(`HTTP PROPFIND(.well-known) ${url} → ${resp.responseCode}`);
if (resp.responseCode === 207 || resp.responseCode === 200) {
return url;
}
if (resp.responseCode === 301 || resp.responseCode === 302
|| resp.responseCode === 307 || resp.responseCode === 308 || resp.responseCode === 401) {
const headers = resp.header as Record<string, string>;
if (headers !== undefined && headers !== null) {
const loc = headers['Location'] ?? headers['location'] ?? '';
if (typeof loc === 'string' && loc !== '') {
const full: string = loc.startsWith('http') ? loc : origin + (loc.startsWith('/') ? '' : '/') + loc;
LogUtil.write(`.well-known 跳转到:${full}`);
return DavDiscovery.tidy(full);
}
}
}
return '';
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`.well-known 探测异常: ${e.code} - ${e.message}`);
return '';
} finally {
httpRequest.destroy();
}
}
/** 状态码是否表示"该地址不是 DAV 端点" */
static isNotDav(code: number): boolean {
return NOT_DAV_CODES.indexOf(code) >= 0;
}
// ───────────────────────── 给用户的"人话提示"(两个页面共用) ─────────────────────────
/**
* ⭐ 把 HTTP 状态码翻译成"人话 + 可操作指引"。
* 用户看到"服务器返回 405"无从下手,这里直接告诉他下一步做什么。
*/
static statusHint(code: number): string {
if (code === 405) {
return '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
+ '常见原因:填了「分享链接」(网页地址),或只填到 DAV 根。\n'
+ '请填「日历家目录」,例如:\n'
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
+ 'Nextcloud 可在网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」拿到这个地址。';
}
if (code === 401) {
return '服务器拒绝凭据(401):请检查用户名与密码。\n'
+ '若服务器开启了二次验证(2FA),需要使用「应用密码」而不是登录密码。';
}
if (code === 403) {
return '服务器拒绝访问(403):账号可能无权限,或 IP 被限制。请检查账号权限与服务器访问限制。';
}
if (code === 404) {
return '地址不存在(404):请检查路径是否写错。\n'
+ '正确形式通常为 https://你的域名/remote.php/dav/calendars/你的用户名/';
}
if (code === 301 || code === 302 || code === 307 || code === 308) {
return '服务器发生跳转:通常说明 `.well-known/caldav` 未正确配置(常见于反向代理或子目录部署)。\n'
+ '请直接填写完整的「日历家目录」地址,或请 NAS 管理员配置 `.well-known/caldav`。';
}
if (code === 0) {
return '无法连接服务器:请确认地址、端口与网络(内网地址需在同一局域网)。';
}
return `获取日历列表失败,服务器返回:${code}`;
}
/** ⭐ 能连上但"未发现日历本"时的提示:区分"地址差一层"与"确实是空的" */
static emptyHint(url: string, username: string): string {
if (DavDiscovery.looksIncomplete(url)) {
const cands: string[] = DavDiscovery.candidates(url, username);
const example: string = cands.length > 0 ? cands[0] : '';
return '该地址下没有日历本 —— 多半是地址「少了一层」。\n\n'
+ '你填的是服务器根路径,但 CalDAV 需要填到「日历家目录」。请试试:\n'
+ (example !== '' ? example + '\n\n' : '\n')
+ '(Nextcloud 可在网页版「日历 → 设置 → 复制私有链接」直接拿到)';
}
return '该地址下未发现日历本。请确认:\n'
+ '1) 服务器上确实已创建日历;\n'
+ '2) 填的是「日历家目录」(形如 .../remote.php/dav/calendars/用户名/)而不是 DAV 根。';
}
}
/** OPTIONS 探测结果 */
export class DavCapability {
status: number = 0;
davHeader: string = ''; // `DAV:` 响应头(含 "1", "2", "calendar-access" 等)
allow: string = ''; // `Allow` 头
isDav: boolean = false;
supportsPropfind: boolean = false;
supportsReport: boolean = false;
}
+295 -11
View File
@@ -24,13 +24,30 @@ export class LocalEvent {
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
/** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。
* 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**,
* 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */
dtstart: number = 0;
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
recurrenceId: number = 0;
/**
* 优先级,用 **iCal 原生值**:1=最高 … 9=最低,0=未设置。
* 直接存原生值,写回时零转换,也不会与服务器上其他客户端写下的值语义不一致。
*/
priority: number = 0;
/** 分类标签,逗号分隔(对应 iCal CATEGORIES,多值用逗号连接) */
categories: string = '';
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
/** 父任务 UID(空 = 顶层任务)。对应 VTODO 的 RELATED-TO:RELTYPE=PARENT。
* 用 UID 而非本地 id:父子关系是服务器资源属性,跨设备同步时 UID 才稳定。 */
parentUid: string = '';
/** 附件 JSON 数组字符串:[{name,mime,size,base64}],空 = 无附件。
* base64 直接存库,同步时拼 ATTACH 行零转换。 */
attachments: string = '';
}
/** 远端事件条目(REPORT 解析结果) */
@@ -40,6 +57,10 @@ export class RemoteEvent {
title: string = '';
description: string = '';
location: string = '';
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
priority: number = 0;
/** 分类标签,逗号分隔(对应 CATEGORIES) */
categories: string = '';
startTime: number = 0;
endTime: number = 0;
isAllDay: boolean = false;
@@ -47,10 +68,24 @@ export class RemoteEvent {
isTodo: boolean = false; // VTODO 待办
completed: boolean = false; // VTODO STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE
dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开
exdate: string = ''; // 原始 EXDATE(分号分隔)
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
reminders: number[] = []; // 全部提醒提前分钟数(多选)
/** 父任务 UID(空 = 顶层任务)。 */
parentUid: string = '';
/** 附件 JSON 数组字符串(含 base64),空 = 无附件。 */
attachments: string = '';
/**
* ⭐ REMOTE_PATH 服务器上的真实资源文件名(REPORT 返回的 href 末段,已含 URL 编码)。
*
* 为什么不能用 uid 拼:服务器不保证「资源文件名 = ICS 里的 UID」。
* 家书 CalDav.php 用 `preg_replace('/[^A-Za-z0-9._-]/', '_', $uid)` 生成文件名,
* UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics`。
* 猜错 ⇒ DELETE/PUT 打到不存在的路径 ⇒ 404,服务器上删不掉、或写出重复资源。
*/
remotePath: string = '';
}
export class EventDb {
@@ -124,6 +159,31 @@ export class EventDb {
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN parent_uid TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN attachments TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql('ALTER TABLE events ADD COLUMN priority INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN categories TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
} catch (err) {
@@ -181,12 +241,16 @@ export class EventDb {
const e = new LocalEvent();
e.id = rs.getLong(rs.getColumnIndex('id'));
e.uid = rs.getString(rs.getColumnIndex('uid'));
e.dtstart = rs.getLong(rs.getColumnIndex('dtstart'));
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
e.href = rs.getString(rs.getColumnIndex('href'));
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
e.title = rs.getString(rs.getColumnIndex('title'));
e.description = rs.getString(rs.getColumnIndex('description'));
e.location = rs.getString(rs.getColumnIndex('location'));
e.priority = rs.getLong(rs.getColumnIndex('priority'));
const cats: string = rs.getString(rs.getColumnIndex('categories'));
e.categories = cats === undefined || cats === null ? '' : cats;
e.startTime = rs.getLong(rs.getColumnIndex('start_time'));
e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
@@ -202,6 +266,10 @@ export class EventDb {
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
const pUid: string = rs.getString(rs.getColumnIndex('parent_uid'));
e.parentUid = (pUid === undefined || pUid === null) ? '' : pUid;
const att: string = rs.getString(rs.getColumnIndex('attachments'));
e.attachments = (att === undefined || att === null) ? '' : att;
const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
if (remStr !== '') {
e.reminders = remStr.split(',').map((s: string): number => Number(s))
@@ -234,7 +302,11 @@ export class EventDb {
'exdate': e.exdate,
'recurrence_id': e.recurrenceId,
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
'reminders': e.reminders.map((n: number): string => String(n)).join(',')
'reminders': e.reminders.map((n: number): string => String(n)).join(','),
'priority': e.priority,
'categories': e.categories,
'parent_uid': e.parentUid,
'attachments': e.attachments
};
return bucket;
}
@@ -336,6 +408,90 @@ export class EventDb {
}
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
/** 保存循环规则与基准(设置/取消循环后调用) */
static async updateTodoRecurrence(context: common.Context, id: number,
rrule: string, dtstart: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'rrule': rrule,
'dtstart': dtstart,
'recurring': rrule !== '' ? 1 : 0,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
/**
* 循环待办"完成一轮"后更新本地:推后的基准/截止 + 清除完成态。
*
* ⚠️ 必须同时把 completed 置 0、dirty 清掉:
* 否则界面会显示成"已完成",而服务器上其实是"下一轮未完成",两边不一致。
*/
static async updateTodoAfterRound(context: common.Context, id: number,
dtstart: number, dueMs: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'dtstart': dtstart,
'start_time': dueMs,
'end_time': dueMs,
'completed': 0,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
/** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */
static async updateTodoFields(context: common.Context, id: number, title: string,
desc: string, dueMs: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'title': title,
'description': desc,
'start_time': dueMs,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
static async setTodoCompleted(context: common.Context, id: number, completed: boolean,
etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'completed': completed ? 1 : 0,
'dirty': 0,
'etag': etag
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
static async queryById(context: common.Context, id: number): Promise<LocalEvent | null> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
const rs = await store.query(predicates);
try {
if (rs.goToNextRow()) {
return EventDb.fromRow(rs);
}
return null;
} finally {
rs.close();
}
}
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
@@ -377,6 +533,72 @@ export class EventDb {
}
/** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */
/**
* 查"未来窗口内需要提醒的未完成待办"(#38)。
*
* 与 queryRemindable(日程)的区别:
* - `kind='todo'` 而不是 'event'
* - **必须 `completed=0`** —— 已完成的待办不该再响
* - 待办的提醒基准同样是 start_time(loadTodos 把 DUE 存进 startTime)
*/
/**
* 单独更新待办的子任务/附件属性(updateTodoFields 只管标题/备注/截止)。
* @param touchParent true=写 parent_uid(空串=清空),false=不动
* @param touchAttach true=写 attachments(空串=清空),false=不动
*/
static async setTodoHierarchy(context: common.Context, id: number, parentUid: string,
attachments: string, touchParent: boolean, touchAttach: boolean): Promise<void> {
if (!touchParent && !touchAttach) {
return;
}
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {};
if (touchParent) {
(bucket as Record<string, string>)['parent_uid'] = parentUid;
}
if (touchAttach) {
(bucket as Record<string, string>)['attachments'] = attachments;
}
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/** 单独更新待办的提醒列表(updateTodoFields 只管标题/备注/截止三字段) */
static async setTodoReminders(context: common.Context, id: number,
reminders: number[]): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'reminder': reminders.length > 0 ? reminders[0] : 0,
'reminders': reminders.map((n: number): string => String(n)).join(',')
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
static async queryRemindableTodos(context: common.Context, from: number,
to: number): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('deleted', 0).and().equalTo('kind', 'todo')
.and().equalTo('completed', 0)
.and().greaterThan('reminder', 0)
.and().greaterThanOrEqualTo('start_time', from)
.and().lessThanOrEqualTo('start_time', to)
.orderByAsc('start_time').limitAs(50);
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
list.push(EventDb.fromRow(rs));
}
} finally {
rs.close();
}
return list;
}
static async queryRemindable(context: common.Context, from: number, to: number): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
@@ -425,7 +647,37 @@ export class EventDb {
rs.close();
}
const remoteKeys: string[] = [];
// ⭐ 2226(2026-10-15 提速):下方逐条要判"这条远端事件本地有没有对应行"。
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
// dirty 行不入 `keyed`、同键取**最先**出现的那行。
// ⭐ 但仅排除出 keyed不够(见下方 REVIVE_GUARD):还需单独索引 dirty 行并主动拦截,
// 否则远端数据查不到 key 会走新增分支,反而插出重复行。
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
// ⭐ REVIVE_GUARD dirty 行**单独**建索引,而不是只从 keyed 里排除。
// 只排除不够:远端数据 keyed 查不到 → 走"新增"分支 → 又插一行,
// 本地就出现两行(用户改的 + 远端旧版),表现为"改完又变回去/出现两条"。
const dirtyKeyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
// ⭐ 本地已标记删除、还没推给服务器的 uid 集合。
// 按 uid 而非 key:删重复系列时本地只留主事件标 deleted,
// 但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住。
const dirtyDeletedUids: Set<string> = new Set<string>();
for (const e of existing) {
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
if (e.dirty && e.deleted) {
dirtyDeletedUids.add(e.uid);
}
if (e.dirty) {
if (!dirtyKeyed.has(k)) {
dirtyKeyed.set(k, e);
}
continue;
}
if (!keyed.has(k)) {
keyed.set(k, e);
}
}
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
@@ -438,17 +690,23 @@ export class EventDb {
continue; // 同一资源内的重复条目 → 只保留一条
}
seenKeys.add(key);
remoteKeys.push(key);
const found = existing.find((x: LocalEvent): boolean =>
!x.dirty && x.uid === r.uid && x.startTime === r.startTime
&& x.recurrenceId === r.recurrenceId);
// ⭐ REVIVE_GUARD 本地已删待推送 → 远端旧版本一律忽略,防止死灰复燃
if (dirtyDeletedUids.has(r.uid)) {
continue;
}
// ⭐ 本地有未推送的修改 → 保留本地版本,绝不能让远端旧版覆盖或重复插入
if (dirtyKeyed.has(key)) {
continue;
}
const found: LocalEvent | undefined = keyed.get(key);
if (found === undefined) {
// 新增
const e = new LocalEvent();
e.uid = r.uid;
e.calKey = calKey;
e.href = href;
e.remotePath = encodeURIComponent(r.uid) + '.ics';
// ⭐ REMOTE_PATH 优先用服务器真实文件名;为空时才退回按 uid 猜(本地新建等场景)
e.remotePath = r.remotePath !== '' ? r.remotePath : (encodeURIComponent(r.uid) + '.ics');
e.title = r.title;
e.description = r.description;
e.location = r.location;
@@ -466,17 +724,27 @@ export class EventDb {
e.recurrenceId = r.recurrenceId;
e.reminder = r.reminder;
e.reminders = r.reminders;
e.priority = r.priority;
e.categories = r.categories;
e.parentUid = r.parentUid;
e.attachments = r.attachments;
await store.insert('events', EventDb.toBucket(e));
added++;
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|| found.reminder !== r.reminder || found.recurring !== r.recurring
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
|| found.priority !== r.priority || found.categories !== r.categories
|| found.parentUid !== r.parentUid || found.attachments !== r.attachments
|| found.href !== href) {
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
found.href = href;
// 服务器上资源被改名时同步更新,否则后续 DELETE/PUT 会打到旧路径
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
found.remotePath = r.remotePath;
}
found.title = r.title;
found.description = r.description;
found.location = r.location;
@@ -494,19 +762,34 @@ export class EventDb {
found.recurrenceId = r.recurrenceId;
found.reminder = r.reminder;
found.reminders = r.reminders;
found.priority = r.priority;
found.categories = r.categories;
found.parentUid = r.parentUid;
found.attachments = r.attachments;
const up = new relationalStore.RdbPredicates('events');
up.equalTo('id', found.id);
await store.update(EventDb.toBucket(found), up);
updated++;
} else {
unchanged++;
// etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中,
// 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释)
// ⭐ UNCHANGED_REMOTE_PATH etag 未变是**常态路径**(REPORT 只给 etag,
// 未变就直接复用本地行、不 GET)。实测 742 行里 99% 走这里。
// 若只在"更新分支"回填 remotePath,对存量数据等于无效 ——
// 历史行里存的是旧的 uid 猜测值,而 DELETE/PUT 正是用它拼 URL,
// 拼错就 404(服务器上删不掉)。
// 所以这里必须一并校正 href 与 remotePath。
const hb: relationalStore.ValuesBucket = {};
if (found.href !== href) {
found.href = href;
hb['href'] = href;
}
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
found.remotePath = r.remotePath;
hb['remote_path'] = r.remotePath;
}
if (Object.keys(hb).length > 0) {
const hp = new relationalStore.RdbPredicates('events');
hp.equalTo('id', found.id);
const hb: relationalStore.ValuesBucket = { 'href': href };
await store.update(hb, hp);
}
}
@@ -517,7 +800,8 @@ export class EventDb {
continue;
}
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');
del.equalTo('id', local.id);
await store.delete(del);
File diff suppressed because it is too large Load Diff
+31 -4
View File
@@ -1,30 +1,57 @@
// entry/src/main/ets/common/LogUtil.ets
// 同步日志:写入应用 filesDir/sync.log(含时间戳),超 512KB 自动截断保留后半。
// 设置页「同步日志」卡片可查看/复制/清空,方便真机测试时诊断同步与提醒问题。
// 日志工具:默认**只输出到 console(hilog)**,不落盘。
//
// ⭐ 0.0.8 起:发行版**不再写 sync.log**(原「同步记录」/诊断落盘功能已移除)。
// 需要真机排查同步/提醒问题时,把下面的 ENABLE_FILE_LOG 改成 true 重新编译即可恢复
// 落盘(含 512KB 自动截断保留后半),其余调用点一行都不用动。
// 关闭状态下会在 init 时顺手删掉历史版本遗留的 sync.log 与 mirror_diag.txt,
// 保证升级上来的设备沙箱也是干净的。
import { common } from '@kit.AbilityKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
export class LogUtil {
/** ⭐ 落盘开关:发行版置 false(仅 console);真机排查时改 true 恢复文件日志 */
private static readonly ENABLE_FILE_LOG: boolean = false;
private static dir: string = '';
private static path: string = '';
private static readonly MAX_BYTES: number = 512 * 1024;
static init(context: common.Context): void {
try {
LogUtil.path = `${context.filesDir}/sync.log`;
LogUtil.dir = context.filesDir;
LogUtil.path = `${LogUtil.dir}/sync.log`;
} catch (err) {
LogUtil.dir = '';
LogUtil.path = '';
}
if (!LogUtil.ENABLE_FILE_LOG && LogUtil.dir !== '') {
LogUtil.removeLegacyDebugFiles();
}
}
/** 删除历史版本遗留的调试落盘文件(sync.log / mirror_diag.txt) */
private static removeLegacyDebugFiles(): void {
for (const name of ['sync.log', 'mirror_diag.txt']) {
try {
fs.unlinkSync(`${LogUtil.dir}/${name}`);
} catch (err) {
// 文件不存在属正常,忽略
}
}
}
static logFilePath(): string {
return LogUtil.path;
}
/** 写一行日志:console + 文件(文件失败静默,不影响业务) */
/** 写一行日志:console + 文件(发行版不落盘;文件失败静默,不影响业务) */
static write(msg: string): void {
console.info(`SyncLog ${msg}`);
if (!LogUtil.ENABLE_FILE_LOG) {
return;
}
if (LogUtil.path === '') {
return;
}
+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(
context: common.Context,
cm: calendarManager.CalendarManager,
@@ -109,8 +128,7 @@ export class MirrorInbound {
}
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
const events: calendarManager.Event[] = await cal.getEvents(
undefined, MirrorInbound.QUERY_FIELDS);
const events: calendarManager.Event[] = await MirrorInbound.queryEvents(cal);
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
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);
}
}
@@ -0,0 +1,80 @@
// entry/src/main/ets/common/NetworkSyncWatcher.ets
// ⭐ 网络恢复补同步:从"无网"变为"有网"时立刻同步一次。
//
// 为什么这条最有价值(《定时同步方案调研.md》第 ④ 层):
// 延迟任务最短 2 小时、还会随应用活跃度降级,长时间在外(飞行模式/地铁/弱网)期间
// 基本不会跑。用户一旦回到有网环境,**立刻对齐一次**,才能做到
// "我要看的时候数据是最新的"。这比追求"进程常驻"实际得多。
//
// 实现:net.connection 的 netAvailable / netLost 事件 + 去抖,避免网络抖动时连打多次。
import { connection } from '@kit.NetworkKit';
import { common } from '@kit.AbilityKit';
import { BackgroundSyncRunner } from './BackgroundSyncRunner';
import { LogUtil } from './LogUtil';
/** 网络恢复后的去抖间隔:避免弱网下反复"断开→恢复"导致连续同步 */
const DEBOUNCE_MS: number = 20 * 1000;
export class NetworkSyncWatcher {
private static netConn: connection.NetConnection | null = null;
/** 上一次触发同步的时间,用于去抖 */
private static lastTriggerAt: number = 0;
/** 当前是否处于"无网"状态(只有从无网恢复才触发,避免频繁切换网络时误触发) */
private static offline: boolean = false;
/** 开始监听网络变化(幂等:重复调用不会重复注册) */
static start(context: common.UIAbilityContext): void {
if (NetworkSyncWatcher.netConn !== null) {
return;
}
try {
const conn: connection.NetConnection = connection.createNetConnection();
conn.on('netLost', (): void => {
NetworkSyncWatcher.offline = true;
LogUtil.write('网络断开:标记为无网状态');
});
conn.on('netAvailable', (): void => {
// ⭐ 只有"从无网恢复"才补同步:单纯切换 Wi-Fi/蜂窝时不重复触发
if (!NetworkSyncWatcher.offline) {
return;
}
NetworkSyncWatcher.offline = false;
NetworkSyncWatcher.triggerDebounced(context, 'netRecover');
});
conn.register((): void => {
LogUtil.write('网络监听已注册(网络恢复补同步)');
});
NetworkSyncWatcher.netConn = conn;
} catch (err) {
LogUtil.write(`网络监听注册失败:${(err as Error).message}`);
}
}
/** 停止监听(一般不需要,App 存活期间持续有效) */
static stop(): void {
if (NetworkSyncWatcher.netConn === null) {
return;
}
try {
NetworkSyncWatcher.netConn.unregister((err: object): void => {
// 忽略取消失败
});
} catch (err) {
// 忽略
}
NetworkSyncWatcher.netConn = null;
}
/** 去抖后触发一次同步 */
private static triggerDebounced(context: common.UIAbilityContext, tag: string): void {
const now: number = Date.now();
if (now - NetworkSyncWatcher.lastTriggerAt < DEBOUNCE_MS) {
LogUtil.write(`网络恢复补同步:距上次过快(<20s),跳过`);
return;
}
NetworkSyncWatcher.lastTriggerAt = now;
LogUtil.write('网络已恢复 → 触发补同步');
// 不 await:事件回调里不能阻塞;BackgroundSyncRunner 内部全 try-catch,不会抛出
BackgroundSyncRunner.runOnce(context, tag);
}
}
+404
View File
@@ -0,0 +1,404 @@
// entry/src/main/ets/common/PlaceParser.ets
// 从剪贴板文本/链接中解析出地点:支持高德分享链接(含坐标)与微信复制的地址文本(无坐标)。
//
// 支持的四种链接形态(均实测过):
// ① poi/detail ?__p=<poiid>,<lat>,<lng>,<name>,<address> —— 坐标在 query
// ② share/index /share/index/__q=<lat>,<lng>,<name> —— ⚠️ 坐标在 PATH 不在 query
// ③ place /place/<poiid>?lng=<lng>&lat=<lat> —— 显式命名
// ④ surl 短链 surl.amap.com/<code> —— 需跟随 302 取 Location
//
// ⚠️ 坐标系与顺序(最容易搞错的地方):
// - 高德所有形态都是「纬度在前、经度在后」,而国内地图复制坐标常是「经度在前」。
// 搞反会定位到几百公里外。本解析器统一用合法范围校验纠偏:
// 纬度绝对值必然 ≤90,若两值都在 90 内则按国旗惯例「纬在前」处理。
// - 微信/高德输出的都是 GCJ-02(火星坐标),与 Site Kit 一致,**不需要坐标转换**。
// 只有照片 EXIF 的 WGS-84 才需要转,本解析器不处理该场景。
import { http } from '@kit.NetworkKit';
import { LogUtil } from './LogUtil';
/** 解析结果 */
export class ParsedPlace {
/** 地点名称(可能为空) */
name: string = '';
/** 详细地址(可能为空) */
address: string = '';
/** 纬度(0 表示无坐标) */
lat: number = 0;
/** 经度(0 表示无坐标) */
lng: number = 0;
/** 是否有有效坐标 */
hasCoord: boolean = false;
/** 命中方式,便于排查:amap-p / amap-q / amap-place / amap-surl / wechat-text / bare-address */
source: string = '';
/** 用于填进地点栏的文本:有名称用「名称」,否则用地址 */
get primaryText(): string {
return this.name !== '' ? this.name : this.address;
}
}
export class PlaceParser {
/** 短链展开超时(毫秒)。网络不好时不值得让用户干等 */
private static readonly SHORTLINK_TIMEOUT: number = 5000;
/** 最多跟随几次跳转,防止死循环 */
private static readonly MAX_REDIRECT: number = 5;
/**
* 解析入口:给一段文本,判断是链接还是地址文本,返回地点信息。
* 无法识别时返回 null(调用方应保持原有输入,不阻塞用户)。
*/
static async parse(text: string, context?: object): Promise<ParsedPlace | null> {
const t: string = (text === undefined || text === null) ? '' : text.trim();
if (t === '') {
return null;
}
// 先尝试按链接解析(含短链展开,需要网络)
const fromUrl: ParsedPlace | null = await PlaceParser.parseUrl(t);
if (fromUrl !== null) {
return fromUrl;
}
// 再按纯文本地址解析(无需网络)
return PlaceParser.parseText(t);
}
// ==================== 链接解析 ====================
/**
* 裁剪 URL 尾部:只裁"多出来"的右括号与中文标点,保留 URL 内部的合法括号。
*
* 为什么需要:高德分享链接里的 POI 名称常含未编码的半角括号,
* 如 `...青岛菜(黄岛珠江路店)%2C武夷山路...`
* 若正则直接排除 ')',就会在第一个 ')' 处截断 → 后面的地址段全丢。
* 实测该链接被截到 221/456 字符,__p 从 5 段变成 4 段。
*
* 做法:从左往右数括号,depth 变负说明这个 ')' 没有对应 '(',
* 它不属于 URL → 从这里截断。
*/
private static trimUrlTail(raw: string): string {
let depth: number = 0;
let cut: number = raw.length;
for (let i = 0; i < raw.length; i++) {
const ch: string = raw.charAt(i);
if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
if (depth < 0) {
// 多出来的右括号 → URL 到此为止
cut = i;
break;
}
}
}
let out: string = raw.substring(0, cut);
// 再裁掉可能粘在末尾的中文标点(用户手动加的全角句号/逗号等)
while (out.length > 0) {
const last: string = out.charAt(out.length - 1);
if (last === '。' || last === ',' || last === '、' || last === ';' ||
last === '!' || last === '?') {
out = out.substring(0, out.length - 1);
} else {
break;
}
}
return out;
}
private static async parseUrl(text: string): Promise<ParsedPlace | null> {
// 从可能夹带其他文字的段落里抽出第一个 http(s) 链接
const urlMatch: RegExpExecArray | null = /https?:\/\/[^\s"'<>()【】]+/i.exec(text);
if (urlMatch === null) {
return null;
}
const url: string = PlaceParser.trimUrlTail(urlMatch[0]);
// ① poi/detail: __p=<id>,<lat>,<lng>,<name>,<address...>
const p: ParsedPlace | null = PlaceParser.parseAmapP(url);
if (p !== null) {
return p;
}
// ② share/index: __q=<lat>,<lng>,<name>(在 path 里)
const q: ParsedPlace | null = PlaceParser.parseAmapQ(url);
if (q !== null) {
return q;
}
// ③ place: ?lng=&lat=
const pl: ParsedPlace | null = PlaceParser.parseAmapPlace(url);
if (pl !== null) {
return pl;
}
// ④ 短链:跟随 302 取 Location 头里的 q 参数
if (/surl\.amap\.com/i.test(url)) {
return await PlaceParser.expandShortLink(url);
}
return null;
}
/**
* ① `__p=<poiid>,<lat>,<lng>,<name>,<address>`
*
* ⚠️ 地址段可能自身含逗号(如「XX路1号,2楼」),所以:
* 只取前 4 段,第 5 段及之后**全部拼回**地址,避免 split 切错。
*/
private static parseAmapP(url: string): ParsedPlace | null {
const m: RegExpExecArray | null = /[?&]__p=([^&]+)/i.exec(url);
if (m === null) {
return null;
}
let raw: string = '';
try {
raw = decodeURIComponent(m[1]);
} catch (err) {
return null;
}
const segs: string[] = raw.split(',');
if (segs.length < 3) {
return null;
}
const lat: number = parseFloat(segs[1]);
const lng: number = parseFloat(segs[2]);
const out = new ParsedPlace();
out.source = 'amap-p';
out.name = segs.length > 3 ? segs[3].trim() : '';
// 地址 = 第 4 段之后全部拼回(含逗号的情况)
if (segs.length > 4) {
out.address = segs.slice(4).join(',').trim();
}
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
if (fixed[0] !== 0 || fixed[1] !== 0) {
out.lat = fixed[0];
out.lng = fixed[1];
out.hasCoord = true;
}
return out.primaryText !== '' ? out : null;
}
/**
* ② `share/index/__q=<lat>,<lng>,<name>`
*
* ⚠️ 这是唯一坐标藏在 **path** 而不是 query 里的形态。
* 只解析 query 会漏掉它,必须先看 path。
*/
private static parseAmapQ(url: string): ParsedPlace | null {
const m: RegExpExecArray | null = /__q=([^&]+)/i.exec(url);
if (m === null) {
return null;
}
let raw: string = '';
try {
raw = decodeURIComponent(m[1]);
} catch (err) {
return null;
}
const segs: string[] = raw.split(',');
if (segs.length < 2) {
return null;
}
const lat: number = parseFloat(segs[0]);
const lng: number = parseFloat(segs[1]);
const out = new ParsedPlace();
out.source = 'amap-q';
if (segs.length > 2) {
out.name = segs.slice(2).join(',').trim();
}
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
if (fixed[0] !== 0 || fixed[1] !== 0) {
out.lat = fixed[0];
out.lng = fixed[1];
out.hasCoord = true;
}
return out.primaryText !== '' ? out : null;
}
/** ③ `/place/<poiid>?lng=<lng>&lat=<lat>` —— 显式命名,不存在顺序问题 */
private static parseAmapPlace(url: string): ParsedPlace | null {
const mlng: RegExpExecArray | null = /[?&]lng=([-\d.]+)/i.exec(url);
const mlat: RegExpExecArray | null = /[?&]lat=([-\d.]+)/i.exec(url);
if (mlng === null || mlat === null) {
return null;
}
const lng: number = parseFloat(mlng[1]);
const lat: number = parseFloat(mlat[1]);
const out = new ParsedPlace();
out.source = 'amap-place';
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
if (fixed[0] === 0 && fixed[1] === 0) {
return null;
}
out.lat = fixed[0];
out.lng = fixed[1];
out.hasCoord = true;
return out;
}
/**
* ④ 短链展开:surl.amap.com/xxx
*
* 实测第一次 302 的 Location 就带完整信息:
* https://wb.amap.com/?q=32.56132,108.50452,紫阳县明树茶厂
* 所以不需要跟到底,拿到第一个 Location 即可,省流量也省时间。
*/
private static async expandShortLink(url: string): Promise<ParsedPlace | null> {
let cur: string = url;
for (let i = 0; i < PlaceParser.MAX_REDIRECT; i++) {
const loc: string = await PlaceParser.headLocation(cur);
if (loc === '') {
return null;
}
// Location 里可能直接带 q=<lat>,<lng>,<name>
const m: RegExpExecArray | null = /[?&]q=([^&]+)/i.exec(loc);
if (m !== null) {
let raw: string = '';
try {
raw = decodeURIComponent(m[1]);
} catch (err) {
return null;
}
const segs: string[] = raw.split(',');
if (segs.length >= 2) {
const lat: number = parseFloat(segs[0]);
const lng: number = parseFloat(segs[1]);
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
if (fixed[0] !== 0 || fixed[1] !== 0) {
const out = new ParsedPlace();
out.source = 'amap-surl';
out.lat = fixed[0];
out.lng = fixed[1];
out.hasCoord = true;
if (segs.length > 2) {
out.name = segs.slice(2).join(',').trim();
}
return out;
}
}
}
// 还没到目标,继续跟
if (!/^https?:/i.test(loc)) {
return null;
}
cur = loc;
// 中途可能变成 m.amap.com/share/index/__q=... 形态
const q: ParsedPlace | null = PlaceParser.parseAmapQ(cur);
if (q !== null) {
q.source = 'amap-surl';
return q;
}
}
return null;
}
/** 发 HEAD 请求取 Location 头(不下载响应体)。失败返回空串 */
private static async headLocation(url: string): Promise<string> {
const req = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.GET,
header: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36' },
connectTimeout: PlaceParser.SHORTLINK_TIMEOUT,
readTimeout: PlaceParser.SHORTLINK_TIMEOUT,
// 关键:不自动跟随跳转,手动读 Location,避免下载整个落地页
usingProtocol: http.HttpProtocol.HTTP1_1
});
const headers = resp.header as Record<string, string>;
if (headers === undefined || headers === null) {
return '';
}
const loc: string = headers['Location'] ?? headers['location'] ?? '';
return typeof loc === 'string' ? loc : '';
} catch (err) {
LogUtil.write(`短链展开失败: ${url}`);
return '';
} finally {
req.destroy();
}
}
// ==================== 坐标纠偏 ====================
/**
* 校正经纬度顺序。
*
* 规则(依据实测与地理常识):
* - 纬度绝对值必然 ≤ 90,经度可以到 180
* - 若第一个值绝对值 > 90 → 第一个其实是经度,交换
* - 若两个都在 90 内 → 按高德惯例「纬在前」,不交换
* - 任一为 0 或非数字 → 视为无坐标(返回 [0,0])
*
* @returns [lat, lng];无效时返回 [0, 0]
*/
private static fixLatLng(a: number, b: number): number[] {
const invalid: number[] = [0, 0];
if (isNaN(a) || isNaN(b) || (a === 0 && b === 0)) {
return invalid;
}
let lat: number = a;
let lng: number = b;
if (Math.abs(a) > 90 && Math.abs(b) <= 90) {
// 第一个是经度,交换
lat = b;
lng = a;
}
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) {
return invalid;
}
return [lat, lng];
}
// ==================== 纯文本地址解析 ====================
/**
* 解析微信「复制地址」出来的纯文本。
*
* 实测格式:`<名称>(<分店名>) <空格> <省市区+门牌>`
* 例:招商银行(青岛经济技术开发区支行) 山东省青岛市黄岛区珠江路1-3楼1018号一楼
*
* 兼容性考虑:
* - 分隔符可能是空格、全角空格、换行
* - 括号可能是半角 () 或全角()
* - 可能只有一个名称没有地址
* - 可能只有地址没有名称
*
* 策略:**名称优先**。因为微信拼的地址可能有问题(实测「珠江路1-3楼1018号」
* 把楼层塞进了门牌号,检索命中率低),而名称检索几乎必中。
*/
private static parseText(text: string): ParsedPlace | null {
// 去掉 URL 残留与多余空白
let t: string = text.replace(/\s+/g, ' ').trim();
if (t === '' || t.length < 2) {
return null;
}
const out = new ParsedPlace();
out.source = 'wechat-text';
// 情况一:名称(分店) 空格 地址 —— 括号后跟空格是最可靠的切分点
const m1: RegExpExecArray | null = /^(.+?[))])\s*(.+)$/.exec(t);
if (m1 !== null) {
out.name = m1[1].trim();
out.address = m1[2].trim();
return out;
}
// 情况二:名称 + 空格 + 行政区开头(省/市/区/县/路/街/号…)
const m2: RegExpExecArray | null = /^(.+?)\s+((?:中国)?[省市区县].+)$/.exec(t);
if (m2 !== null) {
out.name = m2[1].trim();
out.address = m2[2].trim();
return out;
}
// 情况三:只有名称,或只有地址
if (/^(?:中国)?[省市区县]/.test(t) || /[路街号弄巷栋楼室]/.test(t)) {
out.address = t;
} else {
out.name = t;
}
return out.primaryText !== '' ? out : null;
}
// ==================== 工具 ====================
/** 反向地理编码后的展示文本:名称 | 地址 */
static mergeForLocationText(name: string, address: string): string {
if (name !== '' && address !== '' && name !== address) {
return `${name}|${address}`;
}
return name !== '' ? name : address;
}
}
+253
View File
@@ -0,0 +1,253 @@
// entry/src/main/ets/common/PlaceSuggest.ets
// 地点补全/选点:基于鸿蒙系统内置的 Map Kit(Site Kit + sceneMap)
//
// 为什么用华为 Map Kit 而不是高德:
// 1) 系统内置、无需第三方 SDK 依赖,也不需要我们自己维护一个 API Key;
// 2) Site Kit 的 queryAutoComplete / searchByText / reverseGeocode **不需要任何权限**
// (SDK 声明里没有 @permission),符合本项目「权限只在用户主动点该功能时申请」的硬约束;
// 3) HarmonyOS 5.0 及以上 Map Kit 的地图加载/POI 搜索/地理编码当前免费(官方论坛口径)。
//
// ⚠️ 前置条件(必须由用户在 AGC 完成,代码无法替代):
// AGC 项目 → 开放能力管理 → 打开「地图服务」,并**重新申请 Profile**(旧的 Profile 不含地图能力)。
// 未开通时调用会抛 1002600004(The Map permission is not enabled)。
// 因此本类所有入口都必须**优雅降级**:失败只返回空列表,绝不影响日程本身的保存。
import { site, sceneMap, mapCommon } from '@kit.MapKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { LogUtil } from './LogUtil';
/** 一条地点候选(界面直接消费) */
export class PlaceItem {
/** Site Kit 的地点 ID,可用于 searchById 取详情 */
siteId: string = '';
/** 地点名称,如「星巴克(中关村店)」 */
name: string = '';
/** 完整格式化地址,如「北京市海淀区中关村大街1号」 */
address: string = '';
/** 纬度(无法取得时为 0) */
lat: number = 0;
/** 经度(无法取得时为 0) */
lng: number = 0;
/** 是否有有效坐标 */
hasCoord: boolean = false;
/** 界面上第一行显示:有名称用名称,否则用地址 */
get title(): string {
return this.name !== '' ? this.name : this.address;
}
/** 界面上第二行显示:名称+地址重复时不显示 */
get subtitle(): string {
if (this.address === '') {
return '';
}
if (this.name === '' || this.address === this.name) {
return '';
}
return this.address;
}
/** 写入日程 location 字段的文本:优先完整地址,退化为名称 */
get locationText(): string {
return this.address !== '' ? this.address : this.name;
}
}
export class PlaceSuggest {
/** 地图能力是否可用(false 时界面应显示提示而不是反复重试) */
private static unavailable: boolean = false;
/** 最近一次失败原因(给界面显示,便于用户/开发者判断是"没开通"还是"网络问题") */
private static lastError: string = '';
/** 当前在途请求序号:用于丢弃过期结果(边打字边搜时后发先至) */
private static seq: number = 0;
static isUnavailable(): boolean {
return PlaceSuggest.unavailable;
}
static getLastError(): string {
return PlaceSuggest.lastError;
}
/**
* 关键字补全:输入几个字返回候选地点。
* 失败/未开通一律返回空数组(不抛异常,不影响其他功能)。
* @param keyword 用户已输入的关键字
* @param context 可选,传了就用带 context 的重载(定位更准)
*/
static async query(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
const q: string = keyword.trim();
if (q === '') {
return [];
}
if (PlaceSuggest.unavailable) {
return [];
}
// 序号机制:只接受最后一次请求的结果
PlaceSuggest.seq++;
const mySeq: number = PlaceSuggest.seq;
try {
const params: site.QueryAutoCompleteParams = {
query: q,
language: 'zh-CN',
isCityLimit: false
};
const res: site.QueryAutoCompleteResult = context !== undefined
? await site.queryAutoComplete(context, params)
: await site.queryAutoComplete(params);
if (mySeq !== PlaceSuggest.seq) {
// 已有更新的请求发出,丢弃本次结果
return [];
}
return PlaceSuggest.toItems(res.sites);
} catch (err) {
PlaceSuggest.handleError('补全', err);
// 补全失败时退一步用关键字搜索(部分环境 autoComplete 不可用但 searchByText 可用)
return await PlaceSuggest.searchFallback(q, context, mySeq);
}
}
/**
* 关键字搜索(补全失败时的兜底,也用于用户直接点"搜索")。
*/
static async search(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
const q: string = keyword.trim();
if (q === '' || PlaceSuggest.unavailable) {
return [];
}
PlaceSuggest.seq++;
const mySeq: number = PlaceSuggest.seq;
return await PlaceSuggest.searchFallback(q, context, mySeq);
}
private static async searchFallback(keyword: string, context: common.Context | undefined,
mySeq: number): Promise<PlaceItem[]> {
try {
const params: site.SearchByTextParams = {
query: keyword,
language: 'zh-CN'
};
const res: site.SearchByTextResult = context !== undefined
? await site.searchByText(context, params)
: await site.searchByText(params);
if (mySeq !== PlaceSuggest.seq) {
return [];
}
return PlaceSuggest.toItems(res.sites);
} catch (err) {
PlaceSuggest.handleError('搜索', err);
return [];
}
}
/**
* 打开系统地图选点页,返回用户选中的地点(用户取消返回 null)。
* 这是最省事也最不容易出错的方式:地图、搜索、周边都由系统界面提供。
*/
static async pickOnMap(context: common.UIAbilityContext): Promise<PlaceItem | null> {
try {
const options: sceneMap.LocationChoosingOptions = {
language: 'zh-CN',
searchEnabled: true,
showNearbyPoi: true
};
const r: sceneMap.LocationChoosingResult = await sceneMap.chooseLocation(context, options);
const item: PlaceItem = new PlaceItem();
item.siteId = r.siteId !== undefined ? r.siteId : '';
item.name = r.name !== undefined ? r.name : '';
item.address = r.address !== undefined ? r.address : '';
if (r.location !== undefined) {
item.lat = r.location.latitude;
item.lng = r.location.longitude;
item.hasCoord = true;
}
// 两者都空视为取消/无效选择
if (item.name === '' && item.address === '') {
return null;
}
return item;
} catch (err) {
PlaceSuggest.handleError('地图选点', err);
return null;
}
}
/** 生成"名称 — 地址"这种便于粘贴进备注/地点的单行文本 */
static formatOneLine(item: PlaceItem): string {
if (item.name !== '' && item.address !== '' && item.name !== item.address) {
return `${item.name}|${item.address}`;
}
return item.locationText;
}
private static toItems(sites: site.Site[] | undefined): PlaceItem[] {
const out: PlaceItem[] = [];
if (sites === undefined) {
return out;
}
for (let i = 0; i < sites.length; i++) {
const s: site.Site = sites[i];
const item: PlaceItem = new PlaceItem();
item.siteId = s.siteId !== undefined ? s.siteId : '';
item.name = s.name !== undefined ? s.name : '';
// formatAddress 是"省市区+街道门牌"的完整串,最适合写进 LOCATION
item.address = s.formatAddress !== undefined ? s.formatAddress : '';
if (item.address === '' && s.addressComponent !== undefined) {
// 极少数数据只有结构化 addressComponent(字段仅 countryName/locality/city/streetNumber),
// 按"国 → 城市 → 区县(locality) → 街道门牌"拼一个保底地址
item.address = PlaceSuggest.joinAddress(s.addressComponent);
}
if (s.location !== undefined) {
item.lat = s.location.latitude;
item.lng = s.location.longitude;
item.hasCoord = true;
}
if (item.name !== '' || item.address !== '') {
out.push(item);
}
}
return out;
}
/** 从结构化地址组件拼一条保底地址(SDK 里就这几个字段) */
private static joinAddress(ac: site.AddressComponent): string {
const parts: string[] = [];
if (ac.countryName !== undefined && ac.countryName !== '') {
parts.push(ac.countryName);
}
if (ac.city !== undefined && ac.city.cityName !== undefined && ac.city.cityName !== '') {
parts.push(ac.city.cityName);
}
if (ac.locality !== undefined && ac.locality !== '') {
parts.push(ac.locality);
}
if (ac.streetNumber !== undefined) {
const sn: site.StreetNumber = ac.streetNumber;
if (sn.streetName !== undefined && sn.streetName !== '') {
parts.push(sn.streetName);
}
if (sn.streetNumber !== undefined && sn.streetNumber !== '') {
parts.push(sn.streetNumber);
}
}
return parts.join('');
}
/**
* 统一错误处理:
* - 1002600004 地图权限未开通 → 标记不可用,界面不再重试(避免每次打字都打一遍网络)
* - 1002600003 鉴权失败(Profile 未含地图能力)
* - 1002600002 连不上服务器
*/
private static handleError(op: string, err: Object): void {
const e = err as BusinessError;
const code: number = e.code !== undefined ? e.code : 0;
PlaceSuggest.lastError = `${op}失败(code=${code}): ${e.message}`;
if (code === 1002600004 || code === 1002600003) {
PlaceSuggest.unavailable = true;
LogUtil.write(`[地点] ${op}不可用(code=${code}):需在 AGC 打开「地图服务」并重新申请 Profile`);
} else {
LogUtil.write(`[地点] ${PlaceSuggest.lastError}`);
}
}
}
+62 -4
View File
@@ -12,6 +12,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import { EventDb, LocalEvent } from './EventDb';
import { AppSettings } from './AppSettings';
import { AccountStore } from './AccountStore';
import { SystemCalendarMirror } from './SystemCalendarMirror';
import { LogUtil } from './LogUtil';
export class ReminderService {
@@ -26,6 +27,39 @@ export class ReminderService {
private static calNameCache: Map<string, string> = new Map();
private static calNameLoadedAt: number = 0;
/** 已镜像到系统日历的 calKey 集合缓存(避免重复排提醒),30 秒刷新一次 */
private static mirroredCalKeys: Set<string> = new Set();
private static mirroredLoadedAt: number = 0;
/**
* ⭐⭐ 取"已镜像到系统日历"的日历本 calKey 集合(0.0.8 修复双通道重复提醒)。
*
* 镜像到系统日历后,**系统日历自己会提醒**(条目的 reminderTime);
* 若 App 再为同一个本排代理提醒 → 同一条日程提醒两次。
* 因此这里返回"该由系统日历负责提醒"的本,App 侧**完全不再提醒**它们。
*
* ⚠️ 判据是"**镜像账户真实存在**"(`SystemCalendarMirror.mirroredCalKeys`),
* 而不是"用户勾选了要镜像"—— 后者只是意图,本可能还没被真正写进系统日历;
* 按意图跳过会导致"两边都不提醒"(漏提醒)。
* ⚠️ 返回空集 = 不跳过任何本(保守:宁可多提醒,不可漏提醒)。
*/
private static async mirroredKeys(context: common.Context): Promise<Set<string>> {
if (Date.now() - ReminderService.mirroredLoadedAt < 30000
&& ReminderService.mirroredCalKeys.size > 0) {
return ReminderService.mirroredCalKeys;
}
const out: Set<string> = await SystemCalendarMirror.mirroredCalKeys(context);
ReminderService.mirroredCalKeys = out;
ReminderService.mirroredLoadedAt = Date.now();
return out;
}
/** 镜像开关变化后调用,让缓存立即失效(如用户关闭镜像 → 立刻恢复 App 提醒) */
static invalidateMirrorCache(): void {
ReminderService.mirroredLoadedAt = 0;
ReminderService.mirroredCalKeys = new Set();
}
/** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */
private static async calName(context: common.Context, calKey: string): Promise<string> {
if (Date.now() - ReminderService.calNameLoadedAt > 60000
@@ -127,14 +161,27 @@ export class ReminderService {
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
}
const now: number = Date.now();
const rows: LocalEvent[] =
// ⭐ #38 日程 + 待办一起排提醒。两类分别查(过滤条件不同:
// 待办要求 completed=0,日程无此概念),合并后统一处理。
const eventRows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
const todoRows: LocalEvent[] =
await EventDb.queryRemindableTodos(context, now, now + ReminderService.WINDOW);
const rows: LocalEvent[] = eventRows.concat(todoRows);
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
if (mirrored.size > 0) {
LogUtil.write(`提醒跳过 ${mirrored.size} 个已镜像到系统日历的日历本(交由系统日历提醒)`);
}
for (const e of rows) {
if (muted.includes(e.calKey)) {
continue;
}
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复
}
if (published >= ReminderService.MAX_REMINDERS) {
break;
}
@@ -151,6 +198,8 @@ export class ReminderService {
}
const d = new Date(remindAt);
const calName: string = await ReminderService.calName(context, e.calKey);
// #38 文案按类型区分:待办是"到期",日程是"开始"
const isTodo: boolean = e.kind === 'todo';
const request: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
@@ -161,10 +210,14 @@ export class ReminderService {
minute: d.getMinutes(),
second: 0
},
title: '日程提醒',
content: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
title: isTodo ? '待办提醒' : '日程提醒',
content: isTodo
? `「${calName}」中的待办「${e.title}」将在 ${rem} 分钟后到期`
: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
// ⚠️ 通知 id 必须唯一:日程与待办的 id 来自同一张表(自增主键),
// 天然不冲突,所以这里不用额外加偏移,否则会与 rem 的乘加混淆。
notificationId: (e.id * 10 + rem) % 100000,
expiredContent: '该日程已开始',
expiredContent: isTodo ? '该待办已到期' : '该日程已开始',
ringDuration: 5,
snoozeTimes: 0,
timeInterval: 0
@@ -211,11 +264,16 @@ export class ReminderService {
const rows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
let fired: number = 0;
for (const e of rows) {
if (muted.includes(e.calKey)) {
continue;
}
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 交由系统日历提醒
}
const rems: number[] = e.reminders.length > 0 ? e.reminders
: (e.reminder > 0 ? [e.reminder] : []);
for (const rem of rems) {
+166 -16
View File
@@ -122,7 +122,23 @@ export class SyncEngine {
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
const auth: string = DavClient.authHeader(acc.username, acc.password);
// 0) 刷新服务器端日历本颜色(每次同步都校正)
await SyncEngine.refreshCalendarColors(acc, auth);
// ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
if (colorChanged) {
try {
const all: DavAccount[] = await AccountStore.loadAll(context);
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
if (idx >= 0) {
all[idx] = acc;
await AccountStore.saveAll(context, all);
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
}
}
// 1) 推送该账号日历本下的本地修改
await SyncEngine.pushDirtyForAccount(context, acc, auth);
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
@@ -145,6 +161,16 @@ export class SyncEngine {
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
const t1: number = Date.now();
// ⭐ 纯待办本(COMP_SET 只有 VTODO)不跑 event 通道。
// 实测群晖:对纯待办本做 event 的 REPORT 会返回文件列表,
// 里面其实是 VTODO 文件,被 parseEvents 解析成 0 条日程却仍落库
// → 本地库出现 kind='event' 记录,而所在本是待办本。
// 后果:这条"假日程"在待办列表看不到(列表只查 kind='todo'),
// 但点它编辑会走日程通道,报"需要联网 + 日历本需可写"。
const thisComps: string = i < acc.calendarComps.length ? acc.calendarComps[i] : '';
if (thisComps === 'todo') {
LogUtil.write(`日历本[${i}]「${calName}」是纯待办本(COMP_SET=todo),跳过 event 通道`);
} else {
let items: RemoteItem[] = [];
try {
items = await DavClient.reportEtags(href, auth);
@@ -157,15 +183,45 @@ export class SyncEngine {
throw err as Error;
}
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
// ⭐ SYNC_TODOS 同步该本里的待办(VTODO)。
// 原来整个同步流程只处理 VEVENT,`DavClient.reportTodos` 写了却没人调用,
// 导致待办数据从来不会从服务器拉下来 ——
// 用户取消勾选本(触发 pruneStaleHrefs 清行)后再勾回来,待办就永久消失了。
// 放在这里(VEVENT 处理之前)单独走一遍,不影响原有日程逻辑。
try {
const todoItems: RemoteItem[] = await DavClient.reportTodos(href, auth);
if (todoItems.length > 0) {
const todoRemote: RemoteEvent[] = [];
for (const ti of todoItems) {
try {
const raw: string = await DavClient.getRawStrict(ti.href, auth);
const parsed: RemoteEvent[] = IcsUtil.parseTodos(raw);
for (const pr of parsed) {
pr.etag = ti.etag;
todoRemote.push(pr);
}
} catch (e) {
LogUtil.write(`待办 GET 失败(跳过) ${ti.href}`);
}
}
if (todoRemote.length > 0) {
await EventDb.applyRemote(context, calKey, href, todoRemote, true);
LogUtil.write(`日历本[${i}]「${calName}」待办同步:${todoRemote.length} 条`);
}
}
} catch (e) {
// 待办同步失败不能影响日程同步(很多服务器对 VTODO 查询返回 403/不支持)
LogUtil.write(`日历本[${i}]「${calName}」待办同步失败(忽略):${(e as BusinessError).message ?? ''}`);
}
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
const byUid: Map<string, LocalEvent[]> = new Map();
// ⭐ 再按 **etag** 分组,给下面"要不要 GET 这个资源"做兜底(见 §复用判定 注释)
const byEtag: Map<string, LocalEvent[]> = new Map();
for (const row of existingRows) {
const arr: LocalEvent[] | undefined = byUid.get(row.uid);
if (arr === undefined) {
byUid.set(row.uid, [row]);
} else {
arr.push(row);
SyncEngine.groupPush(byUid, row.uid, row);
if (row.etag !== '') {
SyncEngine.groupPush(byEtag, row.etag, row);
}
}
const originMatch = /https?:\/\/[^/]+/i.exec(href);
@@ -174,19 +230,39 @@ export class SyncEngine {
let fetched: number = 0;
let parseFail: number = 0;
const needFetch: RemoteItem[] = [];
let hitUid: number = 0; // 按"资源名 = uid"命中
let hitEtag: number = 0; // 按 etag 内容哈希兜底命中
for (const it of items) {
const resUid: string = SyncEngine.resUidFromHref(it.href);
const group: LocalEvent[] | undefined = byUid.get(resUid);
let group: LocalEvent[] | undefined = byUid.get(resUid);
if (group !== undefined) {
hitUid++;
} else if (it.etag !== '') {
// ⭐ 复用判定(2026-10-15 修复):**服务器不保证"资源文件名 = ICS 里的 UID"**。
// 家书 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/', '_', $uid) 生成文件名:
// UID `movie-188@familybook` 的资源叫 `movie-188_familybook.ics`。
// 旧版只按资源名去 `byUid` 查 → 永远查不到 → 该资源**每轮都被判成"变了"**,
// 于是每次都全量 GET(实测 2184 个资源 → 单次同步 200~300 秒:卡片后台同步
// 那 9 秒窗口连一个日历本都跑不完)。
// etag 是**内容哈希**(服务端由正文 md5 得出):相等即内容一致,
// 与"资源叫什么名字"无关 —— 用它兜底既安全又与服务器实现解耦。
group = byEtag.get(it.etag);
if (group !== undefined) {
hitEtag++;
}
}
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
&& group[0].etag === it.etag) {
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
const rp: string = it.href.substring(it.href.lastIndexOf('/') + 1);
for (const row of group) {
remote.push(SyncEngine.rowToRemote(row, it.etag));
remote.push(SyncEngine.rowToRemote(row, it.etag, rp));
}
continue;
}
needFetch.push(it);
}
LogUtil.write(`日历本[${i}]「${calName}」复用判定:uid 命中 ${hitUid}、etag 兜底命中 ${hitEtag}、需补拉 ${needFetch.length}(共 ${items.length})`);
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
const batchSize: number = 5;
for (let b: number = 0; b < needFetch.length; b += batchSize) {
@@ -199,6 +275,12 @@ export class SyncEngine {
return [];
}
const resUid: string = SyncEngine.resUidFromHref(it.href);
// ⭐ REMOTE_PATH 服务器真实资源文件名(href 末段,已含 URL 编码)。
// 绝不能用 `encodeURIComponent(uid) + '.ics'` 去猜:
// 家书 CalDav.php 用 preg_replace('/[^A-Za-z0-9._-]/','_',uid) 生成文件名,
// UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics`。
// 猜错 ⇒ DELETE/PUT 打到不存在的路径 ⇒ 404,服务器上删不掉、或写出重复资源。
const fileName: string = it.href.substring(it.href.lastIndexOf('/') + 1);
const parsed: RemoteEvent[] = IcsUtil.parse(raw);
const out: RemoteEvent[] = [];
for (const r of parsed) {
@@ -208,6 +290,7 @@ export class SyncEngine {
if (r.uid === '') {
r.uid = resUid;
}
r.remotePath = fileName;
r.etag = it.etag;
out.push(r);
}
@@ -234,6 +317,8 @@ export class SyncEngine {
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
changed += remote.length;
} // end else(非纯待办本才跑 event 通道)
// 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步)
try {
const todoItems = await DavClient.reportTodos(href, auth);
@@ -344,8 +429,13 @@ export class SyncEngine {
/**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性)
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
*/
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> {
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
let changed: boolean = false;
let entries: DavColorEntry[] = [];
try {
entries = await DavClient.propfindColors(acc.serverUrl, auth);
@@ -363,9 +453,11 @@ export class SyncEngine {
if (found !== undefined && found.color !== '') {
while (acc.calendarColors.length <= i) {
acc.calendarColors.push('');
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
}
if (acc.calendarColors[i] !== found.color) {
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
changed = true;
}
acc.calendarColors[i] = found.color;
} else if (found === undefined) {
@@ -377,16 +469,32 @@ export class SyncEngine {
acc.calendarWritable.push('1');
}
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
let writable: string;
if (found !== undefined && found.privilegeKnown) {
// 服务器声明了权限:直接采用
acc.calendarWritable[i] = found.writable ? '1' : '0';
writable = found.writable ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
} else {
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
const w: boolean = await DavClient.probeWritable(target, auth);
acc.calendarWritable[i] = w ? '1' : '0';
writable = w ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
}
if (acc.calendarWritable[i] !== writable) {
changed = true;
}
acc.calendarWritable[i] = writable;
}
return changed;
}
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
private static groupPush(m: Map<string, LocalEvent[]>, key: string, row: LocalEvent): void {
const arr: LocalEvent[] | undefined = m.get(key);
if (arr === undefined) {
m.set(key, [row]);
} else {
arr.push(row);
}
}
@@ -401,8 +509,7 @@ export class SyncEngine {
}
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
private static resUidFromHref(href: string): string {
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
private static resUidFromHref(href: string): string { const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
if (segs.length === 0) {
return String(Date.now());
}
@@ -419,10 +526,18 @@ export class SyncEngine {
}
/** 本地行 → 远端事件(etag 未变的资源复用本地完整数据,含 reminder) */
private static rowToRemote(e: LocalEvent, etag: string): RemoteEvent {
/**
* ⭐ REMOTE_PATH 第三个参数是当前 REPORT 返回的真实资源文件名(href 末段)。
*
* 为什么必须从外面传进来:本地行的 remotePath 可能是历史数据用 uid 猜出来的,
* 也可能是空的(本地新建后还没同步成功)。而 REPORT 的 href 反映的是
* **此刻服务器上的真实文件名**,比本地任何值都可靠。
*/
private static rowToRemote(e: LocalEvent, etag: string, remotePath: string): RemoteEvent {
const r = new RemoteEvent();
r.uid = e.uid;
r.etag = etag;
r.remotePath = remotePath !== '' ? remotePath : e.remotePath;
r.title = e.title;
r.description = e.description;
r.location = e.location;
@@ -477,7 +592,27 @@ export class SyncEngine {
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
try {
if (e.deleted) {
// ⭐ DELETE_404_PURGE 单条路径:deleteRemote 内部已把 404 视作成功,
// 这里再显式兜一层 410(Gone),并保证无论哪种"远端已无"都清本地。
let gone: boolean = false;
try {
await DavClient.deleteRemote(url, auth);
gone = true;
} catch (delErr) {
if (delErr instanceof DavHttpError) {
const st: number = (delErr as DavHttpError).status;
if (st === 401) {
throw delErr as Error;
}
if (st === 404 || st === 410) {
gone = true;
LogUtil.write(`单条资源在服务器上已不存在(${st}),直接清理本地:uid=${e.uid}`);
}
}
if (!gone) {
throw delErr as Error;
}
}
await EventDb.purge(context, e.id);
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
} else {
@@ -529,15 +664,30 @@ export class SyncEngine {
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
if (master.deleted) {
// ⭐ DELETE_404_PURGE 404/410 = 服务器上本就没有这个资源。
// 必须当成"删除成功"继续往下清本地,不能 return ——
// 否则本地行永远留着,用户看到"删不掉的日程",且每次同步都重复失败。
// 典型诱因:remotePath 用 uid 猜错(服务器真实文件名不同)⇒ DELETE 打到不存在的路径。
let remoteGone: boolean = false;
try {
await DavClient.deleteRemote(url, auth);
remoteGone = true;
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
if (err instanceof DavHttpError) {
const st: number = (err as DavHttpError).status;
if (st === 401) {
throw err as Error;
}
LogUtil.write(`推送删除重复系列失败:uid=${uid} → ${(err as Error).message}`);
if (st === 404 || st === 410) {
remoteGone = true;
LogUtil.write(`重复系列资源在服务器上已不存在(${st}),直接清理本地:uid=${uid}`);
}
}
if (!remoteGone) {
LogUtil.write(`推送删除重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
return;
}
}
await EventDb.purgeByUid(context, calKey, uid);
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);
return;
+381 -175
View File
@@ -37,7 +37,6 @@
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { calendarManager } from '@kit.CalendarKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { AccountStore, BookPalette, DavAccount } from './AccountStore';
import { EventDb, LocalEvent } from './EventDb';
import { IcsTime, IcsUtil } from './IcsUtil';
@@ -80,8 +79,14 @@ export class SystemCalendarMirror {
];
/** 系统日历账户 name 前缀。name 是开发者定义且 readonly,必须稳定可复现才能找到已有账户 */
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;
/** getEvents() 显式指定返回字段:identifier 只在 API20+ 的默认字段里,写死更保险 */
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);
}
/**
* ⭐⭐ 取"**确实已经镜像到系统日历**"的日历本 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}`);
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) {
out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, aggressive));
out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, allowDisplayMark));
}
LogUtil.write(`系统日历账户清单(共 ${all.length}):`);
for (const line of out.detail) {
@@ -211,10 +401,9 @@ export class SystemCalendarMirror {
const name: string = acc.name;
const display: string = acc.displayName ?? '';
const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
const mineByDisplay: boolean = aggressive &&
display.startsWith(SystemCalendarMirror.DISPLAY_PREFIX);
if (!mineByName && !mineByDisplay) {
continue;
// ⭐ 身份级判定:name 前缀 → 切出 accId → 必须匹配当前真实账号(详见 isOurMirrorAccount)
if (!SystemCalendarMirror.isOurMirrorAccount(name, display, knownIds, allowDisplayMark)) {
continue; // 身份不符 → 绝不碰(可能用户原有的日历本 / 别的 App 建的)
}
if (validNames.has(name)) {
continue; // 本次还要用,保留
@@ -275,18 +464,78 @@ export class SystemCalendarMirror {
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=…` */
private static describeCal(
cal: calendarManager.Calendar,
validNames: Set<string>,
aggressive: boolean
allowDisplayMark: boolean
): string {
const acc: calendarManager.CalendarAccount = cal.getAccount();
const name: string = acc.name;
const display: string = acc.displayName ?? '';
const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
const mineByDisplay: boolean = aggressive &&
display.startsWith(SystemCalendarMirror.DISPLAY_PREFIX);
const mineByDisplay: boolean = allowDisplayMark &&
display.startsWith(SystemCalendarMirror.DISPLAY_MARK);
let tag: string = 'other ';
if (mineByName || mineByDisplay) {
tag = validNames.has(name) ? 'KEEP ' : 'MINE ';
@@ -344,15 +593,32 @@ export class SystemCalendarMirror {
LogUtil.write(
`镜像前清理:删除 ${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 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) {
try {
const plan: MirrorOcc[] =
await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res);
plans.set(ref.bookId, plan);
await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res);
await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res, format);
res.books++;
} catch (err) {
const e = err as BusinessError;
@@ -360,8 +626,7 @@ export class SystemCalendarMirror {
LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
}
}
// ⭐ 每次镜像后自动导出一份对照诊断,便于"系统日历里有、App 里没有"这类问题逐条比对
await SystemCalendarMirror.dumpDiagnosis(context, cm, refs, plans, winStart, winEnd);
// ⭐ 0.0.8:不再导出 mirror_diag.txt(诊断落盘功能已随「同步记录」一并移除)
LogUtil.write(
`镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` +
`跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` +
@@ -530,10 +795,13 @@ export class SystemCalendarMirror {
/**
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
*
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。
* 三种情况(0.1.1 起,配合"卸载残留"问题):
* ① **还有账号** → 只清"accId 不在当前账号集合里"的 —— 原逻辑,精准不误伤。
* ② **账号库为空、且确认是真空**(`isEmptyVaultConfirmed()`)→ **全部清掉**。
* 这是卸载重装 / 用户重置账号库后的场景:旧镜像账户的 accId 不可能再匹配上,
* 留着只会变成系统日历里永远删不掉的幽灵本,还会继续提醒。
* ③ **账号库为空、但拿不准**(读取异常 / 密文解不开)→ **仍然跳过**。
* `loadAll()` 会把异常吞掉返回 `[]`,若不区分就会把用户真实在用的镜像全删了。
*
* @returns 删除的账户数
*/
@@ -547,8 +815,14 @@ export class SystemCalendarMirror {
} catch (err) {
return 0; // 读失败一律不动
}
let wipeAll: boolean = false;
if (accounts.length === 0) {
return 0; // 安全阀:见注释
// 空账号库:只有"确认空白"才敢全清,否则保守跳过(见上方 ③)
wipeAll = await AccountStore.isEmptyVaultConfirmed(context);
if (!wipeAll) {
return 0;
}
LogUtil.write('兜底清理孤儿镜像:账号库确认为空 → 清理全部镜像账户(卸载/重置后残留)');
}
const validAccIds: Set<string> = new Set<string>();
for (const a of accounts) {
@@ -573,11 +847,13 @@ export class SystemCalendarMirror {
} else {
sep = dash >= 0 ? dash : under;
}
if (sep <= 0) {
continue; // 解析不出 accId → 不动(宁可留,不误删)
// 非全清模式:必须能解析出 accId 才能比对,解析不出就跳过(宁可留,不误删)
// 全清模式:前缀已命中就是本应用建的,脏账户(解析不出 accId)也要删干净
if (!wipeAll && sep <= 0) {
continue;
}
const accId: string = bucket.substring(0, sep);
if (validAccIds.has(accId)) {
const accId: string = sep > 0 ? bucket.substring(0, sep) : bucket;
if (!wipeAll && validAccIds.has(accId)) {
continue;
}
try {
@@ -602,7 +878,7 @@ export class SystemCalendarMirror {
/**
* ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。
* 实际写入(syncBook)与诊断导出(dumpDiagnosis)都用它,保证诊断看到的就是真正写进去的。
* 实际写入(syncBook)以它为准,保证"计划"与"写入"完全一致。
*/
private static async planFor(
context: common.Context,
@@ -689,11 +965,12 @@ export class SystemCalendarMirror {
plan: MirrorOcc[],
winStart: number,
winEnd: number,
res: MirrorResult
res: MirrorResult,
format: number
): Promise<void> {
// ⭐ 账户名与 identifier 一律用 **bookId**(由 href 派生、稳定),绝不用 calKey
const cal: calendarManager.Calendar =
await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref);
await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref, format);
// 现有镜像条目:identifier -> Event
const existing: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
@@ -762,145 +1039,6 @@ export class SystemCalendarMirror {
`窗口=${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` */
private static stamp(ts: number): string {
const d: Date = new Date(ts);
@@ -998,20 +1136,30 @@ export class SystemCalendarMirror {
}
}
/** 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色) */
/**
* 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色)。
*
* ⭐ 0.0.8:displayName 按用户所选格式生成。
* ⚠️ **已存在的账户不会被改名**(平台无"改账户名"API;改名是破坏性操作,
* 必须由用户显式触发 `reapplyDisplayNames`)。此处仅保证**新建**时用对格式。
*/
private static async ensureCalendar(
cm: calendarManager.CalendarManager,
accountName: string,
ref: BookRef
ref: BookRef,
format: number
): Promise<calendarManager.Calendar> {
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of calendars) {
if (cal.getAccount().name === accountName) {
// 每次都尝试同步一次显示名与颜色(用户可能改过日历本名/色)
// 每次都尝试同步一次颜色(用户可能改过日历本色)
// ⚠️ displayName 不在此处改:改账户名需删账户重建(破坏性),由用户显式触发
try {
await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) {
// 设色失败不影响镜像本身
// 设色失败不影响镜像本身,但要留痕(否则"颜色不变"无从排查)
const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败 ${accountName}: ${e.message}`);
}
return cal;
}
@@ -1019,12 +1167,13 @@ export class SystemCalendarMirror {
const cal: calendarManager.Calendar = await cm.createCalendar({
name: accountName,
type: calendarManager.CalendarType.LOCAL,
displayName: SystemCalendarMirror.DISPLAY_PREFIX + ref.name
displayName: SystemCalendarMirror.displayNameFor(ref, format)
});
try {
await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) {
// 同上
const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败(新建) ${accountName}: ${e.message}`);
}
return cal;
}
@@ -1057,14 +1206,62 @@ export class SystemCalendarMirror {
ev.location = { location: e.location };
}
const reminders: number[] = SystemCalendarMirror.remindersOf(e);
if (reminders.length > 0) {
ev.reminderTime = reminders;
// ⭐⭐ 只写"提醒时刻尚未到"的提醒(0.0.8 修复"已过日程仍提醒")。
// 原因:写进系统日历的 reminderTime 一旦其时刻已过,系统会在**任何一次覆写**时
// 立即补发一条通知 → 表现为"日程都过去了还提醒"。这类提醒已无意义,索性不写。
const future: number[] = SystemCalendarMirror.futureReminders(
reminders, startTs, e.isAllDay, Date.now());
if (future.length > 0) {
ev.reminderTime = future;
}
// ⚠️ 这里**故意不设 recurrenceRule**:重复日程由我们自己按窗口展开成多条实例,
// 交给系统日历展开会因 RecurrenceRule 表达不全而出现错误的重复(09-19 事故)。
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 点的时间戳 */
private static dayStart(ts: number): number {
const d: Date = new Date(ts);
@@ -1127,6 +1324,15 @@ export class SystemCalendarMirror {
if ((cur.location?.location ?? '') !== (want.location?.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 '';
}
@@ -36,6 +36,8 @@ export class DayTimeline {
allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前)
blocks: TimelineBlock[] = []; // 时间轴色块
hasTimed: boolean = false; // 当天是否有有时间日程
/** 当天截止、尚未完成的待办(画在时间轴顶部、全天区之上,带勾选框) */
todos: DisplayEvent[] = [];
}
/** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */
+454
View File
@@ -0,0 +1,454 @@
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount } from './AccountStore';
import { AccountStore } from './AccountStore';
import { LocalEvent } from './EventDb';
import { EventDb } from './EventDb';
import { DavClient, DavHttpError } from './DavClient';
import { IcsUtil } from './IcsUtil';
import { LogUtil } from './LogUtil';
/**
* 待办(VTODO)操作。
*
* 现状:待办**只读展示**已实现(拉取/解析/入库/列表),本类补上唯一的写操作
* ——「勾选完成 / 取消完成」。
*
* ⚠️ 为什么不做"从本地字段重建 VTODO 再 PUT":
* VTODO 上有 SUMMARY / DESCRIPTION / DUE / PRIORITY / CATEGORIES / X-* 等字段,
* 本地库只存了其中几个。重建后 PUT 等于把没存的字段**全部抹掉**。
* 所以走"GET 原文 → 就地改 STATUS/COMPLETED/PERCENT-COMPLETE → PUT"。
*
* ⚠️ 为什么不能同步到手机系统待办:
* 系统未开放待办 API(@ohos.calendarManager 只有日程/VEVENT,无 Todo 对象),
* 第三方应用无法读写系统日历的「待办」页签。详见工单 #5。
*/
export class TodoService {
/**
* 设置待办完成状态并回写服务器。
*
* @param localId 本地 events 表行 id(DisplayEvent.id)
* @param done true=标记完成;false=取消完成
* @returns 成功返回 true;失败返回 false(**本地状态不变**,避免界面与服务器不一致)
*/
/**
* 更新待办内容(标题 / 备注 / 截止时间)。
*
* 同样走 GET 原文 → 改字段 → PUT:
* VTODO 上有本地库没保存的字段(PRIORITY / CATEGORIES / X-* 等),
* 重建整份会把这些抹掉。
*
* 只改 SUMMARY / DESCRIPTION / DUE 三项,**不动完成状态**
* (用户改的是内容,不该顺带把状态重置了)。
*
* @param dueMs 0 = 清除截止时间(删除 DUE 行)
*/
static async updateTodo(context: common.Context, localId: number, title: string,
desc: string, dueMs: number, priority: number = -1, categories: string = '',
location: string = '', reminders: number[] = [], parentUid: string = '',
attachments: string = '', touchParent: boolean = false,
touchAttach: boolean = false): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo') {
LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`);
return false;
}
if (row.href === '' || row.remotePath === '') {
LogUtil.write(`待办更新失败:缺少服务器路径 id=${localId}`);
return false;
}
const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href);
if (accResolved === null) {
LogUtil.write(`待办更新失败:找不到所属账号`);
return false;
}
const idx: number = accResolved.calendarHrefs.indexOf(row.href);
if (idx >= 0 && accResolved.calendarWritable.length > idx
&& accResolved.calendarWritable[idx] === '0') {
LogUtil.write(`待办更新失败:该日历本只读 id=${localId}`);
return false;
}
const auth: string = DavClient.authHeader(accResolved.username, accResolved.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
: `${row.href}/${row.remotePath}`;
try {
const original: string = await DavClient.getRawStrict(url, auth);
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`);
return false;
}
const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs,
priority, categories, location, reminders, parentUid, attachments,
touchParent, touchAttach);
const newEtag: string = await DavClient.putEvent(url, auth, updated);
await EventDb.updateTodoFields(context, localId, title, desc,
dueMs > 0 ? dueMs : 0, newEtag);
// 提醒单独落库(updateTodoFields 只处理三个字段)
await EventDb.setTodoReminders(context, localId, reminders);
// 子任务与附件:只有 touch 时才更新本地库(否则保留原值,避免覆盖)
if (touchParent || touchAttach) {
await EventDb.setTodoHierarchy(context, localId, parentUid, attachments,
touchParent, touchAttach);
}
LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`);
return true;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`待办更新失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
} else {
LogUtil.write(`待办更新失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
}
return false;
}
}
/**
* 新建待办:生成 VTODO → PUT 到服务器 → 落本地库。
*
* @param href 目标日历本的集合 URL
* @param title 标题(调用方保证非空)
* @param desc 备注(可为空串)
* @param dueMs 截止时间毫秒(0 = 无截止)
* @param dueIsDateOnly 截止时间是否只到日(true 写 DUE;VALUE=DATE)
* @returns 成功返回新行 id(>0),失败返回 0
*/
static async createTodo(context: common.Context, href: string, title: string,
desc: string, dueMs: number, dueIsDateOnly: boolean, priority: number = 0,
categories: string = '', location: string = '',
reminders: number[] = [], parentUid: string = '',
attachments: string = ''): Promise<number> {
const t: string = title.trim();
if (t === '' || href === '') {
LogUtil.write(`待办新建失败:标题或日历本为空`);
return 0;
}
// 1) 找到该 href 所属账号
const acc: DavAccount | null = await TodoService.findAccount(context, href);
if (acc === null) {
LogUtil.write(`待办新建失败:找不到所属账号 href=${href}`);
return 0;
}
// 2) 只读日历本提前拦下(否则必然 403)
const idx: number = acc.calendarHrefs.indexOf(href);
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
LogUtil.write(`待办新建失败:该日历本只读 href=${href}`);
return 0;
}
// 3) 生成 uid 与资源文件名
// 格式与事件侧一致:todo-<毫秒时间戳>-<随机6位>,足够避免撞车
const stamp: number = Date.now();
const rnd: string = Math.random().toString(36).substring(2, 8);
const uid: string = `todo-${stamp}-${rnd}`;
const fileName: string = `${uid}.ics`;
const url: string = href.endsWith('/') ? href + fileName : `${href}/${fileName}`;
const auth: string = DavClient.authHeader(acc.username, acc.password);
try {
// 4) 先推服务器:成功才落库,避免产生"本地有、服务器没有"的孤儿
const ics: string = IcsUtil.buildTodo(uid, t, desc, dueMs, dueIsDateOnly,
priority, categories, location, reminders, parentUid, attachments);
const etag: string = await DavClient.putEvent(url, auth, ics);
// 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送)
const e: LocalEvent = new LocalEvent();
e.uid = uid;
e.calKey = `${acc.id}_${idx}`;
e.href = href;
e.remotePath = fileName;
e.etag = etag;
e.kind = 'todo';
e.title = t;
e.description = desc;
e.startTime = dueMs > 0 ? dueMs : 0; // 待办的"开始"即截止(与 loadTodos 口径一致)
e.endTime = dueMs > 0 ? dueMs : 0;
e.isAllDay = dueIsDateOnly;
e.completed = false;
e.priority = priority;
e.categories = categories;
e.location = location;
e.reminders = reminders;
e.reminder = reminders.length > 0 ? reminders[0] : 0;
e.parentUid = parentUid;
e.attachments = attachments;
e.dirty = false; // 已经推成功了,无需再推
e.deleted = false;
const newId: number = await EventDb.insertLocal(context, e);
// insertLocal 强制置 dirty=true,这里立刻清掉(内容已在服务器上了)
await EventDb.clearDirty(context, newId, etag);
LogUtil.write(`待办新建成功 id=${newId}(标题已脱敏)→ ${fileName}`);
return newId;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`待办新建失败 HTTP ${(err as DavHttpError).status}(标题已脱敏)`);
} else {
LogUtil.write(`待办新建失败 ${(err as BusinessError).message ?? ''}`);
}
return 0;
}
}
/** 删除待办(DELETE 服务器资源 + 本地软删) */
static async deleteTodo(context: common.Context, localId: number): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo') {
LogUtil.write(`待办删除失败:行不存在或不是待办 id=${localId}`);
return false;
}
if (row.href === '' || row.remotePath === '') {
LogUtil.write(`待办删除失败:缺少服务器路径 id=${localId}`);
return false;
}
const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href);
if (accResolved === null) {
LogUtil.write(`待办删除失败:找不到所属账号`);
return false;
}
const idx: number = accResolved.calendarHrefs.indexOf(row.href);
if (idx >= 0 && accResolved.calendarWritable.length > idx
&& accResolved.calendarWritable[idx] === '0') {
LogUtil.write(`待办删除失败:该日历本只读 id=${localId}`);
return false;
}
const auth: string = DavClient.authHeader(accResolved.username, accResolved.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
: `${row.href}/${row.remotePath}`;
try {
await DavClient.deleteRemote(url, auth);
await EventDb.purge(context, localId);
LogUtil.write(`待办删除成功 id=${localId}(标题已脱敏)`);
return true;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`待办删除失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
} else {
LogUtil.write(`待办删除失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
}
return false;
}
}
/** 按 href 找所属账号(updateTodo/deleteTodo 共用) */
private static async findAccount(context: common.Context, href: string): Promise<DavAccount | null> {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
for (const a of accounts) {
if (a.calendarHrefs.includes(href)) {
return a;
}
}
return null;
}
/**
* 设置或取消待办的循环规则。
*
* @param rrule 规则原文(如 `FREQ=WEEKLY`);空串 = 取消循环
* @param dtstartMs 重复基准;<=0 时用当前 DUE 兜底
* @returns 成功 true
*
* ⚠️ 写回时**同时**给 DTSTART 和 DUE:
* RFC 5545 里 VTODO 的 DTSTART 是 OPTIONAL,但没有它时
* 不同客户端对 RRULE 基准的理解不一致(有的按 DUE 算),
* 显式给 DTSTART 能消除歧义。
*/
static async setRecurrence(context: common.Context, localId: number,
rrule: string, dtstartMs: number): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo') {
LogUtil.write(`待办循环设置失败:行不存在或不是待办 id=${localId}`);
return false;
}
if (row.href === '' || row.remotePath === '') {
LogUtil.write(`待办循环设置失败:缺少服务器路径 id=${localId}`);
return false;
}
const acc: DavAccount | null = await TodoService.findAccount(context, row.href);
if (acc === null) {
LogUtil.write(`待办循环设置失败:找不到所属账号`);
return false;
}
const idx: number = acc.calendarHrefs.indexOf(row.href);
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
LogUtil.write(`待办循环设置失败:该日历本只读 id=${localId}`);
return false;
}
const auth: string = DavClient.authHeader(acc.username, acc.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
: `${row.href}/${row.remotePath}`;
const dueMs: number = row.startTime > 0 ? row.startTime : 0;
const base: number = dtstartMs > 0 ? dtstartMs : (dueMs > 0 ? dueMs : Date.now());
try {
const original: string = await DavClient.getRawStrict(url, auth);
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
LogUtil.write(`待办循环设置失败:服务器内容不是 VTODO id=${localId}`);
return false;
}
const updated: string = IcsUtil.rewriteTodoRecurrence(
original, row.uid, rrule, base, dueMs);
const newEtag: string = await DavClient.putEvent(url, auth, updated);
await EventDb.updateTodoRecurrence(context, localId, rrule.trim(), base, newEtag);
LogUtil.write(`待办循环设置成功 id=${localId} rule=${rrule === '' ? '(取消)' : rrule}`);
return true;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`待办循环设置失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
} else {
LogUtil.write(`待办循环设置失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
}
return false;
}
}
/**
* 循环任务"完成一轮":把 DUE 推进到下一个周期,并保持未完成。
*
* 与 setCompleted 的区别:
* · 非循环 → 走 setCompleted(置 COMPLETED,永久结束)
* · 循环 → 走这里(DUE += 一个周期,STATUS 保持 NEEDS-ACTION)
*
* @returns 推进后的新 DUE(毫秒);失败返回 0
*/
static async completeRecurringRound(context: common.Context, localId: number): Promise<number> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo' || row.rrule.trim() === '') {
return 0;
}
if (row.href === '' || row.remotePath === '') {
return 0;
}
const acc: DavAccount | null = await TodoService.findAccount(context, row.href);
if (acc === null) {
return 0;
}
const idx: number = acc.calendarHrefs.indexOf(row.href);
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
return 0;
}
// 从 FREQ 取周期,推下一个基准
const freq: string = IcsUtil.freqOfRrule(row.rrule);
// 基准优先用 DTSTART(真正的重复基准),没有则用当前 DUE
const base: number = row.dtstart > 0 ? row.dtstart : row.startTime;
const nextBase: number = IcsUtil.incForFreq(freq, base);
// 保留"截止 = 基准 + N"的偏移(原任务若是 09:00 截止,下一次还是 09:00)
const offset: number = row.startTime > 0 && row.dtstart > 0 ? row.startTime - row.dtstart : 0;
const nextDue: number = nextBase + offset;
const auth: string = DavClient.authHeader(acc.username, acc.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
: `${row.href}/${row.remotePath}`;
try {
const original: string = await DavClient.getRawStrict(url, auth);
// 先把状态复位成未完成,再把基准与截止推到下一轮
let updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, false);
updated = IcsUtil.rewriteTodoRecurrence(updated, row.uid, row.rrule, nextBase, nextDue);
const newEtag: string = await DavClient.putEvent(url, auth, updated);
await EventDb.updateTodoAfterRound(context, localId, nextBase, nextDue, newEtag);
LogUtil.write(`循环待办推进到下一轮 id=${localId}(标题已脱敏)`);
return nextDue > 0 ? nextDue : nextBase;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`循环待办推进失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
} else {
LogUtil.write(`循环待办推进失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
}
return 0;
}
}
/**
* 级联完成:把 parentUid 指向某父任务的所有**未完成**子任务全部置完成。
*
* 用于父任务勾选完成时。只完成未完成的(已完成的跳过,幂等)。
* 逐条调用 setCompleted(它本身幂等 + 会 GET/PUT 服务器 + 更新本地)。
*/
static async completeChildren(context: common.Context, parentUid: string): Promise<number> {
if (parentUid === '') {
return 0;
}
const rows: LocalEvent[] = await EventDb.queryTodos(context);
let done: number = 0;
for (const r of rows) {
if (r.parentUid === parentUid && !r.completed) {
const ok: boolean = await TodoService.setCompleted(context, r.id, true);
if (ok) {
done++;
}
}
}
if (done > 0) {
LogUtil.write(`级联完成 ${done} 个子任务(父 ${parentUid})`);
}
return done;
}
static async setCompleted(context: common.Context, localId: number, done: boolean): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null) {
LogUtil.write(`待办完成失败:本地行不存在 id=${localId}`);
return false;
}
if (row.kind !== 'todo') {
LogUtil.write(`待办完成失败:该行不是待办 id=${localId} kind=${row.kind}`);
return false;
}
if (row.href === '' || row.remotePath === '') {
LogUtil.write(`待办完成失败:缺少服务器路径(不可写)id=${localId}`);
return false;
}
if (row.completed === done) {
return true; // 状态没变化,直接算成功(幂等)
}
// 1) 找到该 href 所属账号,构造鉴权
const accounts: DavAccount[] = await AccountStore.loadAll(context);
let acc: DavAccount | null = null;
for (const a of accounts) {
if (a.calendarHrefs.includes(row.href)) {
acc = a;
break;
}
}
if (acc === null) {
LogUtil.write(`待办完成失败:找不到所属账号 href=${row.href}`);
return false;
}
// 只读日历本:推送必然 403,提前拦下给出明确结果
const idx: number = acc.calendarHrefs.indexOf(row.href);
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
LogUtil.write(`待办完成失败:该日历本只读 id=${localId}`);
return false;
}
const auth: string = DavClient.authHeader(acc.username, acc.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath : `${row.href}/${row.remotePath}`;
try {
// 2) GET 原文(保住本地没存的字段)
const original: string = await DavClient.getRawStrict(url, auth);
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
LogUtil.write(`待办完成失败:服务器返回的内容不是 VTODO id=${localId}`);
return false;
}
// 3) 只改状态相关字段
const updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, done);
// 4) PUT 回去,拿新 etag
const newEtag: string = await DavClient.putEvent(url, auth, updated);
// 5) 同步本地(成功后才改,保证两边一致)
await EventDb.setTodoCompleted(context, localId, done, newEtag);
LogUtil.write(`待办${done ? '完成' : '取消完成'}成功 id=${localId}(标题已脱敏)`);
return true;
} catch (err) {
if (err instanceof DavHttpError) {
const he = err as DavHttpError;
LogUtil.write(`待办写回失败 HTTP ${he.status}:id=${localId}(本地状态未改)`);
} else {
const e = err as BusinessError;
LogUtil.write(`待办写回失败:id=${localId} ${e.message ?? ''}(本地状态未改)`);
}
return false;
}
}
}
@@ -2,6 +2,8 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration,
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { BackgroundSyncService } from '../common/BackgroundSyncService';
import { NetworkSyncWatcher } from '../common/NetworkSyncWatcher';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { LogUtil } from '../common/LogUtil';
const DOMAIN = 0x0000;
@@ -9,11 +11,14 @@ const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log
// 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
LogUtil.init(this.context);
LogUtil.write('---- App 启动 ----');
// 后台同步:注册延迟任务兜底 + 按设置恢复长时任务
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
// ⚠️ 不再申请"长时任务":dataTransfer 类型有 10 分钟无进度即被取消的一致性校验,
// 日历同步不符合该语义、必被取消(详见 BackgroundSyncService 顶部注释)。
BackgroundSyncService.initOnLaunch(this.context);
NetworkSyncWatcher.start(this.context);
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
this.handleWidgetParam(want);
// ⚠️ 这里**不再申请任何权限**(含通知权限)。
@@ -37,10 +42,12 @@ export default class EntryAbility extends UIAbility {
private handleWidgetParam(want: Want): void {
const params = want.parameters;
if (params !== undefined) {
// ⭐ 白名单:日程卡片发 'add',待办卡片发 'addTodo'(新建待办)/ 'openTodo'(进待办页)。
// 之前只认 'add',待办卡片点 + 毫无反应 —— 参数被静默丢弃。
const raw = params['widgetAction'];
if (typeof raw === 'string' && raw === 'add') {
AppStorage.setOrCreate<string>('widgetAction', 'add');
LogUtil.write('卡片添加入口:已设置 widgetAction=add');
if (typeof raw === 'string' && (raw === 'add' || raw === 'addTodo' || raw === 'openTodo')) {
AppStorage.setOrCreate<string>('widgetAction', raw);
LogUtil.write(`卡片入口:已设置 widgetAction=${raw}`);
}
// 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期)
const od = params['openDate'];
@@ -189,9 +196,15 @@ export default class EntryAbility extends UIAbility {
onForeground(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
// ⭐ 回到前台:① 归还短时任务配额 ② 立刻补同步一次(用户真正要看时数据必须最新)
BackgroundSyncService.cancelTransientSync();
BackgroundSyncRunner.runOnce(this.context, 'foreground');
}
onBackground(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
// ⭐ 退到后台:申请短时任务,兜住"退后台后那一次"同步能跑完
// (必须在退后台 5 秒内申请;单次最长 3 分钟,单日配额 10 分钟)
BackgroundSyncService.requestTransientSync('正在同步日历数据');
}
}
@@ -3,19 +3,47 @@
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { CardDataService, CardData } from '../common/CardDataService';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { TodoService } from '../common/TodoService';
import { LogUtil } from '../common/LogUtil';
export default class EntryFormAbility extends FormExtensionAbility {
/** 组装更新数据并推送到指定卡片 */
/** 当前进程 pid(取不到返回 -1):用于判断卡片回调是否与主 App 同进程 */
private static pidOf(): number {
try {
return process.pid;
} catch (err) {
return -1;
}
}
/**
* 组装更新数据并推送到指定卡片。
*
* ⭐ 一次同时构建「日程」与「待办」两份数据,合并进同一个 binding。
* 日程卡片只读 eventsJson 等字段,待办卡片只读 todoListJson 等字段,
* 彼此不会串味。这样无需维护 formId → 卡片类型的映射(那需要持久化,
* 且系统重启后容易丢失),一套逻辑服务全部 8 张卡片。
*/
private pushData(formId: string, dayOffset: number = 0): void {
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
Promise.all([
CardDataService.buildCardData(this.context, dayOffset),
CardDataService.buildTodoCardData(this.context),
]).then((results: CardData[]): void => {
const ev: CardData = results[0];
const td: CardData = results[1];
// 待办字段并入日程数据对象(两者都是 CardData,字段不冲突)
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(data);
formBindingData.createFormBindingData(ev);
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
});
LogUtil.write(`卡片 ${formId} 数据已推送`);
LogUtil.write(`卡片 ${formId} 数据已推送(日程+待办)`);
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片数据构建失败: ${err.message}`);
});
@@ -40,10 +68,30 @@ export default class EntryFormAbility extends FormExtensionAbility {
onUpdateForm(formId: string): void {
LogUtil.init(this.context);
LogUtil.write(`系统触发卡片更新: formId=${formId}`);
LogUtil.write(`系统触发卡片更新: formId=${formId}(进程 pid=${EntryFormAbility.pidOf()})`);
CardDataService.registerForm(this.context, formId);
// ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
this.pushData(formId, CardDataService.getDayOffset(formId));
// ⭐ ② 再借这次系统刷新顺带跑一次后台同步(见 runOnceThrottledByCard:带节流、永不抛错)
// 同步成功后再推一次,让卡片显示刚同步回来的最新数据。
//
// ⚠️ 关键约束(依据官方 FormExtensionAbility 生命周期文档):
// "FormExtensionAbility 进程不能常驻后台,生命周期回调中无法处理长时间的任务,
// 生命周期调度完成后会继续存在 **10 秒**……可能需要 10 秒以上才能完成的业务逻辑,
// 建议拉起主应用进行处理。"
// ⇒ 本处同步**可能被进程回收中断**。这是**可接受**的,因为:
// · 同步是幂等的(基于 etag 增量比对 + dirty 标记),中断不会损坏任何数据;
// · 中断只是"这一次没同步成",下个 30 分钟卡片再刷新时会重试;
// · 正常网络下 1~3 个账号的**增量**同步通常数秒内完成,落在 10 秒窗口内。
// ⇒ 因此它是"**尽力而为地提高后台同步频率**",与 workScheduler 2 小时兜底、
// 回前台/网络恢复补同步共同构成多层保障,**不承诺 30 分钟必然同步成功**。
// (不采用"拉起主应用同步"是因为会每 30 分钟弹出一次界面,体验不可接受。)
BackgroundSyncRunner.runOnceThrottledByCard(this.context).then((): void => {
this.pushData(formId, CardDataService.getDayOffset(formId));
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片驱动同步失败: ${err.message}`);
});
}
onRemoveForm(formId: string): void {
@@ -53,12 +101,35 @@ export default class EntryFormAbility extends FormExtensionAbility {
CardDataService.clearDayOffset(formId);
}
/** 卡片上「‹ / › / 回到今天」按钮触发:message 里带 pageAction=prev|next|today */
/**
* 卡片事件:既处理日程翻页,也处理待办打勾。
*
* ⚠️ 不用 JSON.parse 解析 message —— 不同系统版本下格式可能是
* `{params:{...}}` 也可能是直接 `{...}`,格式差异会让 JSON 解析拿到 undefined。
* 统一用**子串匹配**,稳且不依赖具体结构。
*/
onFormEvent(formId: string, message: string): void {
LogUtil.init(this.context);
// ===== 待办打勾:message 里带 todoCheck=<localId> =====
const checkIdx: number = message.indexOf('todoCheck');
if (checkIdx >= 0) {
const m = /todoCheck\D{0,8}(\d+)/.exec(message);
const localId: number = m !== null ? parseInt(m[1], 10) : 0;
LogUtil.write(`卡片打勾待办: formId=${formId} localId=${localId} msg=${message}`);
if (localId > 0) {
// onFormEvent 是同步回调,这里异步执行但必须兜住 reject,
// 否则未捕获的 Promise 会让进程异常(FormExtension 生命周期只有 10 秒)
this.completeTodoFromCard(formId, localId).catch((err: BusinessError): void => {
LogUtil.write('卡片打勾未捕获异常: ' + err.message);
});
}
return;
}
// ===== 日程翻页 =====
const cur: number = CardDataService.getDayOffset(formId);
let next: number = cur;
// 不用 JSON 解析:不同版本 message 的格式可能是 {params:{...}} 或直接 {...},直接子串匹配更稳
if (message.indexOf('prev') >= 0) {
next = cur - 1;
} else if (message.indexOf('next') >= 0) {
@@ -70,4 +141,63 @@ export default class EntryFormAbility extends FormExtensionAbility {
LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`);
this.pushData(formId, next);
}
/**
* 在卡片上完成一条待办。
*
* 时序设计(关键):
* ① **先推一次数据让该条从卡片消失** —— 用户点勾必须立刻有视觉反馈。
* 但此时库里还没改成 completed,所以这里不等它完成,
* 而是先推送"排除该 localId"的临时数据(见 pushDataExcludingTodo)。
* ② 再真正调 TodoService.setCompleted(内部 GET 原 ics → 改写 → PUT)。
* ③ 成功 → 推真实数据(该条已 completed,本来就不在列表里,效果一致);
* 失败 → 也推真实数据(该条会重新出现,用户能看出没成功,可重试或进 App 处理)。
*
* 为什么不 await 完再推:
* FormExtensionAbility 生命周期回调只有 10 秒窗口,
* GET+PUT 两步网络在慢网下可能 10 秒以上,卡片会先"卡住不动"再消失,体验很差。
*/
private async completeTodoFromCard(formId: string, localId: number): Promise<void> {
// ⭐ 关键:推给**所有**卡片,不只 formId 这一张。
// 原来只推 formId ⇒ 用户在 A 卡片打勾后,B 卡片仍显示那条待办,
// 必须在 B 上再点一次才刷新(用户实测反馈)。
// 每张卡片是独立实例、各持一份数据,必须逐个 updateForm。
// ① 乐观移除:所有卡片立即不含该条(先给视觉反馈,不等网络)
await CardDataService.pushToAllForms(this.context, localId);
// ② 真正落库 + 推送服务器(GET 原 ics → 改写 → PUT)
let ok: boolean = false;
try {
ok = await TodoService.setCompleted(this.context, localId, true);
LogUtil.write('卡片打勾结果: localId=' + localId + ' ' + (ok ? '成功' : '失败'));
} catch (err) {
const e = err as BusinessError;
LogUtil.write('卡片打勾异常: localId=' + localId + ' ' + e.message);
}
// ③ 无论成败都再推一次真实数据:
// 成功 → 该条已 completed,本就不在未完成列表里,各卡片保持一致
// 失败 → 该条重新出现,用户能看出没成功,可重试
await CardDataService.pushToAllForms(this.context);
}
/** 推送"排除某条待办"的卡片数据(乐观更新用,只改卡片视图,不动数据库) */
private pushDataExcludingTodo(formId: string, excludeId: number): void {
Promise.all([
CardDataService.buildCardData(this.context, CardDataService.getDayOffset(formId)),
CardDataService.buildTodoCardData(this.context, excludeId),
]).then((results: CardData[]): void => {
const ev: CardData = results[0];
const td: CardData = results[1];
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(ev);
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
LogUtil.write(`卡片 ${formId} 乐观更新失败: ${err.message}`);
});
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片乐观更新数据构建失败: ${err.message}`);
});
}
}
+33 -2
View File
@@ -6,6 +6,8 @@ import { buffer } from '@kit.ArkTS';
import url from '@ohos.url';
import { BusinessError } from '@kit.BasicServicesKit';
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
import { DavDiscovery, DavCapability } from '../common/DavDiscovery';
import { LogUtil } from '../common/LogUtil';
@Entry
@Component
@@ -107,12 +109,30 @@ struct AddAccountPage {
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
}
if (code === 401) {
this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码';
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
this.statusOk = false;
return false;
}
// ⭐ 0.0.7:405 是"这里不是 DAV 端点"的典型信号 —— 直接给出可操作指引,
// 而不是笼统地报一个状态码让用户去猜。
if (code === 405) {
const cap: DavCapability = await DavDiscovery.optionsCapability(serverUrl, authHeader);
if (!cap.isDav) {
this.statusMsg = '这个地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
+ '请填写「日历家目录」而不是 DAV 根或网页链接,例如:\n'
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
+ '(也可以直接填域名,下一步会自动尝试发现)';
this.statusOk = false;
return false;
}
}
if (code >= 200 && code < 500) {
// ⭐ 即使可连,也顺手提示"地址看起来不完整",让用户提前知道下一步会自动补全
if (DavDiscovery.looksIncomplete(serverUrl)) {
this.statusMsg = '服务器连接成功(该地址看起来是服务器根路径,下一步会自动尝试定位日历家目录)';
} else {
this.statusMsg = '服务器连接成功';
}
this.statusOk = true;
return true;
}
@@ -186,6 +206,17 @@ struct AddAccountPage {
if (this.isLoading) {
return;
}
// ⭐ 0.0.7:先识别「网页分享链接」——这类地址对 PROPFIND 必定 405,
// 提前拦下并给出人话指引,避免用户白跑一趟只看到"服务器返回 405"。
const kind: string = DavDiscovery.classify(targetUrl);
const shareMsg: string = DavDiscovery.shareHint(kind);
if (shareMsg !== '') {
this.statusMsg = shareMsg;
this.statusOk = false;
LogUtil.write(`添加账号:识别为网页分享链接(${kind}),已拦截`);
return;
}
this.isLoading = true;
this.statusMsg = '正在连接服务器…';
this.statusOk = false;
@@ -229,7 +260,7 @@ struct AddAccountPage {
.width('100%')
Column({ space: 16 }) {
this.formField('服务器地址', '例如:https://nas.example.com/caldav/', this.serverUrl,
this.formField('服务器地址', '例如:https://nas.example.com/remote.php/dav/calendars/用户名/(也可只填域名,下一步会自动识别)', this.serverUrl,
(value: string) => {
this.serverUrl = value;
}, false)
+33 -4
View File
@@ -5,6 +5,7 @@ import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
import { DavDiscovery, DiscoverResult, DavCapability } from '../common/DavDiscovery';
import { LogUtil } from '../common/LogUtil';
/**
@@ -98,17 +99,44 @@ struct CalendarListPage {
this.statusOk = false;
return;
}
const auth: string = 'Basic ' + token;
// ⭐ 0.0.7:先做地址自动发现/智能补全 —— 用户常只填域名或 DAV 根,
// 而 Nextcloud 等要求填「日历家目录」。此处自动找对端点,避免"读不出日历"。
try {
const resolved: DiscoverResult = await DavDiscovery.resolve(
this.serverUrl,
this.username,
auth,
async (u: string): Promise<boolean> => {
const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth);
return d.entries !== undefined && d.entries.length > 0;
});
if (resolved.ok && resolved.url !== '' && resolved.url !== this.serverUrl) {
LogUtil.write(`服务地址已自动修正:${this.serverUrl} → ${resolved.url}(来源 ${resolved.via})`);
this.serverUrl = resolved.url;
} else if (!resolved.ok && resolved.hint !== '') {
// 自动发现彻底失败 → 直接把"人话原因"给用户,不再只报一个状态码
this.isLoading = false;
this.statusMsg = resolved.hint;
this.statusOk = false;
return;
}
} catch (err) {
// 发现阶段异常不阻塞:继续用原地址走既有流程(保持旧行为兜底)
LogUtil.write(`地址发现阶段异常,回退原地址:${(err as BusinessError).message}`);
}
// PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑)
try {
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, 'Basic ' + token);
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, auth);
this.isLoading = false;
if (disc.statusCode === 401) {
this.statusMsg = '登录已失效,请返回重新连接';
this.statusMsg = '服务器拒绝凭据(401):请返回检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
this.statusOk = false;
return;
}
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
// ⭐ 0.0.7:把生硬的状态码换成人话 + 可操作指引
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
this.statusOk = false;
return;
}
@@ -118,7 +146,8 @@ struct CalendarListPage {
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
}
if (list.length === 0) {
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)';
// ⭐ 0.0.7:区分"地址层级不对"与"确实没有日历",并给出正确填法示例
this.statusMsg = DavDiscovery.emptyHint(this.serverUrl, this.username);
this.statusOk = false;
} else {
this.calendarList = list;
+60 -6
View File
@@ -10,6 +10,7 @@ import { common } from '@kit.AbilityKit';
import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore } from '../common/AccountStore';
import { DavDiscovery, DiscoverResult } from '../common/DavDiscovery';
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
import { EventDb } from '../common/EventDb';
import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror';
@@ -23,14 +24,27 @@ export class EditCalendarItem {
href: string;
name: string;
color: string; // 服务器定义的颜色(calendar-color),可能为空
/** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */
comps: string;
selected: boolean;
constructor(href: string, name: string, color: string) {
constructor(href: string, name: string, color: string, comps: string = 'unknown') {
this.href = href;
this.name = name;
this.color = color;
this.comps = comps;
this.selected = false;
}
/** 是否待办本(纯待办或混合) */
isTodoBook(): boolean {
return this.comps === 'todo' || this.comps === 'both';
}
/** 是否日历本(纯日历、混合、或服务器未声明) */
isEventBook(): boolean {
return this.comps === 'event' || this.comps === 'both' || this.comps === 'unknown';
}
}
/**
@@ -141,24 +155,47 @@ struct EditAccountPage {
return;
}
// PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现)
const auth: string = 'Basic ' + token;
// ⭐ 0.0.7:地址自动发现/智能补全(老账号可能当初填的是 DAV 根或已失效的路径)
try {
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, 'Basic ' + token);
const resolved: DiscoverResult = await DavDiscovery.resolve(
this.acc.serverUrl,
this.acc.username,
auth,
async (u: string): Promise<boolean> => {
const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth);
return d.entries !== undefined && d.entries.length > 0;
});
if (resolved.ok && resolved.url !== '' && resolved.url !== this.acc.serverUrl) {
LogUtil.write(`编辑账号:服务地址自动修正 ${this.acc.serverUrl} → ${resolved.url}(${resolved.via})`);
this.acc.serverUrl = resolved.url;
} else if (!resolved.ok && resolved.hint !== '') {
this.isLoading = false;
this.statusMsg = resolved.hint;
this.statusOk = false;
return;
}
} catch (err) {
LogUtil.write(`编辑账号:地址发现异常,回退原地址 ${(err as BusinessError).message}`);
}
try {
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, auth);
this.isLoading = false;
if (disc.statusCode === 401) {
this.statusMsg = '登录已失效,请检查账号密码';
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
this.statusOk = false;
return;
}
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
this.statusOk = false;
return;
}
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
new EditCalendarItem(e.href, e.displayName, e.color));
new EditCalendarItem(e.href, e.displayName, e.color, e.comps));
if (list.length === 0) {
LogUtil.write('编辑账号:未发现任何日历本');
this.statusMsg = '该路径下未发现日历本';
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
this.statusOk = false;
} else {
// 已勾选的日历本按账号当前配置预选
@@ -222,6 +259,7 @@ struct EditAccountPage {
target.calendarHrefs = newHrefs;
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
target.calendarComps = selectedItems.map((c: EditCalendarItem): string => c.comps);
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
@@ -506,6 +544,22 @@ struct EditCalendarRow {
build() {
Row({ space: 12 }) {
// ⭐ SVG_ICON 类型图标:放在颜色圆点**前面**,
// 让用户先看清"这本是日历还是待办",再看它是什么颜色。
//
// 用 SVG 而不是字符:字符图标(▦ / ✓)依赖系统字体字形,
// 笔画粗细与视觉重量不统一,跨设备还可能缺字形变成方框。
// 这两个 SVG 共用同一套笔触(stroke 2、round 端点、24×24),
// 渲染成 16vp 时重量一致。
//
// 混合本(both):显示待办图标(它确实能存待办),
// 但用户看名字/颜色仍能分辨,不必塞两个图标挤在一行。
Image(this.item.comps === 'todo' || this.item.comps === 'both'
? $r('app.media.ic_todo_book')
: $r('app.media.ic_event_book'))
.width(16)
.height(16)
.objectFit(ImageFit.Contain)
if (this.item.color !== '') {
Circle()
.width(12)
+653 -35
View File
@@ -1,8 +1,8 @@
// entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
import { curves, router } from '@kit.ArkUI';
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { notificationManager } from '@kit.NotificationKit';
import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore';
import { EventDb, LocalEvent } from '../common/EventDb';
@@ -13,6 +13,8 @@ import { ReminderService } from '../common/ReminderService';
import { LogUtil } from '../common/LogUtil';
import { IcsUtil } from '../common/IcsUtil';
import { RruleUtil } from '../common/RruleUtil';
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
import { PlaceParser, ParsedPlace } from '../common/PlaceParser';
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
class SeriesBounds {
@@ -34,6 +36,34 @@ class BookChoice {
struct EventEditPage {
@State title: string = '';
@State location: string = '';
/**
* ⭐ 地点补全(鸿蒙 Map Kit / Site Kit):
* placeItems —— 候选数据(**不参与页面布局**,由 bindPopup 渲染成浮层)
* placeLoading —— 是否正在请求,浮层内显示"搜索中"
* placeOpen —— 浮层是否弹出(绑在输入框上,向上弹)
* placeHint —— 无结果/地图能力不可用时的提示(不阻塞手输地点)
*
* ⚠️ 为什么必须是浮层而不是内联列表:内联列表改变表单高度 ⇒ 输入框跟着移动 ⇒
* 每次"搜索中/出结果/结果变少"都要滚动纠正 ⇒ 用户看到输入框反复上下跳。
*/
@State placeItems: PlaceItem[] = [];
@State placeLoading: boolean = false;
@State placeOpen: boolean = false;
@State placeHint: string = '';
/** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */
private placePicked: boolean = false;
/**
* 最后一次**程序写入**输入框的文本(选中候选后回写的地址)。
* TextInput 的 text 被程序改写同样会触发 onChange;用它精确识别"这次 onChange
* 不是用户打字造成的",从而不重复发起搜索。
*/
private lastAppliedText: string = '';
/** 地点输入框当前是否聚焦(仅用于日志/状态,不再驱动滚动) */
private placeFocused: boolean = false;
/** 输入防抖定时器 */
private placeTimer: number = -1;
/** 表单滚动控制器(键盘避让由系统 RESIZE 模式负责,这里不再主动滚) */
private formScroller: Scroller = new Scroller();
@State description: string = '';
@State allDay: boolean = false;
@State startMs: number = 0;
@@ -41,7 +71,20 @@ struct EventEditPage {
@State books: BookChoice[] = [];
@State chosenKey: string = '';
@State isSaving: boolean = false;
/**
* 表单状态行文字。
* ⚠️ 它渲染在表单**最底部**("删除日程"按钮上方),用户在顶部点"保存"时根本看不见。
* 所以它只能作为"事后可复查的记录",**不能**作为唯一的错误反馈手段 ——
* 真正的提醒必须走 showFormError():Toast(必可见)+ 字段标红 + 滚到出错字段。
*/
@State statusMsg: string = '';
/**
* ⭐ 哪个字段校验没过('' = 没有)。
* 用途:把对应输入框描红并显示一条紧贴字段的红字说明,
* 让用户一眼看到"哪儿错了",而不是在一屏之外找一行小字。
* 取值:'title' | 'time' | ''
*/
@State errorField: string = '';
@State isExisting: boolean = false;
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
/**
@@ -70,6 +113,8 @@ struct EventEditPage {
@State sheetMode: string = 'picker';
@State pickerIsEnd: boolean = false;
@State pickDate: Date = new Date();
/** 新建"非今天"日程时的默认开始小时(24 小时制) */
private static readonly DEFAULT_START_HOUR: number = 9;
@State pickHour: number = 9;
@State pickMin: number = 0;
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
@@ -87,9 +132,36 @@ struct EventEditPage {
private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> {
// 键盘避让不阻塞页面加载:拿窗口是异步的,失败也只是少一层滚动校正
this.setupKeyboardAvoid();
return this.initPage();
}
/** 页面销毁:清掉地点补全的防抖定时器 */
aboutToDisappear(): void {
this.cancelSuggestTimer();
}
/**
* ⭐ 键盘避让:改成 RESIZE 模式。
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕。
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
* 聚焦的输入框由系统自动滚入可见区,**无需再手动 scrollBy**。
*
* ⚠️ 曾经写过"手动量矩形 + scrollBy 对齐"的方案,是错的方向:
* ① getRectangleById 返回 px、scrollBy 收 vp,单位混用会把输入框推飞;
* ② 更根本的是——只要候选参与布局,位置就会反复变化,
* 靠"事后纠正"必然表现为抖动。所以候选改成了浮层(见 placePopup)。
*/
private setupKeyboardAvoid(): void {
try {
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
}
}
private async initPage(): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
@@ -202,18 +274,331 @@ struct EventEditPage {
return;
}
}
// 新建:日期取所选日(未选则今天),时间取当前时刻;结束 = 开始 + 1 小时
// 新建:日期取所选日(未选则今天)。默认开始时刻分两种情况:
// ① 今天 → 取当前时刻("现在就记一笔"最自然;而且今天的 09:00 往往已经过了)
// ② 非今天 → 固定 09:00("现在几点"对别的日子毫无参考价值,09:00 才是通用默认)
// 结束时间统一 = 开始 + 1 小时。
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
new Date(base).getDate()).getTime();
const pickedDay = new Date(base);
const dayStart = new Date(pickedDay.getFullYear(), pickedDay.getMonth(),
pickedDay.getDate()).getTime();
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
if (dayStart === todayStart) {
// ① 今天:当前时刻
this.startMs = dayStart + now.getHours() * 3600000 + now.getMinutes() * 60000;
} else {
// ② 非今天:09:00
this.startMs = dayStart + EventEditPage.DEFAULT_START_HOUR * 3600000;
}
this.endMs = this.startMs + 3600000;
if (choices.length > 0) {
this.chosenKey = choices[0].calKey;
}
}
// ==================== 地点补全(鸿蒙 Map Kit / Site Kit) ====================
/**
* 输入防抖:停止输入 350ms 后才真正发请求。
* 好处:① 不把服务端配额/网络浪费在"每敲一个字"上;② 避免结果闪烁。
* ⚠️ 这里只用「关键词补全」这一种能力(不需要定位权限),
* 所以**不需要**申请 LOCATION 权限,符合"权限只在用户主动点该功能时申请"的约束。
*/
private scheduleSuggest(v: string): void {
this.cancelSuggestTimer();
const q: string = v.trim();
if (q.length < 1) {
this.closePlacePopup();
return;
}
this.placeTimer = setTimeout(() => {
this.placeTimer = -1;
this.runSuggest(q, false);
}, 350);
}
private cancelSuggestTimer(): void {
if (this.placeTimer !== -1) {
clearTimeout(this.placeTimer);
this.placeTimer = -1;
}
}
private async runSuggest(q: string, forceOpen: boolean): Promise<void> {
if (this.placePicked && !forceOpen) {
return;
}
if (PlaceSuggest.isUnavailable()) {
// 地图服务没开通:给一次明确提示,然后永久静默(不阻塞手输)
this.placeHint = '地图服务不可用,可直接手动输入地点';
this.placeItems = [];
this.placeOpen = true;
return;
}
// ⭐ 浮层一直保持展开(从"搜索中"过渡到结果),中间不收起、不重弹 ——
// 收起再弹会让浮层闪一下,看起来像"消失了又出现"。
this.placeLoading = true;
this.placeHint = '';
this.placeOpen = true;
try {
const c = this.getUIContext().getHostContext();
const ctx: common.Context | undefined = c;
// 先补全;补全空则内部会自动退到关键字搜索
let items: PlaceItem[] = await PlaceSuggest.query(q, ctx);
if (items.length === 0 && !PlaceSuggest.isUnavailable()) {
items = await PlaceSuggest.search(q, ctx);
}
// ⭐ 丢弃过期结果:用户又改了输入框,就别再覆盖成旧候选
if (q !== this.location.trim()) {
return;
}
this.placeItems = items.slice(0, 6);
if (this.placeItems.length === 0) {
this.placeHint = PlaceSuggest.isUnavailable()
? '地图服务不可用,可直接手动输入地点'
: '没有找到匹配地点,可直接手动输入';
// 无结果也留在浮层里给提示;用户再输入会被下一次请求覆盖
this.placeOpen = true;
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`地点补全异常: ${e.message}`);
this.placeItems = [];
this.placeHint = '地点搜索失败,可直接手动输入';
this.placeOpen = true;
} finally {
this.placeLoading = false;
}
}
/**
* ⭐ 候选浮层(bindPopup)的内容。
*
* 为什么是浮层而不是内联列表:
* 内联列表会改变表单高度 ⇒ 输入框位置随之变化 ⇒ 每次"搜索中/出结果/结果变少"
* 都要靠滚动去纠正 ⇒ 用户看到的就是**输入框上下跳好几次**。
* 浮层不参与布局,出现与消失**完全不移动输入框**,这是唯一能根治该问题的结构。
*
* 为什么向上弹(Placement.Top):
* 输入框在表单下部、键盘又在下方,向下弹必然被键盘盖住;
* 向上弹则整块候选都落在输入框与标题之间的空白区。
*/
@Builder
placePopup() {
Column({ space: 0 }) {
if (this.placeLoading) {
Row({ space: 8 }) {
LoadingProgress().width(16).height(16).color($r('app.color.text_hint'))
Text('搜索中…')
.fontSize(13)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
} else if (this.placeItems.length > 0) {
// 限高 + 内部滚动:候选最多 6 条,一次全铺开会顶到屏幕顶部
List() {
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
ListItem() {
Column({ space: 2 }) {
Text(it.title)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
if (it.subtitle !== '') {
Text(it.subtitle)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
}
.onClick(() => {
this.applyPlace(it);
})
}, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`)
}
.width('100%')
.constraintSize({ maxHeight: 216 })
.scrollBar(BarState.Auto)
.divider({ strokeWidth: 0.5, color: $r('app.color.shadow_color') })
} else {
Text(this.placeHint !== '' ? this.placeHint : '没有找到匹配地点,可直接手动输入')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
}
.width('100%')
.padding({ top: 4, bottom: 4 })
}
/**
* 从剪贴板粘贴并解析地点。
*
* ⚠️ 必须由 PasteButton 的 onClick 触发,权限仅在回调内有效。
* 在回调外调用 pasteboard.getData() 会因缺少权限而失败。
*/
private async pastePlace(): Promise<void> {
this.cancelSuggestTimer();
let text: string = '';
try {
const sys = pasteboard.getSystemPasteboard();
const has: boolean = await sys.hasData();
if (!has) {
this.toast('剪贴板是空的');
return;
}
const data: pasteboard.PasteData = await sys.getData();
text = data.getPrimaryText();
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`粘贴读取失败: ${e.code} ${e.message}`);
this.toast('无法读取剪贴板');
return;
}
if (text === undefined || text === null || text.trim() === '') {
this.toast('剪贴板里没有文字');
return;
}
await this.resolvePastedText(text.trim());
}
/**
* 把粘贴到的文本解析成地点。
*
* 三级降级(从准到松):
* ① 高德链接 → 直接有坐标,无需检索
* ② 微信文本 → 拆出名称,用**名称**检索(实测命中率最高)
* ③ 名称搜不到 → 用完整地址再搜一次
* ④ 都失败 → 把原文填进地点栏,提示用户手动确认
*
* 第 ④ 步兜底很重要:宁可让用户自己看一眼,
* 也不能因为解析失败就清空输入框或卡住保存。
*/
private async resolvePastedText(text: string): Promise<void> {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
let parsed: ParsedPlace | null = null;
try {
parsed = await PlaceParser.parse(text, ctx);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`粘贴解析异常: ${e.message}`);
}
// 解析不出东西 → 原文填入,让用户自行判断
if (parsed === null) {
this.lastAppliedText = text;
this.location = text;
this.toast('已填入,请确认地点是否正确');
return;
}
// 有坐标(高德链接):直接用,坐标已包含在解析结果里
if (parsed.hasCoord) {
const locText: string = PlaceParser.mergeForLocationText(parsed.name, parsed.address);
this.lastAppliedText = locText !== '' ? locText : text;
this.location = this.lastAppliedText;
this.placePicked = true;
this.toast('已从链接解析地点');
return;
}
// 无坐标(微信文本):名称优先检索,失败再用地址
const tryKeywords: string[] = [];
if (parsed.name !== '') {
tryKeywords.push(parsed.name);
}
if (parsed.address !== '' && parsed.address !== parsed.name) {
tryKeywords.push(parsed.address);
}
if (tryKeywords.length === 0) {
tryKeywords.push(text);
}
for (let i = 0; i < tryKeywords.length; i++) {
const kw: string = tryKeywords[i];
try {
let items: PlaceItem[] = await PlaceSuggest.query(kw, ctx);
if (items.length === 0) {
items = await PlaceSuggest.search(kw, ctx);
}
if (items.length > 0) {
// 命中:直接应用第一条(与用户点选候选等价)
const best: PlaceItem = items[0];
this.placePicked = true;
this.lastAppliedText = best.locationText;
this.location = best.locationText;
this.toast('已匹配到地点');
return;
}
} catch (err) {
LogUtil.write(`粘贴检索失败(${kw}): ${(err as BusinessError).message}`);
}
}
// 全部检索失败:填原文,不阻塞
const fallback: string = parsed.name !== '' ? parsed.name : parsed.address;
this.lastAppliedText = fallback !== '' ? fallback : text;
this.location = this.lastAppliedText;
this.placePicked = true;
this.toast('已填入,请确认地点是否正确');
}
/** 轻量提示(复用页面已有的 toast 机制;没有就静默) */
private toast(msg: string): void {
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 1800 });
}
/** 选中一条候选:写进地点输入框并收起列表 */
private applyPlace(it: PlaceItem): void {
// ⭐ 顺序很关键:先收起浮层,再写文本。
// TextInput 的 text 被程序改写会触发 onChange,若此时 placePicked 已被
// onChange 重置为 false,就会**重新发起一次搜索** → 用户看到
// "候选框消失了一下又冒出来一次"。用 lastAppliedText 精确识别这类回写。
this.cancelSuggestTimer();
this.closePlacePopup();
this.placePicked = true;
this.lastAppliedText = it.locationText;
this.location = it.locationText;
}
/** 收起候选浮层(统一入口,保证 placePicked 之外的状态都被清干净) */
private closePlacePopup(): void {
this.placeOpen = false;
this.placeLoading = false;
this.placeItems = [];
this.placeHint = '';
}
/** 打开系统地图选点页(可搜索、可周边、可拖图钉) */
private async pickOnMap(): Promise<void> {
const c = this.getUIContext().getHostContext();
if (c === undefined) {
return;
}
const it: PlaceItem | null = await PlaceSuggest.pickOnMap(c as common.UIAbilityContext);
if (it === null) {
// 用户取消,或地图服务不可用
if (PlaceSuggest.isUnavailable()) {
this.placeHint = '地图服务不可用,可直接手动输入地点';
}
return;
}
this.applyPlace(it);
}
private chosenBook(): BookChoice | null {
const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
if (found !== undefined) {
@@ -432,20 +817,74 @@ struct EventEditPage {
this.endMs = dayStart + 86399999;
} else {
const s = new Date(this.startMs);
this.startMs = s.getTime() + 9 * 3600000;
this.startMs = new Date(s.getFullYear(), s.getMonth(), s.getDate()).getTime()
+ EventEditPage.DEFAULT_START_HOUR * 3600000;
this.endMs = this.startMs + 3600000;
}
}
/**
* ⭐ 统一的表单错误提示(**唯一的错误出口**)。
*
* 为什么需要专门做这件事:
* 校验提示原本只写进 `statusMsg`,而它渲染在表单最底部("删除日程"按钮上方)。
* 用户在顶部点"保存"时,那行红字远在屏幕之外 —— 看起来就是"点了没反应"。
*
* 三件事一起做,保证用户一定看得见、也知道该改哪儿:
* ① **Toast**:系统级浮在屏幕中上部,不受滚动位置影响,必定可见;
* ② **字段标红**:给对应输入框加红框 + 紧贴字段的红字说明;
* ③ **滚回出错字段并聚焦**:把视野直接带到问题所在(标题 → 打光标;时间 → 滚到时间卡片)。
*
* @param msg 给用户看的话术(要具体,说清"该做什么",不要只说"错误")
* @param field 'title' | 'time' | ''('' 表示没有具体字段,只弹 Toast)
*/
private showFormError(msg: string, field: string): void {
this.statusMsg = msg;
this.errorField = field;
// ① Toast:不走布局,无论滚到哪儿都能看到
try {
this.getUIContext().getPromptAction().showToast({
message: msg,
duration: 2500,
bottom: '70%' // 抬到屏幕偏上方,避免被键盘/底部按钮遮挡
});
} catch (err) {
// Toast 失败也不影响其它两种提示
}
// ③ 滚回出错位置:延时一帧,等状态刷新(标红后高度可能变化)后再滚
setTimeout(() => {
try {
if (field === 'title') {
this.formScroller.scrollEdge(Edge.Top);
try {
this.getUIContext().getFocusController().requestFocus('titleInput');
} catch (e2) {
// 聚焦失败(例如输入法正在切换)不阻塞流程
}
} else if (field === 'time') {
// 时间卡片在表单上部,滚到顶基本就能看到;再留一点余量
this.formScroller.scrollEdge(Edge.Top);
} else {
this.formScroller.scrollEdge(Edge.Top);
}
} catch (err2) {
const e = err2 as BusinessError;
LogUtil.write(`错误提示定位失败: ${e.message}`);
}
}, 60);
}
private validate(): boolean {
if (this.title.trim() === '') {
this.statusMsg = '请输入日程标题';
this.showFormError('请先填写日程标题', 'title');
return false;
}
if (this.endMs < this.startMs) {
this.statusMsg = '结束时间不能早于开始时间';
this.showFormError('结束时间不能早于开始时间', 'time');
return false;
}
// 通过校验:清掉上一轮的错误标记
this.errorField = '';
return true;
}
@@ -711,9 +1150,10 @@ struct EventEditPage {
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
// 这也是"必填项缺失",同样要弹 Toast + 滚到对应字段,不能只写底部状态行
this.showFormError(this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
: '请选择要同步到的 CalDAV 日历本', 'book');
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
return;
}
@@ -726,19 +1166,20 @@ struct EventEditPage {
return;
}
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误用 showFormError 强提示)。
if (!this.validate()) {
return;
}
const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
this.showFormError(this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
: '请选择要同步到的 CalDAV 日历本', 'book');
return;
}
this.isSaving = true;
this.statusMsg = '';
this.errorField = '';
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
@@ -798,6 +1239,16 @@ struct EventEditPage {
const ex = err as BusinessError;
LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`);
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
// ⭐ 这里紧接着会 router.back(),底部状态行根本来不及被看到 → 必须用 Toast
try {
this.getUIContext().getPromptAction().showToast({
message: '已保存到本地,同步到服务器失败,稍后会自动重试',
duration: 3000,
bottom: '70%'
});
} catch (e2) {
// Toast 失败不影响返回
}
// 数据仍在本地且带 dirty 标记,不会丢
router.back();
}
@@ -940,6 +1391,7 @@ struct EventEditPage {
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column({ space: 14 }) {
// 顶部(与内容区左右对齐,避免贴边误触)
Row({ space: 6 }) {
@@ -968,10 +1420,12 @@ struct EventEditPage {
.width('100%')
.padding({ left: 16, right: 16 })
Scroll() {
Scroll(this.formScroller) {
Column({ space: 14 }) {
// 标题
TextInput({ text: this.title, placeholder: '标题' })
// 标题(必填)
// ⭐ 校验失败时:输入框描红 + 紧贴下方一条红字说明。
// 只在 errorField === 'title' 时出现,用户开始输入即自动清除。
TextInput({ text: this.title, placeholder: '标题(必填)' })
.height(46)
.fontSize(16)
.backgroundColor($r('app.color.card_bg'))
@@ -981,9 +1435,28 @@ struct EventEditPage {
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
// 标红:用边框而不是背景,深色/浅色主题下都醒目且不难看
.border({
width: this.errorField === 'title' ? 1.5 : 0,
color: $r('app.color.error')
})
.onChange((v: string) => {
this.title = v;
// 用户开始填写就把红框撤掉,避免"改了还一直红着"
if (v.trim() !== '' && this.errorField === 'title') {
this.errorField = '';
this.statusMsg = '';
}
})
.id('titleInput')
if (this.errorField === 'title') {
Text('请填写标题后再保存')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%')
.padding({ left: 4 })
}
// 全天
Row() {
@@ -1012,6 +1485,19 @@ struct EventEditPage {
.padding(6)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 时间顺序错误时整卡描红,用户一眼能看到问题在这个卡片里
.border({
width: this.errorField === 'time' ? 1.5 : 0,
color: $r('app.color.error')
})
if (this.errorField === 'time') {
Text('结束时间不能早于开始时间')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%')
.padding({ left: 4 })
}
// 重复
Column({ space: 8 }) {
@@ -1173,6 +1659,11 @@ struct EventEditPage {
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
.onClick(() => {
this.chosenKey = b.calKey;
// 选了日历本就把红框撤掉
if (this.errorField === 'book') {
this.errorField = '';
this.statusMsg = '';
}
})
}, (b: BookChoice) => b.calKey)
}
@@ -1183,11 +1674,20 @@ struct EventEditPage {
.width('100%')
.padding(14)
.borderRadius(12)
// 日历本未选且用户点了保存 → 描红,提示"必填"
.border({
width: this.errorField === 'book' ? 1.5 : 0,
color: $r('app.color.error')
})
.backgroundColor($r('app.color.card_bg'))
// 地点
TextInput({ text: this.location, placeholder: '地点(可选)' })
// 地点(输入几个字 → 系统地图服务补全候选)
// ⭐ 候选列表**不在布局流里**:它通过 bindPopup 挂在输入框上、向**上方**弹出。
// 这样候选的出现/消失完全不改变输入框位置 —— 从根上消除"输入框上下跳"。
Row({ space: 8 }) {
TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
.height(44)
.layoutWeight(1)
.fontSize(14)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
@@ -1195,8 +1695,96 @@ struct EventEditPage {
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => {
// ⭐ 程序改写 text(选中候选后回写地址)也会触发 onChange。
// 精确比对最后一次程序写入值:命中就不再发起搜索,
// 否则会"选完 → 候选框消失 → 又冒出来一次"。
if (v === this.lastAppliedText) {
this.lastAppliedText = '';
this.location = v;
return;
}
this.lastAppliedText = '';
this.location = v;
// 用户重新输入时解除"已选中"状态,恢复自动补全
this.placePicked = false;
this.scheduleSuggest(v);
})
.onSubmit(() => {
// 回车:立刻搜一次(不等防抖)
this.cancelSuggestTimer();
this.runSuggest(this.location, true);
})
.onFocus(() => {
// 键盘避让交给系统(RESIZE 已保证光标可见);
// 候选是浮层,不需要再滚动页面去给它腾位置
this.placeFocused = true;
})
.onBlur(() => {
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选浮层
this.placeFocused = false;
this.closePlacePopup();
})
.bindPopup($$this.placeOpen, {
builder: (): void => {
this.placePopup()
},
placement: Placement.Top,
popupColor: $r('app.color.card_bg'),
enableArrow: false,
showInSubWindow: false,
focusable: false,
autoCancel: true,
radius: 12,
onStateChange: (e: PopupStateChangeParam) => {
// 用户点浮层外部/floating 自动关闭时,同步状态,避免下次不弹
if (!e.isVisible && this.placeOpen) {
this.placeOpen = false;
}
}
})
.id('placeAnchor')
// ⭐ 粘贴地址:系统 PasteButton 安全控件
// 用它而不是自绘按钮 + pasteboard.getData(),原因见 pastePlace() 注释:
// READ_PASTEBOARD 是受限权限,普通应用申请会被拒绝。
// PasteButton 无需声明权限、无需弹窗,权限仅在 onClick 内有效。
// ⭐ PasteButton:安全控件,点击时临时获得剪贴板读权限
// (无需申请 READ_PASTEBOARD —— 那是受限权限,普通应用申请会被拒)
//
// ⚠️ icon/text/buttonType **只能走构造参数**,写成链式属性会编译报错。
// 文字内容由系统枚举决定(PasteDescription 只有 PASTE 一个值,即「粘贴」)。
// 外观与右边的「地图」按钮保持一致:同字号、同色、同底色、同圆角、同高度。
PasteButton({
text: PasteDescription.PASTE,
// ⚠️ 必须用 Normal,不能用 Capsule:
// 按 SDK 文档,Capsule / Circle 下 borderRadius **不生效**,
// 圆角由按钮类型自动决定(= 两端全圆),与「地图」的圆角矩形不一致。
// 只有 Normal / ROUNDED_RECTANGLE 才认 borderRadius。
buttonType: ButtonType.Normal
})
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.backgroundColor($r('app.color.card_bg'))
.height(44)
.padding({ left: 12, right: 12 })
.borderRadius(12)
.onClick(() => {
this.pastePlace();
})
// 在地图上选点(系统地图界面,可选可搜可周边)
Text('地图')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.padding({ left: 12, right: 12 })
.height(44)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.onClick(() => {
this.pickOnMap();
})
}
.width('100%')
.id('placeRow')
// 描述
TextArea({ text: this.description, placeholder: '备注(可选)' })
@@ -1211,7 +1799,10 @@ struct EventEditPage {
this.description = v;
})
if (this.statusMsg !== '') {
// 状态行:只用于"保存失败/账号未配置"这类**系统级**信息,且必须有 Toast 同时提示。
// ⚠️ 校验类错误不走这里 —— 它们已经在对应字段旁就地显示,
// 再在这里重复一遍只会让用户以为有两处不同的问题。
if (this.statusMsg !== '' && this.errorField === '') {
Text(this.statusMsg)
.fontSize(13)
.fontColor($r('app.color.error'))
@@ -1249,20 +1840,34 @@ struct EventEditPage {
.backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// ⚠️ 整个页面**只允许一个 bindSheet**:挂两个时后者会覆盖前者,
// 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。
// 这里用 sheetMode 在同一个弹层里切换内容。
.bindSheet($$this.sheetShow, this.bottomSheet(), {
height: this.sheetMode === 'scope' ? 320 : (this.sheetMode === 'repeatEnd' ? 340 : 380),
showClose: false,
dragBar: true,
backgroundColor: $r('app.color.card_bg')
// ===== 居中弹层 =====
if (this.sheetShow) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => {
this.sheetShow = false;
})
this.centerSheet()
}
}
.width('100%')
.height('100%')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"/"重复范围选择"/"重复截止日期" */
/**
* 居中弹层(自绘覆盖层,与待办页一致)。
*
* 原来用 bindSheet 从底部弹出,用户反馈"点完之后选择框在下面出来,操作不顺畅"。
* 改成屏幕居中,视线不用上下跳。
*
* 为什么不用 bindSheet:SDK 的 SheetOptions 没有 position/alignment,只能底部弹。
*/
@Builder
bottomSheet() {
centerSheet() {
if (this.sheetMode === 'scope') {
this.scopeSheet()
} else if (this.sheetMode === 'repeatEnd') {
@@ -1312,8 +1917,10 @@ struct EventEditPage {
this.sheetShow = false;
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
}
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
@@ -1349,8 +1956,10 @@ struct EventEditPage {
this.sheetShow = false;
})
}
.width('100%')
.padding({ left: 20, right: 20 })
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
}
@Builder
@@ -1559,8 +2168,10 @@ struct EventEditPage {
this.sheetShow = false;
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
}
}
@@ -1584,6 +2195,13 @@ class CalendarDataBridge {
if (!detected || manualKeys.includes(`${acc.id}_${i}`)) {
continue;
}
// ⭐ 只列**能放日程**的本:纯待办本(COMP_SET 只有 VTODO)写不进 VEVENT。
// 'both' / 'unknown' 保留,理由同 Index.openTodoCreator。
const comp: string = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
? acc.calendarComps[i] : 'unknown';
if (comp === 'todo') {
continue;
}
const s = new CalSourceWithHref();
s.calKey = `${acc.id}_${i}`;
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
File diff suppressed because it is too large Load Diff
+410 -14
View File
@@ -11,6 +11,7 @@ import { AccountStore, DavAccount } from '../common/AccountStore';
import { ReminderService } from '../common/ReminderService';
import { BusinessError } from '@kit.BasicServicesKit';
import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { MirrorRenamer, RenameResult } from '../common/MirrorRenamer';
import { DocViewer } from '../common/DocViewer';
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
@@ -27,7 +28,7 @@ class BackupTarget {
@Component
struct SettingsPage {
@State showSystem: boolean = true;
@State intervalMinutes: number = 1;
@State intervalMinutes: number = 15; // ⭐ 0.0.8:默认 15 分钟(原 1 分钟会烧光短时任务配额)
@State backgroundSync: boolean = false;
@State sysMode: string = 'display'; // display | backup
@State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
@@ -48,9 +49,16 @@ struct SettingsPage {
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
@State mirrorRunning: boolean = false; // 正在执行镜像
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
@State wipeAllRunning: boolean = false; // 正在清除全部镜像(卸载前清干净)
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
@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;
aboutToAppear(): void {
@@ -103,6 +111,8 @@ struct SettingsPage {
if (!ok && this.context !== undefined) {
this.mirrorEnabled = false;
AppSettings.setMirrorEnabled(this.context, false);
// 权限被回收 → 镜像失效 → 提醒应交回 App 负责
ReminderService.invalidateMirrorCache();
}
});
}
@@ -118,6 +128,71 @@ struct SettingsPage {
AppSettings.getMirrorInbound(ctx).then((v: boolean): void => {
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 +294,51 @@ struct SettingsPage {
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 {
return !b.serverWritable || this.manualKeys.includes(b.calKey);
@@ -446,13 +566,19 @@ struct SettingsPage {
}
this.mirrorEnabled = isOn;
await AppSettings.setMirrorEnabled(this.context, isOn);
// ⭐ 镜像开关变化 → 立即失效提醒侧的"已镜像本"缓存,并重排提醒
// (开启:这些本改由系统日历提醒,App 不再重复;关闭:必须立刻恢复 App 提醒,避免漏提醒)
ReminderService.invalidateMirrorCache();
if (isOn) {
this.getUIContext().getPromptAction().showToast({
message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」'
});
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
return;
}
const n: number = await SystemCalendarMirror.removeAll(this.context);
// 关闭镜像后,这些本的提醒要交回 App 负责
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
this.getUIContext().getPromptAction().showToast({
message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像'
});
@@ -473,6 +599,9 @@ struct SettingsPage {
return;
}
await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys);
// ⭐ 勾选本变化 → 提醒侧"已镜像本"集合变化:失效缓存并重排提醒
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
}
/**
@@ -521,7 +650,7 @@ struct SettingsPage {
/**
* 「清理残留镜像账户(诊断)」按钮:
* ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志;
* ① 读系统日历里**全部**账户(含 id / name / displayName / type)并显示在下方清单里;
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
*/
@@ -540,7 +669,8 @@ struct SettingsPage {
if (r.remaining > 0) {
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
}
msg += '(完整账户清单见同步日志)';
// ⭐ 0.0.8:不再写同步日志 → 提示语改为指向页面下方那份清单(accountDump)
msg += '(完整账户清单见下方)';
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
} catch (err) {
const e = err as BusinessError;
@@ -552,6 +682,81 @@ struct SettingsPage {
}
}
/**
* ⭐「彻底清除全部镜像数据」(卸载前清干净用)。
*
* 为什么需要它:鸿蒙**没有**「应用即将被卸载」的回调,系统日历属于**沙箱外**数据,
* 卸载时不会自动清理。用户卸载后,系统日历里会永久留着一堆
* 「同步日历·xxx」账户(还会继续提醒),只有重装并重新同步才能被当孤儿清掉。
* ⇒ 提供显式入口,让用户在卸载前主动清干净。
*
* ⚠️ 清除范围(务必与文案一致):
* - 只删**系统日历里本应用建立的镜像账户及其日程**
* - **不动** CalDAV 账号配置(账号密码仍保留,重装后可直接用)
* - **不动** 服务器上的任何日程(同步过去的内容一条都不会少)
*/
private confirmWipeAllMirrors(): void {
if (this.context === undefined || this.wipeAllRunning) {
return;
}
this.getUIContext().showAlertDialog({
title: '彻底清除系统日历镜像数据',
message: '将删除本应用写入系统日历的全部镜像日历本及其日程。\n\n'
+ '只影响系统日历:你的 CalDAV 账号与服务器上的日程完全不受影响。\n'
+ '清除后系统日历里不再显示这些日程,也不再提醒;镜像开关会一并关闭。\n\n'
+ '准备卸载本应用时,请先点这里清干净(卸载时系统不会自动清除)。',
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {
}
},
secondaryButton: {
value: '确认清除',
action: (): void => {
this.doWipeAllMirrors();
}
}
});
}
private async doWipeAllMirrors(): Promise<void> {
if (this.context === undefined || this.wipeAllRunning) {
return;
}
this.wipeAllRunning = true;
try {
const n: number = await SystemCalendarMirror.removeAll(this.context);
// ⭐ 必须同时把"镜像开关"关掉并落库:否则下一轮同步会立刻按勾选重新建回来,
// 用户会以为"清了等于没清"。
this.mirrorEnabled = false;
await AppSettings.setMirrorEnabled(this.context, false);
// 镜像本没了 → 提醒要交回 App 负责,否则这些本会既没系统提醒、也没 App 提醒(漏提醒)
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
// ⭐ 清空结果必须说清楚:用户点这个按钮就是为了"确认卸载后不留东西",
// 所以"0 个"也要区分是权限没给(没清成)还是本来就干净(放心)
const granted: boolean = await SystemCalendarMirror.hasPermission();
let msg: string = '';
if (n > 0) {
msg = `已清除 ${n} 个镜像日历本,系统日历中不再有本应用的日程;镜像开关已关闭`;
} else if (!granted) {
msg = '未授予"写入系统日历"权限,无法清除。请先在设置中允许本应用读写日历后重试';
} else {
msg = '系统日历中已没有本应用的镜像数据,无需清除';
}
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 5000 });
} catch (err) {
const e = err as BusinessError;
this.getUIContext().getPromptAction().showToast({
message: `清除失败:${e.message ?? '请确认已授予"写入系统日历"权限'}`
});
} finally {
this.wipeAllRunning = false;
}
}
private async saveInterval(minutes: number): Promise<void> {
if (this.context === undefined) {
return;
@@ -566,23 +771,17 @@ struct SettingsPage {
if (this.context === undefined) {
return;
}
// 后台持续同步依赖常驻通知 → 在用户主动开启时才申请通知权限(不随启动自动弹窗)
if (value) {
await this.ensureNotifyPermission();
}
try {
await BackgroundSyncService.setBackgroundSync(
this.context as common.UIAbilityContext, value);
this.getUIContext().getPromptAction().showToast({
message: value
? '后台持续同步已开启,通知栏将显示常驻通知'
: '后台持续同步已关闭'
message: value ? '后台自动同步已开启' : '后台自动同步已关闭'
});
} catch (err) {
this.backgroundSync = !value; // 失败回滚 UI
const msg: string = (err as BusinessError).message !== ''
? (err as BusinessError).message : '请重试';
this.getUIContext().getPromptAction().showToast({ message: `开启失败:${msg}` });
this.getUIContext().getPromptAction().showToast({ message: `设置失败:${msg}` });
}
}
@@ -900,6 +1099,35 @@ struct SettingsPage {
.fontColor($r('app.color.text_hint'))
.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 }) {
ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
Row({ space: 6 }) {
@@ -942,6 +1170,67 @@ struct SettingsPage {
.width('100%')
.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 ? '正在执行…' : '立即执行镜像')
.fontSize(14)
.width('100%')
@@ -950,6 +1239,7 @@ struct SettingsPage {
this.runMirrorNow();
})
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
@@ -993,6 +1283,82 @@ struct SettingsPage {
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// ⭐ WIPE_CARD_MOVED:数据清除独立成卡(不依赖镜像开关是否开启)
// 鸿蒙没有"应用即将被卸载"的回调,而系统日历属于沙箱外的系统数据,
// 卸载时不会自动清理 ⇒ 必须提供显式入口,让用户卸载前主动清干净。
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('清除系统日历数据')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('卸载本应用前请先清除,否则系统日历里会一直留着这些日程(还会提醒)')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Button(this.wipeAllRunning ? '正在清除…' : '彻底清除系统日历镜像数据')
.fontSize(14)
.fontColor($r('app.color.error'))
.backgroundColor(Color.Transparent)
.border({ width: 1, color: $r('app.color.error') })
.width('100%')
.enabled(!this.wipeAllRunning)
.onClick(() => {
this.confirmWipeAllMirrors();
})
Text('只删系统日历里的镜像本;CalDAV 账号与服务器上的日程一条都不会少。清除后镜像开关会自动关闭。')
.fontSize(11)
.fontColor($r('app.color.text_hint'))
.width('100%')
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.backgroundColor(Color.Transparent)
.width('100%')
.enabled(!this.purgeRunning)
.onClick(() => {
this.runPurgeOrphans();
})
if (this.accountDump !== '') {
Column({ space: 4 }) {
Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):')
.fontSize(11)
.fontColor($r('app.color.text_secondary'))
.width('100%')
Scroll() {
Text(this.accountDump)
.fontSize(10)
.lineHeight(14)
.fontColor($r('app.color.text_primary'))
.copyOption(CopyOptions.LocalDevice)
.width('100%')
}
.height(150)
.width('100%')
.backgroundColor($r('app.color.page_bg'))
.borderRadius(8)
.padding(6)
}
.width('100%')
.padding({ top: 4 })
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 默认视图:打开 App 后默认展示的视图(月/周/列表)
Column({ space: 8 }) {
Row({ space: 10 }) {
@@ -1197,15 +1563,15 @@ struct SettingsPage {
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 后台持续同步(长时任务)
// 后台自动同步
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('后台持续同步')
Text('后台自动同步')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('开启后申请后台长时任务,App 退到后台也按设定间隔继续同步(通知栏会显示常驻通知)')
Text('开启后,退到后台仍会定时同步(系统最短约 2 小时一次);回到前台或网络恢复时会立即补同步一次')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
@@ -1331,6 +1697,36 @@ struct SettingsPage {
this.openDoc(AppSettings.HELP_TITLE, AppSettings.HELP_URL);
})
// FEEDBACK_CARD 问题反馈(FeedLite 平台,?sw=synccalendar 指定本项目站点)
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('问题反馈')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('遇到问题或有建议,点这里告诉我们')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('›')
.fontSize(18)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
.onClick(() => {
this.openDoc(AppSettings.FEEDBACK_TITLE, AppSettings.FEEDBACK_URL);
})
// 隐私政策与用户协议(点击弹层;bindSheet 挂在本卡片上,避免与其它弹层冲突)
Column({ space: 8 }) {
Row({ space: 10 }) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
// entry/src/main/ets/pages/widget/TodoWidget2x2.ets
// 2x2 待办服务卡片:突出未完成待办总数 + 今日到期提示。
// 本卡片**不展示待办列表**,因此没有打勾交互;点击整卡进入 App 的待办页。
let storageTodo2x2 = new LocalStorage();
@Entry(storageTodo2x2)
@Component
struct TodoWidget2x2Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
/** 右上角添加按钮:拉起 App 直接进入新建待办页 */
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
/** 打开 App 并进入待办 Tab */
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
build() {
Column({ space: 6 }) {
// 顶部:日期 + 添加按钮
Row({ space: 6 }) {
Column({ space: 2 }) {
Text(this.dateMd)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
Text(`${this.weekday === '' ? '' : this.weekday + ' '}${this.lunarText}`)
.fontSize(11)
.fontColor('#8A8A8A')
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
// 未完成待办大数字
Row({ space: 8 }) {
Text(`${this.todoCount}`)
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor('#6A4C93')
Column({ space: 2 }) {
Text('待办未完成')
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
// 今日到期优先提示,没有则给操作引导
Text(this.todoTodayCount > 0 ? `今日到期 ${this.todoTodayCount} 项`
: (this.todoCount === 0 ? '点击添加' : '今日无到期'))
.fontSize(10)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => this.openApp())
}
}
@@ -0,0 +1,232 @@
// entry/src/main/ets/pages/widget/TodoWidget2x4.ets
// 2x4 待办服务卡片:左侧 = 未完成总数 + 今日到期(同 2x2);右侧 = 最近 2 条待办。
// 右侧带**圆形勾选框**,点击即完成并推送回服务器。
let storageTodo2x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem2x4 {
id: number = 0;
title: string = '';
dueText: string = '';
/** 副行:截止时间 · 日历本 */
subText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo2x4)
@Component
struct TodoWidget2x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
/** 右侧列表数据(可滚动;数据侧已按截止时间排序并限制在 20 条内) */
private parseTodos(): TodoItem2x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem2x4[];
} catch (err) {
return [];
}
}
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
/** 打开 App 并进入待办 Tab */
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
/**
* 点击圆形勾选框 → 让 FormExtensionAbility 去完成这条待办。
*
* ⭐ 用 message 而不是 call:message 会进入 onFormEvent,能在
* FormExtension 的 10 秒窗口内完成"改库 + PUT 推送 + 刷新卡片",
* 不需要拉起主 App(拉起会闪界面,体验差)。
* ⭐ 带上 localId:卡片只知道自己渲染的是哪一条,改库要靠主键。
*/
private checkTodo(id: number): void {
postCardAction(this, {
action: 'message',
params: { todoCheck: `${id}` }
});
}
/** 右侧单条待办:勾选框 + 标题 + 截止时间 */
/**
* 单条待办:与 4×4 / 6×4 同款结构(圆圈用日历本色 + 标题 + 副行),
* 尺寸再收一档 —— 2×4 只有 2 格高,圈 16 / 标题 12 / 副行 9。
*/
@Builder
todoRow(item: TodoItem2x4) {
Row({ space: 6 }) {
// 勾选圈:颜色用日历本色,点击即完成
Row() {
Text('')
.width(16)
.height(16)
.borderRadius(8)
.border({ width: 2, color: item.color })
}
.padding({ right: 2 })
.onClick(() => {
this.checkTodo(item.id);
})
Column({ space: 1 }) {
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.subText)
.fontSize(9)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 4, right: 8, top: 5, bottom: 5 })
.borderRadius(9)
.backgroundColor('#F5F7FA')
}
/** 右侧空态 */
@Builder
emptyHint() {
Column({ space: 4 }) {
Text(this.todoCount === 0 ? '暂无待办' : '今日无到期')
.fontSize(12)
.fontColor('#8A8A8A')
.maxLines(2)
.textAlign(TextAlign.Center)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
build() {
Row({ space: 10 }) {
// ===== 左半:未完成总数 =====
Column({ space: 6 }) {
Row({ space: 6 }) {
Column({ space: 2 }) {
Text(this.dateMd)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${this.weekday === '' ? '' : this.weekday + ' '}${this.lunarText}`)
.fontSize(10)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
Row({ space: 6 }) {
Text(`${this.todoCount}`)
.fontSize(38)
.fontWeight(FontWeight.Bold)
.fontColor('#6A4C93')
Column({ space: 2 }) {
Text('待办未完成')
.fontSize(11)
.fontColor('#1A1A1A')
.maxLines(1)
Text(this.todoTodayCount > 0 ? `今日到期 ${this.todoTodayCount} 项`
: (this.todoCount === 0 ? '点击添加' : '今日无到期'))
.fontSize(9)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.height('100%')
Divider()
.vertical(true)
.height('92%')
.strokeWidth(0.5)
.color('#E5E5E5')
// ===== 右半:最近 2 条待办(可打勾)=====
Column() {
if (this.parseTodos().length === 0) {
this.emptyHint()
} else {
// ⭐ List 可滚动:2x4 原来只渲染固定 2 条(Column + slice),
// 待办是清单性质、条数常十几条,只看 2 条价值太低。
// List 自带虚拟化与滚动惯性,条目多也不占内存。
List({ space: 3 }) {
ForEach(this.parseTodos(), (item: TodoItem2x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem2x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => this.openApp())
}
}
@@ -0,0 +1,216 @@
// entry/src/main/ets/pages/widget/TodoWidget4x4.ets
// 4x4 待办服务卡片:顶部标题栏(未完成数 / 今日到期)+ 可滚动待办列表。
// 每条待办左侧为圆形勾选框,点击即完成并推送回服务器。
let storageTodo4x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem4x4 {
id: number = 0;
title: string = '';
dueText: string = '';
/** 副行:截止时间 · 日历本 */
subText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo4x4)
@Component
struct TodoWidget4x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
private parseTodos(): TodoItem4x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem4x4[];
} catch (err) {
return [];
}
}
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
/** 点击勾选框 → 交给 FormExtension 完成(带 localId) */
private checkTodo(id: number): void {
postCardAction(this, {
action: 'message',
params: { todoCheck: `${id}` }
});
}
/** 圆形勾选框(空心圆,点击后由数据刷新从列表中消失) */
@Builder
checkbox(id: number) {
// ⚠️ 不用 Circle().stroke() —— 那是 SDK 26 的 API,本工程 compatibleSdkVersion 为
// 6.1.1(24),真机上 stroke 不生效会导致圆圈不可见。
// 改用 border + 50% 圆角,视觉一致且从 API 7 起就支持。
Column()
.width(20)
.height(20)
.borderRadius(10)
.border({ width: 1.5, color: '#B0B0B0' })
.backgroundColor(Color.Transparent)
.onClick(() => this.checkTodo(id))
}
/**
* 单条待办:与 App 内 Index.todoRow 同款结构 ——
* 圆圈(颜色跟随日历本)+ 标题 + 副行「截止时间 · 日历本」
*
* ⚠️ 未完成=空心环、已完成=实心+✓,这套语义与 App 内一致;
* 卡片上只显示未完成,所以这里主要是空心环。
* ⚠️ 4×4 与 6×4 的 todoRow 必须**完全一致**(用户要求),改一处要同步另一处。
*/
@Builder
todoRow(item: TodoItem4x4) {
Row({ space: 8 }) {
// 勾选圈:颜色用日历本色(与 App 内一致),点击即完成
Row() {
Text('')
.width(18)
.height(18)
.borderRadius(9)
.border({ width: 2, color: item.color })
}
.padding({ right: 2 })
.onClick(() => {
this.checkTodo(item.id);
})
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.subText)
.fontSize(10)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 6, right: 8, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F5F7FA')
}
/** 顶部标题栏 */
@Builder
headerBar() {
Row({ space: 8 }) {
Column({ space: 2 }) {
Row({ space: 6 }) {
Text('待办')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(`${this.todoCount}`)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#6A4C93')
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
Text(this.todoTodayCount > 0
? `${this.dateMd} ${this.weekday} · 今日到期 ${this.todoTodayCount} 项`
: `${this.dateMd} ${this.weekday} · 今日无到期`)
.fontSize(10)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
}
/** 空态 */
@Builder
emptyState() {
Column({ space: 8 }) {
Text('✓')
.fontSize(30)
.fontColor('#C7C7CC')
Text(this.todoCount === 0 ? '所有待办都完成了' : '暂无待办')
.fontSize(13)
.fontColor('#8A8A8A')
Text('点右上角 + 新建')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
build() {
Column({ space: 8 }) {
this.headerBar()
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
if (this.parseTodos().length === 0) {
this.emptyState()
} else {
// List 虚拟化:待办多时滑动流畅
List({ space: 4 }) {
ForEach(this.parseTodos(), (item: TodoItem4x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem4x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
}
@@ -0,0 +1,209 @@
// entry/src/main/ets/pages/widget/TodoWidget6x4.ets
// 6x4 待办服务卡片:4x4 的横向扩展。左侧 = 统计概览,右侧 = 可滚动的待办列表(更多条目)。
// 每条待办左侧圆形勾选框,点击即完成并推送回服务器。
let storageTodo6x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem6x4 {
id: number = 0;
title: string = '';
dueText: string = '';
/** 副行:截止时间 · 日历本 */
subText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo6x4)
@Component
struct TodoWidget6x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
private parseTodos(): TodoItem6x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem6x4[];
} catch (err) {
return [];
}
}
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
private checkTodo(id: number): void {
postCardAction(this, {
action: 'message',
params: { todoCheck: `${id}` }
});
}
@Builder
checkbox(id: number) {
// ⚠️ 不用 Circle().stroke() —— 那是 SDK 26 的 API,本工程 compatibleSdkVersion 为
// 6.1.1(24),真机上 stroke 不生效会导致圆圈不可见。
// 改用 border + 50% 圆角,视觉一致且从 API 7 起就支持。
Column()
.width(20)
.height(20)
.borderRadius(10)
.border({ width: 1.5, color: '#B0B0B0' })
.backgroundColor(Color.Transparent)
.width(20)
.height(20)
.onClick(() => this.checkTodo(id))
}
/**
* 单条待办:与 App 内 Index.todoRow 同款结构 ——
* 圆圈(颜色跟随日历本)+ 标题 + 副行「截止时间 · 日历本」
*
* ⚠️ 未完成=空心环、已完成=实心+✓,这套语义与 App 内一致;
* 卡片上只显示未完成,所以这里主要是空心环。
* ⚠️ 4×4 与 6×4 的 todoRow 必须**完全一致**(用户要求),改一处要同步另一处。
*/
@Builder
todoRow(item: TodoItem6x4) {
Row({ space: 8 }) {
// 勾选圈:颜色用日历本色(与 App 内一致),点击即完成
Row() {
Text('')
.width(18)
.height(18)
.borderRadius(9)
.border({ width: 2, color: item.color })
}
.padding({ right: 2 })
.onClick(() => {
this.checkTodo(item.id);
})
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.subText)
.fontSize(10)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 6, right: 8, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F5F7FA')
}
@Builder
headerBar() {
Row({ space: 8 }) {
Row({ space: 6 }) {
Text('待办')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(`${this.todoCount}`)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#6A4C93')
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
Text(this.todoTodayCount > 0
? `${this.dateMd} ${this.weekday} · 今日到期 ${this.todoTodayCount} 项`
: `${this.dateMd} ${this.weekday} · 今日无到期`)
.fontSize(10)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
.layoutWeight(1)
this.addButton()
}
.width('100%')
}
@Builder
emptyState() {
Column({ space: 8 }) {
Text('✓')
.fontSize(30)
.fontColor('#C7C7CC')
Text(this.todoCount === 0 ? '所有待办都完成了' : '暂无待办')
.fontSize(13)
.fontColor('#8A8A8A')
Text('点右上角 + 新建')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
build() {
Column({ space: 8 }) {
this.headerBar()
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
if (this.parseTodos().length === 0) {
this.emptyState()
} else {
List({ space: 4 }) {
ForEach(this.parseTodos(), (item: TodoItem6x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem6x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
}
@@ -11,8 +11,12 @@ struct Widget2x2Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todayCount') todayCount: number = 0;
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
@LocalStorageProp('selectedDate') selectedDate: number = 0;
/** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder
addButton() {
Button() {
@@ -35,12 +39,12 @@ struct Widget2x2Card {
})
}
/** 卡片"打开 App":点击日期区或今日日程数区域触发;添加按钮是 header 行的兄弟节点,其点击不会冒泡到这里 */
/** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
params: { openDate: `${this.selectedDate}` }
});
}
@@ -61,7 +65,6 @@ struct Widget2x2Card {
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.addButton()
}
@@ -92,12 +95,13 @@ struct Widget2x2Card {
.width('100%')
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
.onClick(() => this.openApp())
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日(原来只有日期区/条数区可点)
.onClick(() => this.openApp())
}
}
+18 -14
View File
@@ -23,12 +23,13 @@ struct Widget4x2Card {
@LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('ongoingJson') ongoingJson: string = '[]';
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
@LocalStorageProp('selectedDate') selectedDate: number = 0;
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */
/** 右侧列表数据(可滚动;数据侧已按时间升序并限制在 15 条内) */
private parseOngoing(): CardItem2x4[] {
try {
const all: CardItem2x4[] = JSON.parse(this.ongoingJson) as CardItem2x4[];
return all.slice(0, 2);
return JSON.parse(this.ongoingJson) as CardItem2x4[];
} catch (err) {
return [];
}
@@ -57,12 +58,12 @@ struct Widget4x2Card {
})
}
/** 卡片"打开 App":点击左侧日期区、今日条数区、右侧日程区触发;添加按钮是 header 行兄弟节点,不冒泡 */
/** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
params: { openDate: `${this.selectedDate}` }
});
}
@@ -100,7 +101,7 @@ struct Widget4x2Card {
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.padding({ left: 6, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
@@ -118,7 +119,6 @@ struct Widget4x2Card {
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
}
build() {
@@ -141,7 +141,6 @@ struct Widget4x2Card {
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.addButton()
}
@@ -173,7 +172,6 @@ struct Widget4x2Card {
.width('100%')
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
.onClick(() => this.openApp())
}
.layoutWeight(1)
.height('100%')
@@ -184,20 +182,24 @@ struct Widget4x2Card {
.strokeWidth(0.5)
.color('#E5E5E5')
// ===== 右半:当前时间之后最近的两条日程(不滚动)=====
// ===== 右半:未来日程列表(可滚动,最多 15 条)=====
// ⭐ 原来固定 Column + 只取 2 条,用户想看后天的安排就看不到。
// 改为 List:自带虚拟化与滚动惯性,条目多也不占内存。
Column() {
if (this.parseOngoing().length === 0) {
this.emptyHint()
} else {
Column({ space: 6 }) {
List({ space: 4 }) {
ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => {
ListItem() {
this.ongoingRow(item)
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`)
}
}, (item: CardItem2x4, idx: number) => `o${idx}_${item.time}_${item.title}`)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.layoutWeight(1)
@@ -208,5 +210,7 @@ struct Widget4x2Card {
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点:左右空白处也能进 App 并定位到当前显示日
.onClick(() => this.openApp())
}
}
@@ -387,7 +387,9 @@ struct Widget4x4Card {
return h < 10 ? `0${h}` : `${h}`;
}
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡到其它点击区) */
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder
addButton() {
Button() {
@@ -442,6 +444,7 @@ struct Widget4x4Card {
.borderRadius(11)
.padding(0)
.backgroundColor('#F2F3F5')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
.onClick(() => this.page(act))
}
@@ -462,6 +465,7 @@ struct Widget4x4Card {
.borderRadius(11)
.padding(0)
.backgroundColor('#007DFF')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
.onClick(() => this.page('today'))
}
}
@@ -554,7 +558,6 @@ struct Widget4x4Card {
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} else {
List({ space: 4 }) {
ForEach(this.parseList(), (item: LItem4) => {
@@ -568,7 +571,6 @@ struct Widget4x4Card {
.layoutWeight(1)
.scrollBar(BarState.Auto)
.cachedCount(8)
.onClick(() => this.openApp())
}
}
@@ -589,7 +591,6 @@ struct Widget4x4Card {
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.pageBtn('›', 'next')
this.sideBtn()
}
@@ -630,7 +631,6 @@ struct Widget4x4Card {
.padding({ left: 5, right: 5 })
.borderRadius(5)
.backgroundColor(a.color)
.onClick(() => this.openApp())
}, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`)
if (this.w4AllDayRest() > 0) {
Text(`还有 ${this.w4AllDayRest()} 个全天日程`)
@@ -690,6 +690,9 @@ struct Widget4x4Card {
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
.onClick(() => this.openApp())
}
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@@ -815,7 +818,6 @@ struct Widget4x4Card {
.width('100%')
.height(this.w4BlockVp(b))
.constraintSize({ minHeight: 14 })
.onClick(() => this.openApp())
}, (b: TBlock) => `b_${r.key}_${b.eventKey}`)
}
.layoutWeight(1)
@@ -384,7 +384,9 @@ struct Widget6x4Card {
return h < 10 ? `0${h}:00` : `${h}:00`;
}
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡) */
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder
addButton() {
Button() {
@@ -439,6 +441,7 @@ struct Widget6x4Card {
.borderRadius(11)
.padding(0)
.backgroundColor('#F2F3F5')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
.onClick(() => this.page(act))
}
@@ -457,6 +460,7 @@ struct Widget6x4Card {
.borderRadius(11)
.padding(0)
.backgroundColor('#007DFF')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
.onClick(() => this.page('today'))
}
}
@@ -549,7 +553,6 @@ struct Widget6x4Card {
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} else {
List({ space: 4 }) {
ForEach(this.parseList(), (item: LItem6) => {
@@ -563,7 +566,6 @@ struct Widget6x4Card {
.layoutWeight(1)
.scrollBar(BarState.Auto)
.cachedCount(8)
.onClick(() => this.openApp())
}
}
@@ -584,7 +586,6 @@ struct Widget6x4Card {
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
Text(`${this.dayCount} 条`)
.fontSize(10)
.fontColor('#B0B0B0')
@@ -629,7 +630,6 @@ struct Widget6x4Card {
.padding({ left: 6, right: 6 })
.borderRadius(6)
.backgroundColor(a.color)
.onClick(() => this.openApp())
}, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`)
if (this.w6AllDayRest() > 0) {
Text(`还有 ${this.w6AllDayRest()} 个全天日程`)
@@ -689,6 +689,9 @@ struct Widget6x4Card {
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
.onClick(() => this.openApp())
}
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@@ -814,7 +817,6 @@ struct Widget6x4Card {
.width('100%')
.height(this.w6BlockVp(b))
.constraintSize({ minHeight: 16 })
.onClick(() => this.openApp())
}, (b: TBlock6) => `b_${r.key}_${b.eventKey}`)
}
.layoutWeight(1)
+11 -67
View File
@@ -1,16 +1,13 @@
// entry/src/main/ets/syncwork/SyncWorkAbility.ets
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步
// 注意:必须幂等——同步本身基于 ETag 全量比对,天然幂等;执行完必须 stopWork,否则系统按超时处理
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步。
//
// ⚠️ 约束(勿改):
// - 单次回调**上限 2 分钟**,超时进程被系统终止 → 逻辑必须轻量(本类只调 Runner,不做事后处理)。
// - 周期由 BackgroundSyncService 设为 2 小时(系统活跃分组下限),此处不再重复设置。
// - 必须幂等:同步基于 etag 全量比对 + dirty 标记,天然幂等。
// - 必须主动 stopWork,否则系统按超时处理。
import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
import { SyncEngine } from '../common/SyncEngine';
import { CardDataService } from '../common/CardDataService';
import { ReminderService } from '../common/ReminderService';
import { SystemCalendarImport } from '../common/SystemCalendarImport';
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { MirrorInbound } from '../common/MirrorInbound';
import { AppSettings } from '../common/AppSettings';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { LogUtil } from '../common/LogUtil';
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
@@ -26,62 +23,9 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
private async doSync(workInfo: workScheduler.WorkInfo): Promise<void> {
try {
const context = this.context;
const accounts: DavAccount[] = await AccountStore.loadAll(context);
// 备份模式:先导入系统本地日历(幂等),再随同步推送
try {
await SystemCalendarImport.importIfNeeded(context);
} catch (err) {
// 导入失败不影响正常同步
}
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
try {
await MirrorInbound.reconcile(context);
} catch (err) {
// 对账失败不影响正常同步
}
let ok: number = 0;
let attempted: number = 0;
for (const acc of accounts) {
if (acc.type !== TYPE_CALDAV) {
continue;
}
attempted++;
try {
// 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 5 分钟),不再套外层超时
await SyncEngine.syncAccount(context, acc);
ok++;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`延迟任务同步 id=${acc.id} 失败: ${e.message}`);
}
}
// 全部账号同步成功才结束"一次性全量重拉"状态
if (attempted > 0 && ok === attempted) {
await AppSettings.markFullRefetchDone(context);
}
await SyncEngine.settleLocalEvents(context);
await SyncEngine.pruneOrphanRows(context, accounts);
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失,
// 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。
await AccountStore.saveAll(context, accounts);
// 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点)
try {
if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) {
const mr = await SystemCalendarMirror.syncNow(context);
LogUtil.write(`延迟任务自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
}
} catch (err) {
// 镜像失败不影响同步
}
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
await CardDataService.pushToAllForms(context);
await ReminderService.refreshReminders(context);
LogUtil.write(`延迟任务同步完成:${ok} 个账号`);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`延迟任务同步失败: ${e.message}`);
// 完整的后台同步流程(导入/对账/逐账号同步/镜像/卡片/提醒)统一由 Runner 实现,
// 与"网络恢复补同步"、"回到前台补同步"共用同一套逻辑,避免三处实现漂移。
await BackgroundSyncRunner.runOnce(this.context, 'deferred');
} finally {
// 必须主动结束,否则系统按 120 秒超时处理
workScheduler.stopWork(workInfo);
-26
View File
@@ -12,9 +12,6 @@
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
},
{
"name": "ohos.permission.PUBLISH_AGENT_REMINDER"
},
@@ -47,26 +44,6 @@
],
"when": "inuse"
}
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:perm_location",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:perm_location_approx",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
],
"deliveryWithInstall": true,
@@ -83,9 +60,6 @@
"startWindowBackground": "$color:start_window_background",
"exported": true,
"orientation": "auto_rotation_restricted",
"backgroundModes": [
"dataTransfer"
],
"skills": [
{
"entities": [
@@ -19,6 +19,8 @@
{ "name": "grid_line", "value": "#14000000" },
{ "name": "today_bg", "value": "#14007DFF" },
{ "name": "selected_bg", "value": "#007DFF" },
{ "name": "chip_off_bg", "value": "#0D000000" }
{ "name": "chip_off_bg", "value": "#0D000000" },
{ "name": "event_mark", "value": "#D93025" },
{ "name": "soft_red", "value": "#F2938A" }
]
}
@@ -24,14 +24,6 @@
"name": "perm_read_whole_calendar",
"value": "读取所有日历账户的日程,用于在日历中统一展示"
},
{
"name": "perm_location",
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
},
{
"name": "perm_location_approx",
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
},
{
"name": "card_ability_desc",
"value": "同步日历服务卡片"
@@ -59,6 +51,26 @@
{
"name": "sync_work_desc",
"value": "日历后台兜底同步任务"
},
{
"name": "todo_card_2x2_name",
"value": "待办 2×2"
},
{
"name": "todo_card_2x4_name",
"value": "待办 2×4"
},
{
"name": "todo_card_4x4_name",
"value": "待办 4×4"
},
{
"name": "todo_card_6x4_name",
"value": "待办 6×4"
},
{
"name": "todo_card_desc",
"value": "待办服务卡片:查看未完成待办,可直接点击完成"
}
]
}
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- 日历本图标:线性风格,与 todo_book 同一套笔触(stroke 2, round 端点) -->
<rect x="3" y="5" width="18" height="16" rx="3" fill="none" stroke="#666666" stroke-width="2"/>
<path d="M3 10 H21" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
<!-- 两个挂环 -->
<path d="M8 3 V7" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
<path d="M16 3 V7" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
<!-- 日期格:用两个小方块暗示"日程格" -->
<rect x="7" y="13" width="3.5" height="3.5" rx="1" fill="#666666"/>
<rect x="13.5" y="13" width="3.5" height="3.5" rx="1" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 792 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- 待办本图标:与 ic_event_book 同一套笔触,用"清单+勾"表达待办 -->
<rect x="3" y="4" width="18" height="17" rx="3" fill="none" stroke="#00B34A" stroke-width="2"/>
<!-- 三条清单项 -->
<path d="M7 9 H17" fill="none" stroke="#00B34A" stroke-width="2" stroke-linecap="round"/>
<path d="M7 13.5 H14" fill="none" stroke="#00B34A" stroke-width="2" stroke-linecap="round"/>
<path d="M7 18 H12" fill="none" stroke="#00B34A" stroke-width="2" stroke-linecap="round"/>
<!-- 右下角一个勾,强调"待办"语义 -->
<path d="M15 17 L17 19 L21 14.5" fill="none" stroke="#00B34A" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 779 B

@@ -16,7 +16,9 @@
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["2*2"]
"supportDimensions": [
"2*2"
]
},
{
"name": "WidgetCard2x4",
@@ -34,7 +36,9 @@
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*4",
"supportDimensions": ["2*4"]
"supportDimensions": [
"2*4"
]
},
{
"name": "WidgetCard4x4",
@@ -52,7 +56,9 @@
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "4*4",
"supportDimensions": ["4*4"]
"supportDimensions": [
"4*4"
]
},
{
"name": "WidgetCard6x4",
@@ -70,7 +76,89 @@
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "6*4",
"supportDimensions": ["6*4"]
"supportDimensions": [
"6*4"
]
},
{
"name": "TodoCard2x2",
"displayName": "$string:todo_card_2x2_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget2x2.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": [
"2*2"
]
},
{
"name": "TodoCard2x4",
"displayName": "$string:todo_card_2x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget2x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*4",
"supportDimensions": [
"2*4"
]
},
{
"name": "TodoCard4x4",
"displayName": "$string:todo_card_4x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget4x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "4*4",
"supportDimensions": [
"4*4"
]
},
{
"name": "TodoCard6x4",
"displayName": "$string:todo_card_6x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget6x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "6*4",
"supportDimensions": [
"6*4"
]
}
]
}
@@ -6,6 +6,7 @@
"pages/CalendarListPage",
"pages/EditAccountPage",
"pages/EventEditPage",
"pages/TodoEditPage",
"pages/SettingsPage"
]
}
@@ -19,6 +19,8 @@
{ "name": "grid_line", "value": "#1FFFFFFF" },
{ "name": "today_bg", "value": "#330A84FF" },
{ "name": "selected_bg", "value": "#0A84FF" },
{ "name": "chip_off_bg", "value": "#1FFFFFFF" }
{ "name": "chip_off_bg", "value": "#1FFFFFFF" },
{ "name": "event_mark", "value": "#FF6B60" },
{ "name": "soft_red", "value": "#C97A72" }
]
}
+148
View File
@@ -0,0 +1,148 @@
// 验证 IcsUtil.rewriteTodoFields(改标题/备注/截止时间)
function escape(s) {
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
}
function fmtIcsTime(ms) {
const pad = n => n < 10 ? '0' + n : String(n);
const d = new Date(ms);
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function rewriteTodoFields(ics, uid, title, desc, dueMs) {
const crlf = '\r\n';
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
const upperOf = s => s.toUpperCase();
let targetBegin = -1, targetEnd = -1, curBegin = -1, curHasUid = false;
for (let i = 0; i < lines.length; i++) {
const u = upperOf(lines[i]);
if (u.startsWith('BEGIN:VTODO')) { curBegin = i; curHasUid = false; continue; }
if (u.startsWith('UID:') && curBegin >= 0) {
if (lines[i].substring(4).trim() === uid) curHasUid = true;
continue;
}
if (u.startsWith('END:VTODO')) {
if (curBegin >= 0 && curHasUid && targetBegin < 0) { targetBegin = curBegin; targetEnd = i; }
curBegin = -1; curHasUid = false;
}
}
if (targetBegin < 0) return ics;
const out = [];
let sawSummary = false, sawDesc = false, sawDue = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i], u = upperOf(line);
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
if (u.startsWith('SUMMARY')) { sawSummary = true; if (title !== '') out.push('SUMMARY:' + escape(title)); continue; }
if (u.startsWith('DESCRIPTION')) { sawDesc = true; if (desc !== '') out.push('DESCRIPTION:' + escape(desc)); continue; }
if (u.startsWith('DUE')) { sawDue = true; if (dueMs > 0) out.push('DUE:' + fmtIcsTime(dueMs)); continue; }
out.push(line);
}
const extra = [];
if (!sawSummary && title !== '') extra.push('SUMMARY:' + escape(title));
if (!sawDesc && desc !== '') extra.push('DESCRIPTION:' + escape(desc));
if (!sawDue && dueMs > 0) extra.push('DUE:' + fmtIcsTime(dueMs));
if (extra.length === 0) { const p = out.join(crlf); return p.endsWith(crlf) ? p : p + crlf; }
// 重新数目标块序号
let targetOrdinal = -1;
{
let n = -1, inBlk = false, has = false;
for (let i = 0; i < out.length; i++) {
const u = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) { n++; inBlk = true; has = false; continue; }
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { has = true; continue; }
if (u.startsWith('END:VTODO') && inBlk) { if (has && targetOrdinal < 0) targetOrdinal = n; inBlk = false; }
}
}
const result = [];
let vtodoIdx = -1, inserted = false;
for (let i = 0; i < out.length; i++) {
const u = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
if (u.startsWith('END:VTODO')) {
if (!inserted && vtodoIdx === targetOrdinal) { for (const e of extra) result.push(e); inserted = true; }
vtodoIdx = -1; result.push(out[i]); continue;
}
result.push(out[i]);
}
if (!inserted) {
const t0 = result.join(crlf);
const iv = t0.lastIndexOf('END:VCALENDAR');
if (iv > 0) {
const m = t0.substring(0, iv) + extra.join(crlf) + crlf + t0.substring(iv);
return m.endsWith(crlf) ? m : m + crlf;
}
for (const e of extra) result.push(e);
}
const t2 = result.join(crlf);
return t2.endsWith(crlf) ? t2 : t2 + crlf;
}
let pass = 0, fail = 0;
function ok(name, cond, extra) {
if (cond) { console.log(' ✅', name); pass++; }
else { console.log(' ❌', name, extra || ''); fail++; }
}
function structureIntact(ics) {
const i = ics.indexOf('END:VCALENDAR');
if (i < 0) return false;
return ics.substring(i + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '') === '';
}
function balanced(ics, tag) {
const b = (ics.match(new RegExp('BEGIN:' + tag, 'g')) || []).length;
const e = (ics.match(new RegExp('END:' + tag, 'g')) || []).length;
return b === e;
}
// 原始:含 PRIORITY / CATEGORIES / X-扩展 —— 这些必须原样保留(防丢数据)
const ics1 = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:旧标题\r\nDESCRIPTION:旧备注\r\nDUE:20260105T090000Z\r\nPRIORITY:1\r\nCATEGORIES:家务,生活\r\nX-MY-EXT:hello\r\nSTATUS:NEEDS-ACTION\r\nPERCENT-COMPLETE:0\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const due = new Date(2026, 2, 15, 14, 30, 0).getTime();
const r1 = rewriteTodoFields(ics1, 't1', '新标题', '新备注', due);
ok('标题已更新', r1.includes('SUMMARY:新标题'));
ok('旧标题已消失', !r1.includes('SUMMARY:旧标题'));
ok('备注已更新', r1.includes('DESCRIPTION:新备注'));
ok('截止时间已更新', /DUE:\d{8}T\d{6}/.test(r1) && !r1.includes('DUE:20260105'));
ok('⭐ 保留 PRIORITY', r1.includes('PRIORITY:1'));
ok('⭐ 保留 CATEGORIES', r1.includes('CATEGORIES:家务,生活') || r1.includes('CATEGORIES:家务\\,生活'));
ok('⭐ 保留 X-MY-EXT', r1.includes('X-MY-EXT:hello'));
ok('⭐ 保留 STATUS', r1.includes('STATUS:NEEDS-ACTION'));
ok('⭐ 结构完整', structureIntact(r1));
ok('BEGIN/END VTODO 配对', balanced(r1, 'VTODO'));
// 清除截止时间
const r2 = rewriteTodoFields(ics1, 't1', '旧标题', '旧备注', 0);
ok('截止时间可清除', !/DUE:\d{8}/.test(r2));
ok('清除后仍保留其他字段', r2.includes('PRIORITY:1'));
// 特殊字符转义(分号/逗号/换行)
const r3 = rewriteTodoFields(ics1, 't1', '标题;含逗号,和换行\n第二行', '', due);
ok('⭐ 特殊字符已转义', r3.includes('SUMMARY:标题\\;含逗号\\,和换行\\n第二行'), r3.match(/SUMMARY:[^\r]*/)?.[0]);
// uid 不匹配 → 原文不动
ok('uid 不匹配 → 原文不变', rewriteTodoFields(ics1, 'nope', 'x', 'y', due) === ics1);
// 缺 SUMMARY/DESCRIPTION 的极简待办 → 补齐
const ics4 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t2\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const r4 = rewriteTodoFields(ics4, 't2', '补的标题', '补的备注', due);
ok('缺字段能补齐', r4.includes('SUMMARY:补的标题') && r4.includes('DESCRIPTION:补的备注') && /DUE:/.test(r4));
ok('⭐ 补齐在 VTODO 内', structureIntact(r4) && (() => {
const b = r4.substring(r4.indexOf('BEGIN:VTODO'), r4.indexOf('END:VTODO'));
return b.includes('SUMMARY:补的标题');
})());
// 多 VTODO 只改目标
const ics5 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const r5 = rewriteTodoFields(ics5, 'a2', '改后', '', due);
ok('多 VTODO:a1 未动', r5.split('UID:a1')[1].split('END:VTODO')[0].includes('SUMMARY:第一个'));
ok('多 VTODO:a2 已改', r5.split('UID:a2')[1].split('END:VTODO')[0].includes('SUMMARY:改后'));
ok('⭐ 多 VTODO 结构完整', structureIntact(r5));
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
console.log('\n--- 测试1 输出 ---');
console.log(r1.replace(/\r\n/g, '\n'));
process.exit(fail === 0 ? 0 : 1);
+185
View File
@@ -0,0 +1,185 @@
// 验证待办循环:incForFreq 与 rewriteTodoRecurrence
function fmtIcsTime(ms) {
const pad = n => n < 10 ? '0' + n : String(n);
const d = new Date(ms);
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function freqOfRrule(rrule) {
for (const part of rrule.split(';')) {
const i = part.indexOf('=');
if (i <= 0) continue;
if (part.substring(0, i).trim().toUpperCase() === 'FREQ') {
return part.substring(i + 1).trim().toUpperCase();
}
}
return 'DAILY';
}
function incForFreq(freq, fromMs) {
const d = new Date(fromMs), f = freq.toUpperCase();
if (f === 'DAILY') return fromMs + 86400000;
if (f === 'WEEKLY') return fromMs + 7 * 86400000;
if (f === 'MONTHLY') {
const y = d.getFullYear(), m = d.getMonth() + 1, day = d.getDate();
const last = new Date(y, m + 1, 0).getDate();
return new Date(y, m, Math.min(day, last), d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
}
if (f === 'YEARLY') {
const y = d.getFullYear() + 1, m = d.getMonth(), day = d.getDate();
const last = new Date(y, m + 1, 0).getDate();
return new Date(y, m, Math.min(day, last), d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
}
return fromMs + 86400000;
}
function rewriteTodoRecurrence(ics, uid, rrule, dtstart, dueMs) {
const crlf = '\r\n';
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
const upperOf = s => s.toUpperCase();
const hasRule = rrule.trim() !== '';
let targetBegin = -1, targetEnd = -1, curBegin = -1, curHasUid = false;
for (let i = 0; i < lines.length; i++) {
const u = upperOf(lines[i]);
if (u.startsWith('BEGIN:VTODO')) { curBegin = i; curHasUid = false; continue; }
if (u.startsWith('UID:') && curBegin >= 0) {
if (lines[i].substring(4).trim() === uid) curHasUid = true;
continue;
}
if (u.startsWith('END:VTODO')) {
if (curBegin >= 0 && curHasUid && targetBegin < 0) { targetBegin = curBegin; targetEnd = i; }
curBegin = -1; curHasUid = false;
}
}
if (targetBegin < 0) return ics;
const out = [];
let sawRule = false, sawStart = false, sawDue = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i], u = upperOf(line);
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
if (u.startsWith('RRULE')) { sawRule = true; if (hasRule) out.push('RRULE:' + rrule.trim()); continue; }
if (u.startsWith('DTSTART')) { sawStart = true; if (hasRule && dtstart > 0) out.push('DTSTART:' + fmtIcsTime(dtstart)); continue; }
if (u.startsWith('DUE')) { sawDue = true; if (dueMs > 0) out.push('DUE:' + fmtIcsTime(dueMs)); continue; }
out.push(line);
}
const extra = [];
if (hasRule) {
if (!sawRule) extra.push('RRULE:' + rrule.trim());
if (!sawStart && dtstart > 0) extra.push('DTSTART:' + fmtIcsTime(dtstart));
if (!sawDue && dueMs > 0) extra.push('DUE:' + fmtIcsTime(dueMs));
}
if (extra.length === 0) { const p = out.join(crlf); return p.endsWith(crlf) ? p : p + crlf; }
let targetOrdinal = -1;
{
let n = -1, inBlk = false, has = false;
for (let i = 0; i < out.length; i++) {
const u = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) { n++; inBlk = true; has = false; continue; }
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { has = true; continue; }
if (u.startsWith('END:VTODO') && inBlk) { if (has && targetOrdinal < 0) targetOrdinal = n; inBlk = false; }
}
}
const result = [];
let vtodoIdx = -1, inserted = false;
for (let i = 0; i < out.length; i++) {
const u = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
if (u.startsWith('END:VTODO')) {
if (!inserted && vtodoIdx === targetOrdinal) { for (const e of extra) result.push(e); inserted = true; }
result.push(out[i]); continue;
}
result.push(out[i]);
}
if (!inserted) {
const t0 = result.join(crlf);
const iv = t0.lastIndexOf('END:VCALENDAR');
if (iv > 0) {
const m = t0.substring(0, iv) + extra.join(crlf) + crlf + t0.substring(iv);
return m.endsWith(crlf) ? m : m + crlf;
}
for (const e of extra) result.push(e);
}
const t2 = result.join(crlf);
return t2.endsWith(crlf) ? t2 : t2 + crlf;
}
let pass = 0, fail = 0;
function ok(name, cond, extra) {
if (cond) { console.log(' ✅', name); pass++; }
else { console.log(' ❌', name, extra || ''); fail++; }
}
function structureIntact(ics) {
const i = ics.indexOf('END:VCALENDAR');
if (i < 0) return false;
return ics.substring(i + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '') === '';
}
// ===== 1. incForFreq 各种边界 =====
const d0 = new Date(2026, 0, 31, 9, 0, 0).getTime(); // 2026-01-31 09:00
const m1 = new Date(incForFreq('MONTHLY', d0));
ok('1/31 + 1月 → 2/28(钳制,不跳到3月)',
m1.getMonth() === 1 && m1.getDate() === 28, `${m1.getMonth() + 1}/${m1.getDate()}`);
ok(' 时分保持不变(09:00)', m1.getHours() === 9 && m1.getMinutes() === 0);
const leap = new Date(2024, 1, 29, 9, 0, 0).getTime(); // 2024-02-29
const y1 = new Date(incForFreq('YEARLY', leap));
ok('2/29 + 1年 → 2/28(次年无29日)', y1.getMonth() === 1 && y1.getDate() === 28,
`${y1.getFullYear()}-${y1.getMonth() + 1}-${y1.getDate()}`);
const w = new Date(incForFreq('WEEKLY', d0));
ok('每周 = +7 天', (w.getTime() - d0) === 7 * 86400000);
const dd = new Date(incForFreq('DAILY', d0));
ok('每天 = +1 天', (dd.getTime() - d0) === 86400000);
// ===== 2. freqOfRrule =====
ok('freqOfRrule 取 WEEKLY', freqOfRrule('FREQ=WEEKLY;INTERVAL=2') === 'WEEKLY');
ok('freqOfRrule 无序也能取', freqOfRrule('INTERVAL=2;FREQ=DAILY') === 'DAILY');
ok('freqOfRrule 空规则回退 DAILY', freqOfRrule('') === 'DAILY');
// ===== 3. rewriteTodoRecurrence 写规则 =====
const ics1 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:浇花\r\nDUE:20260110T090000\r\nSTATUS:NEEDS-ACTION\r\nPRIORITY:5\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const due1 = new Date(2026, 0, 10, 9, 0, 0).getTime();
const r1 = rewriteTodoRecurrence(ics1, 't1', 'FREQ=WEEKLY', due1, due1);
ok('写入 RRULE', r1.includes('RRULE:FREQ=WEEKLY'));
ok('⭐ 同时写入 DTSTART(消除客户端歧义)', /DTSTART:\d{8}T\d{6}/.test(r1));
ok('DUE 保留', /DUE:\d{8}T\d{6}/.test(r1));
ok('⭐ 保留 PRIORITY(不丢字段)', r1.includes('PRIORITY:5'));
ok('⭐ 保留 STATUS', r1.includes('STATUS:NEEDS-ACTION'));
ok('⭐ 结构完整', structureIntact(r1));
// ===== 4. 取消循环 =====
const ics2 = r1;
const r2 = rewriteTodoRecurrence(ics2, 't1', '', 0, due1);
ok('取消循环 → RRULE 被删', !r2.includes('RRULE:'));
ok('取消循环 → DTSTART 被删', !r2.includes('DTSTART:'));
ok('取消循环 → DUE 保留', /DUE:\d{8}/.test(r2));
ok('⭐ 取消后结构完整', structureIntact(r2));
// ===== 5. 推进到下一轮(模拟 completeRecurringRound)=====
const base = due1;
const nextBase = incForFreq('WEEKLY', base);
const r3 = rewriteTodoRecurrence(ics1, 't1', 'FREQ=WEEKLY', nextBase, nextBase);
const mDt = r3.match(/DTSTART:(\d{8}T\d{6})/);
ok('⭐ 推进后 DTSTART 变成下一周', mDt !== null && mDt[1].startsWith('20260117'),
mDt ? mDt[1] : 'no match');
ok('推进后 RRULE 不变', r3.includes('RRULE:FREQ=WEEKLY'));
// ===== 6. 多 VTODO 只改目标 =====
const ics6 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nDUE:20260101T090000\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nDUE:20260102T090000\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const r6 = rewriteTodoRecurrence(ics6, 'a2', 'FREQ=DAILY', due1, due1);
ok('多 VTODO:a1 未加 RRULE', !r6.split('UID:a1')[1].split('END:VTODO')[0].includes('RRULE'));
ok('多 VTODO:a2 已加 RRULE', r6.split('UID:a2')[1].split('END:VTODO')[0].includes('RRULE'));
ok('⭐ 多 VTODO 结构完整', structureIntact(r6));
// ===== 7. uid 不匹配 → 原文不动 =====
ok('uid 不匹配 → 原文不变', rewriteTodoRecurrence(ics1, 'nope', 'FREQ=DAILY', due1, due1) === ics1);
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
console.log('\n--- 写入循环后的 ICS ---');
console.log(r1.replace(/\r\n/g, '\n'));
process.exit(fail === 0 ? 0 : 1);
+162
View File
@@ -0,0 +1,162 @@
// 验证 IcsUtil.rewriteTodoCompletion(第二版:按结构定位插入点)
function fmtUtcNow() {
return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
}
function rewrite(ics, uid, done) {
const crlf = '\r\n';
const unfolded = ics.replace(/\r?\n[ \t]/g, '');
const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, ''));
const upperOf = s => s.toUpperCase();
const nowUtc = fmtUtcNow();
let targetBegin = -1, targetEnd = -1, targetOrdinal = -1;
let curBegin = -1, curHasUid = false, blockIdx = -1;
for (let i = 0; i < lines.length; i++) {
const u = upperOf(lines[i]);
if (u.startsWith('BEGIN:VTODO')) { blockIdx++; curBegin = i; curHasUid = false; continue; }
if (u.startsWith('UID:') && curBegin >= 0) {
if (lines[i].substring(4).trim() === uid) curHasUid = true;
continue;
}
if (u.startsWith('END:VTODO')) {
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
targetBegin = curBegin; targetEnd = i; targetOrdinal = blockIdx;
}
curBegin = -1; curHasUid = false;
}
}
if (targetBegin < 0) return ics;
const out = [];
let sawStatus = false, sawPercent = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i], u = upperOf(line);
if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; }
if (u.startsWith('STATUS:')) { sawStatus = true; out.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION'); continue; }
if (u.startsWith('COMPLETED')) { continue; }
if (u.startsWith('PERCENT-COMPLETE')) { sawPercent = true; out.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0'); continue; }
out.push(line);
}
const extra = [];
if (!sawStatus) extra.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
if (done) extra.push('COMPLETED:' + nowUtc);
if (!sawPercent) extra.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
if (extra.length === 0) {
const plain = out.join(crlf);
return plain.endsWith(crlf) ? plain : plain + crlf;
}
const result = [];
let vtodoIdx = -1, inserted = false;
for (let i = 0; i < out.length; i++) {
const u = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; }
if (u.startsWith('END:VTODO')) {
if (!inserted && vtodoIdx === targetOrdinal) {
for (const ex of extra) result.push(ex);
inserted = true;
}
vtodoIdx = -1; result.push(out[i]); continue;
}
result.push(out[i]);
}
if (!inserted) {
const tail0 = result.join(crlf);
const idxVc = tail0.lastIndexOf('END:VCALENDAR');
if (idxVc > 0) {
const merged = tail0.substring(0, idxVc) + extra.join(crlf) + crlf + tail0.substring(idxVc);
return merged.endsWith(crlf) ? merged : merged + crlf;
}
for (const ex of extra) result.push(ex);
}
const text2 = result.join(crlf);
return text2.endsWith(crlf) ? text2 : text2 + crlf;
}
let pass = 0, fail = 0;
function assert(name, cond, detail) {
if (cond) { console.log(' ✅', name); pass++; }
else { console.log(' ❌', name, detail || ''); fail++; }
}
/** 关键结构校验:任何字段都不能跑到 END:VCALENDAR 之后 */
function structureIntact(ics) {
const iEnd = ics.indexOf('END:VCALENDAR');
if (iEnd < 0) return false;
const after = ics.substring(iEnd + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '');
return after === ''; // END:VCALENDAR 之后不应有任何内容
}
function balanced(ics, tag) {
const b = (ics.match(new RegExp('BEGIN:' + tag, 'g')) || []).length;
const e = (ics.match(new RegExp('END:' + tag, 'g')) || []).length;
return b === e;
}
// 1. STATUS 在 UID 之前(触发补字段的路径)
const ics1 = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nDTSTAMP:20260101T000000Z\r\nSTATUS:NEEDS-ACTION\r\nSUMMARY:买菜\r\nUID:todo-abc-123\r\nDUE:20260105T090000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const r1 = rewrite(ics1, 'todo-abc-123', true);
assert('STATUS 在 UID 之前 → COMPLETED', r1.includes('STATUS:COMPLETED'));
assert('旧状态消失', !r1.includes('STATUS:NEEDS-ACTION'));
assert('有 COMPLETED 时间戳', /COMPLETED:\d{8}T\d{6}Z/.test(r1));
assert('补 PERCENT-COMPLETE:100', r1.includes('PERCENT-COMPLETE:100'));
assert('保留 SUMMARY', r1.includes('SUMMARY:买菜'));
assert('保留 DUE', r1.includes('DUE:20260105T090000Z'));
assert('⭐ 结构完整(END:VCALENDAR 后无残留)', structureIntact(r1), JSON.stringify(r1.slice(r1.indexOf('END:VCALENDAR'))));
assert('BEGIN/END VTODO 配对', balanced(r1, 'VTODO'));
// 2. UID 在前 + 已有 STATUS/PERCENT(不触发补字段)
const ics2 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:todo-xyz\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:写报告\r\nPRIORITY:1\r\nSTATUS:NEEDS-ACTION\r\nPERCENT-COMPLETE:0\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const r2 = rewrite(ics2, 'todo-xyz', true);
assert('UID 在前 → COMPLETED', r2.includes('STATUS:COMPLETED'));
assert('PERCENT 改 100', r2.includes('PERCENT-COMPLETE:100'));
assert('保留 SUMMARY/PRIORITY', r2.includes('SUMMARY:写报告') && r2.includes('PRIORITY:1'));
assert('⭐ 结构完整', structureIntact(r2));
assert('COMPLETED 时间戳在 VTODO 内部', (() => {
const body = r2.substring(r2.indexOf('BEGIN:VTODO'), r2.indexOf('END:VTODO'));
return /COMPLETED:\d{8}/.test(body);
})());
// 3. 取消完成
const ics3 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:todo-xyz\r\nSTATUS:COMPLETED\r\nCOMPLETED:20260101T000000Z\r\nPERCENT-COMPLETE:100\r\nSUMMARY:写报告\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const r3 = rewrite(ics3, 'todo-xyz', false);
assert('取消完成 → NEEDS-ACTION', r3.includes('STATUS:NEEDS-ACTION'));
assert('PERCENT 回 0', r3.includes('PERCENT-COMPLETE:0'));
assert('COMPLETED 行被移除', !/COMPLETED:\d{8}/.test(r3));
assert('⭐ 结构完整', structureIntact(r3));
// 4. uid 不匹配 → 原文不动
assert('uid 不匹配 → 原文不变', rewrite(ics1, 'nope', true) === ics1);
// 5. 多 VTODO 只改目标
const ics5 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const r5 = rewrite(ics5, 'a2', true);
assert('多 VTODO:a1 未动', r5.split('UID:a1')[1].split('END:VTODO')[0].includes('STATUS:NEEDS-ACTION'));
assert('多 VTODO:a2 已改', r5.split('UID:a2')[1].split('END:VTODO')[0].includes('STATUS:COMPLETED'));
assert('⭐ 多 VTODO 结构完整', structureIntact(r5));
assert('两个 VTODO 都配对', balanced(r5, 'VTODO'));
// 6. 目标 VTODO 缺 STATUS(走"补字段"最极端路径)
const ics6 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:bare\r\nSUMMARY:没有状态字段\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
const r6 = rewrite(ics6, 'bare', true);
assert('缺 STATUS 时能补上', r6.includes('STATUS:COMPLETED'));
assert('⭐ 补在 VTODO 内而非 VCALENDAR 后', structureIntact(r6) && (() => {
const body = r6.substring(r6.indexOf('BEGIN:VTODO'), r6.indexOf('END:VTODO'));
return body.includes('STATUS:COMPLETED');
})());
// 7. 折行保留
const ics7 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:long\r\nSUMMARY:很长的标题\r\n 需要折行\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n';
assert('折行被还原保留', rewrite(ics7, 'long', true).includes('很长的标题需要折行'));
// 8. 末尾多个空行也不应让结构崩
const ics8 = ics1 + '\r\n\r\n';
assert('⭐ 尾部空行场景结构完整', structureIntact(rewrite(ics8, 'todo-abc-123', true)));
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
console.log('\n--- 测试1 输出 ---');
console.log(r1.replace(/\r\n/g, '\n'));
console.log('--- 测试6 输出(最极端:无 STATUS)---');
console.log(r6.replace(/\r\n/g, '\n'));
process.exit(fail === 0 ? 0 : 1);
+65
View File
@@ -0,0 +1,65 @@
// 验证 preferredDay 机制消除日号漂移
function addMonths(y, m, delta) {
let mm = m + delta, yy = y;
while (mm < 0) { mm += 12; yy--; }
while (mm > 11) { mm -= 12; yy++; }
return [yy, mm];
}
function migrate(preferredDay, y, m, delta) {
const nm = addMonths(y, m, delta);
const lastDay = new Date(nm[0], nm[1] + 1, 0).getDate();
return { ms: new Date(nm[0], nm[1], Math.min(preferredDay, lastDay)).getTime(), y: nm[0], m: nm[1] };
}
function fmt(ms) {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
let pass = 0, fail = 0;
function ok(name, cond, extra) {
if (cond) { console.log(' ✅', name); pass++; }
else { console.log(' ❌', name, extra); fail++; }
}
// 1. 偏好 31 号:1/31 → 2/28 → 3/31(回到 31!)
let pref = 31, y = 2026, m = 0, cur = new Date(2026, 0, 31).getTime(), log = [];
for (let i = 0; i < 4; i++) {
const r = migrate(pref, y, m, 1);
cur = r.ms; y = r.y; m = r.m;
log.push(fmt(cur));
}
console.log(' 偏好31号连续翻月:', log.join(' → '));
ok('1/31→2/28→3/31→4/30(月底语义保留)',
log[0] === '2026-02-28' && log[1] === '2026-03-31' && log[2] === '2026-04-30',
log.join(','));
ok('第4次翻到5月回到31', log[3] === '2026-05-31', log[3]);
// 2. 偏好 15 号:一直稳定
pref = 15; y = 2026; m = 0; log = [];
for (let i = 0; i < 6; i++) {
const r = migrate(pref, y, m, 1);
y = r.y; m = r.m; log.push(fmt(r.ms));
}
ok('偏好15号稳定', log.every(s => s.endsWith('-15')), log.join(','));
// 3. 用户点 2/28 后偏好变 28,翻到 3 月应是 28 号
pref = 28; y = 2026; m = 1;
let r = migrate(pref, y, m, 1);
ok('点过28号后翻月为3/28', fmt(r.ms) === '2026-03-28', fmt(r.ms));
// 4. 闰年偏好 31
pref = 31; y = 2028; m = 0;
r = migrate(pref, y, m, 1);
ok('闰年 1/31 → 2/29', fmt(r.ms) === '2028-02-29', fmt(r.ms));
r = migrate(pref, 2028, 1, 1);
ok('闰年 2/29 → 3/31', fmt(r.ms) === '2028-03-31', fmt(r.ms));
// 5. 往前翻同样保留
pref = 31; y = 2026; m = 2; // 3月
r = migrate(pref, y, m, -1);
ok('3/31 上月 → 2/28', fmt(r.ms) === '2026-02-28', fmt(r.ms));
r = migrate(pref, 2026, 1, -1);
ok('2/28 再上月 → 1/31', fmt(r.ms) === '2026-01-31', fmt(r.ms));
console.log(`\n结果:通过 ${pass},失败 ${fail}`);
process.exit(fail === 0 ? 0 : 1);