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

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

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

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

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

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

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

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

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

二、转场动效(意见 2:使用系统转场,淡入淡出,曲线优先弹簧曲线)
- 全部 7 个 @Entry 页面新增 pageTransition():只做 opacity 淡入淡出,
  不调用 .slide() / .translate(),即不做左右平移 / 上下位移 / 单帧直接切换。
- 转场曲线使用 curves.springMotion() 弹簧曲线。
2026-09-28 14:20:14 +08:00
laoyang 736a0a6587 新增系统日历镜像功能,镜像与备份目标统一改用 bookId;修复重复日程覆盖行、备份目标随勾选漂移等一批问题
- 新增 SystemCalendarMirror / MirrorSnapshot / MirrorInbound:把选定的 CalDAV 日历本镜像写入系统日历(供小艺、桌面日历卡片、手表可见),并做入站对账,区分"自己写入的 / 用户改的 / 用户删的 / 用户新建的",镜像标识一律用稳定的 bookId(accId-href哈希),不再用会随勾选顺序漂移的 calKey
- 后台同步接入镜像:同步前先做入站对账、同步后自动出站镜像,无需用户手动触发
- 系统日历备份目标改用 bookId:当用户取消同步某个日历本导致目标失效时,自动清空目标并把模式回退为"仅显示";开启备份必须先选定目标本,不再自动挑第一个本
- EventDb:覆盖行保留 RECURRENCE-ID,排除主事件对应发生改用 overrideKey(calKey, uid, recurrenceId, startTime),修复重复日程出现"幽灵条目"的问题
- 新增 WRITE_CALENDAR 权限声明及其用途说明;添加日历不再出现"本机",日历本对齐显示;版本升至 0.0.3(300)
2026-09-19 23:07:03 +08:00
57 changed files with 15559 additions and 771 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 200, "versionCode": 10201,
"versionName": "0.0.2", "versionName": "0.2.1",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+455 -24
View File
@@ -1,48 +1,479 @@
# SyncCalendar(同步日历) # SyncCalendar(同步日历)
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。 一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
- 应用市场送审版本:**0.2.0** | 仓库当前:**0.2.0**(versionCode 10200)
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://feedlite.yangyq.net/?sw=synccalendar>
- 开源协议:Apache License 2.0
---
## 📝 最近更新
### v0.2.0(2026-11)
**待办(VTODO)从只读升级为完整双向同步**
- 新增 `TodoService`:新建 / 编辑 / 打勾完成 / 删除待办,全部推送回服务器
- 打过勾会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT
- 修改前先 GET 原始 `.ics`,原地改写而不是整份重建 —— 保留服务器上本应用不认识的属性
- 日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
**同步核心修复(3 项高危)**
- **删除 404**:`remotePath` 改为保存服务器真实资源文件名。此前一直用
`encodeURIComponent(uid) + '.ics'` 去猜,遇到「文件名 ≠ UID」的服务器
(如用 `preg_replace` 改名的家书)会 DELETE 到不存在的路径 → 404 → 服务器上删不掉。
修复覆盖三条取值链路:REPORT 补拉、etag 未变的复用路径、以及 unchanged 分支的存量回填。
- **防复活**:`applyRemote` 增加 `dirtyKeyed` / `dirtyDeletedUids` 白名单。
此前只把 dirty 行排除出索引,远端数据查不到就走"新增"分支又插一行,
导致同一事件出现两条、或"删了又回来"。
- **404/410 物理清库**:删除请求返回 404/410 时视为服务器上已不存在,
继续清理本地行而不是直接 return —— 否则用户会看到"怎么删都删不掉"的日程。
**反馈渠道迁移**
- 从自建 PHP 论坛迁至多软件共用的 **FeedLite**,按软件分栏管理
### v0.1.0(首个送审版本)
- CalDAV 全链路同步、月/周/列表视图、重复日程展开、系统日历镜像、桌面卡片、后台上层同步
---
## ✨ 功能特性 ## ✨ 功能特性
### CalDAV 同步 ### CalDAV 同步
- 支持 CalDAV 协议的完整同步:PROPFIND 发现日历本、REPORT 全量拉取、ETag 增量比对、PUT/DELETE 双向推送
- 一个账号可挂载多个日历本,按需勾选同步范围,支持在线编辑账号、重选日历本、删除账号
- 全链路同步引擎:拉取 → 解析(VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID)→ 本地库落库 → 推送变更
### 日历视图 - 完整协议链路:**PROPFIND** 发现日历本 → **REPORT** 全量拉取 → **ETag** 增量比对 → **PUT / DELETE** 双向推送
- **月 / 周 / 日 / 列表 / 待办** 五种视图自由切换 - 一个账号可挂载多个日历本,按需勾选同步范围;支持在线编辑账号、重选日历本、删除账号
- 月视图支持左右滑动翻月、农历显示(初一显示月名)、点击查看当日日程 - 解析 **VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID**,重复日程的**单次覆盖实例**也能正确还原
- 重复日程(RRULE)按规则完整展开,支持 DAILY / WEEKLY / MONTHLY / YEARLY 及 INTERVAL、BYDAY、UNTIL、EXDATE 等 - 完整 **VTODO 待办**支持:新建 / 编辑 / 打勾完成 / 删除,均可双向推送回服务器
- 列表视图按天分组、今日置顶,配合 List 虚拟化保证流畅滚动 - 待办与日程分列:日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
- VTODO 待办独立展示,未完成 / 已完成分组,勾选框颜色跟随日历本 - 打勾完成会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT 回服务器
- 修改时先 GET 原始 `.ics` 再原地改写,保留服务器上本应用不认识的属性
- 「重建日程数据」可强制整表重拉,用于修复提醒丢失等本地数据问题
### 日历视图与交互
- **月 / 周 / 列表** 三种视图(顶部标签切换,也支持左右滑动手势);默认视图可在设置里指定
- 月视图三页 Swiper 左右翻月、显示**农历**(初一显示月名),点击查看当日日程
- 周视图支持按天左右滑动切换日期,跨月自动重载
- 列表视图按天分组、今日置顶,配合 List 虚拟化保证长列表流畅
- 日程显示方式两种:**竖向时间轴**(按时间刻度看一天的占用与冲突,带当前时间红线)/**列表**(快速扫读,适合长标题)
- 重复日程(RRULE)按规则完整展开:DAILY / WEEKLY / MONTHLY / YEARLY 及 INTERVAL、BYDAY、UNTIL、EXDATE 等
- 可选**混合显示系统日历**,并与 DAV 数据自动去重 - 可选**混合显示系统日历**,并与 DAV 数据自动去重
- 点击日程里的「地点」可**拉起高德 App 路线规划**(系统地理编码 + WGS84→GCJ02 坐标转换,地址文本不落日志)
### 服务卡片 ### 日程编辑与提醒
- 四种规格:**2x2 / 2x4 / 4x4 / 6x4**,时间轴样式设计
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
- **地点搜索补全**:在「地点」栏输入关键词即可列出匹配地点(华为 Map Kit / Site Kit),点一下填入完整地址;也可点「地图」进入系统地图界面搜索、拖动图钉选点。**不申请任何定位权限**,手动输入同样可用
- **一键导航**:日程详情中点击「导航 ›」,用系统地理编码把地点地址换算为坐标后唤起高德地图;未安装则回退网页版。**不申请定位权限**(`getAddressesFromLocationName` 无需该权限),不会读取设备实时位置
- **必填项校验强提醒**:未填标题等必填项时,以 Toast + 输入框标红 + 自动滚回定位三管齐下提示,不会出现"提示在屏幕外看不到"的情况
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
### CalDAV 地址自动发现
- 只填服务器域名也能用:按标准 **`/.well-known/caldav`** 自动发现端点,并在常见路径(Nextcloud、群晖等)中依次尝试
- 发现时只认 `resourcetype` 含 `calendar` 的集合,避免把 DAV 根或分享链接误当成日历
- 失败时给出**人话错误提示**(如"正确形式通常为 `https://域名/remote.php/dav/calendars/用户名/`"),而不是抛原始状态码
### 系统日历镜像(可选增强,默认关闭)
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
- **镜像到系统日历**:把选中的 CalDAV 日历本写入系统日历,这样**小艺语音、桌面日历卡片和手表**也能看到你的日程。每个日历本在系统日历里是**独立账户**,可单独设色与隐藏
- **把系统日历的改动回写**:你在系统日历里新建、修改、删除的日程,同步回指定的 CalDAV 日历本(相当于把本机日程备份到服务器)
- 采用**星型架构**:服务器 ↔ 本地库、本地库 ↔ 系统日历各做一次两方同步,不会互相污染
- 日历本用**稳定的 bookId**(`账号ID-资源路径哈希`)标识,不随勾选顺序变化;取消勾选、目标失效时会自动关闭备份并把相关账户清理干净,绝不把日程写进你没选过的日历本
- 重复日程不写入系统 `recurrenceRule`,而是按时间窗口自行展开(过去 1 年 ~ 未来 2 年),保证 `RECURRENCE-ID` 覆盖与 `EXDATE` 排除精确对应
### 桌面服务卡片
- 四种规格:**2×2 / 2×4 / 4×4 / 6×4**,时间轴样式设计
- 按天分组展示未来日程,组内全天事件置顶;跨天日程智能归入"今天",标注"全天" - 按天分组展示未来日程,组内全天事件置顶;跨天日程智能归入"今天",标注"全天"
- 每条日程左侧竖条颜色与日历本一致,右侧显示所属日历本名 - 每条日程左侧竖条颜色与日历本一致,右侧显示所属日历本名
- 同步完成后自动刷新,formId 持久化保证 App 重启后推送不丢 - 同步完成后自动刷新,`formId` 持久化保证 App 重启后推送不丢
### 日程提醒 ### 后台同步与数据备份
- 自动读取服务器端 VALARM 提醒(如"开始前 10 分钟")
- 基于**系统级代理提醒**(reminderAgentManager)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除 - **前台自动同步**:App 在前台时按设定间隔自动同步(可设 1 / 5 / 15 / 30 分钟)
- **后台自动同步**:开启后由系统定时唤起同步,退到后台也会继续(受系统省电策略限制,**最短约 2 小时一次**)
- **回到前台 / 网络恢复补同步**:切回应用、或从断网恢复到有网时,**立即补同步一次**,保证你打开日历时数据总是最新的
- **应用数据备份/恢复**:接入 Backup 扩展能力,换机时可恢复账号与本地数据
> 说明:HarmonyOS 出于省电考虑,**不允许普通应用在后台常驻并高频轮询**
> (长时任务 `dataTransfer` 超过 10 分钟无进度即被系统取消,延迟任务存在小时级下限)。
> 本应用遵循系统机制,采用「前台按间隔同步 + 后台定时唤起 + 回前台/网络恢复即时补同步」的分层组合。
### 界面与适配
- **深色模式**:全程使用资源引用(`$r('app.color.*')`),深浅两套色板一一对应,跟随系统自动切换
- **沉浸式**:系统状态栏与导航栏全透明,每页(含内置文档页)都做了安全区扩展
- **大屏与横竖屏**:以**窗口短边是否达到 840vp** 作为大屏判据(而非屏幕方向),因此折叠屏展开态、平板可自由横竖屏切换,而在屏幕比例接近 1:1 的设备上旋转时布局保持一致
- **转场动效**:所有页面使用系统转场,淡入淡出 + 弹簧曲线(`curves.springMotion`),不做单帧直切、左右平移或上下位移
### 设置项一览
**系统日历**
- 镜像到系统日历(含「选择要镜像的日历本」)
- 把系统日历的改动回写
- 系统日历中的显示名称(三种命名格式可选)
- 混合显示系统日历(关闭后只显示 DAV 账号的日程)
- 清除系统日历数据(卸载前清理镜像数据,防止残留孤儿账户)
**通用**
- 默认视图(月 / 周 / 列表)
- 日程显示方式(竖向时间轴 / 列表)
- 自动同步间隔(1 / 5 / 15 / 30 分钟)
- 后台自动同步
- 通知权限
**数据**
- 重建日程数据(强制整表重拉)
- 重置账号库
- 日历本只读 / 静音(逐本设置)
**关于**
- 软件特性 · 使用帮助 · 问题反馈 · 隐私政策与用户协议
---
## 🔒 隐私
我们遵循"**数据自持、最小必要**"原则:
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机,且以 **AES-256-GCM** 加密存储,密钥由系统 HUKS/TEE 保管、不可导出)
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
- **不申请定位权限**:本应用从不读取设备实时位置。「一键导航」所需的坐标由系统地理编码服务按**你填写的地址文本**换算,故 `module.json5` 中不含 `LOCATION` / `APPROXIMATELY_LOCATION`
- 地点关键词检索由 HarmonyOS 系统能力**华为地图服务**(Map Kit / Site Kit)提供,仅用于检索,不用于广告、画像或统计分析
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
---
## 🛠 技术要点 ## 🛠 技术要点
- **架构**:SyncEngine 同步引擎 + DavClient(HTTP/WebDAV)+ EventDb(relationalStore 本地库)+ CalendarDataService(多源合并展示) - **架构**:`SyncEngine` 同步引擎 + `DavClient`(HTTP / WebDAV)+ `EventDb`(relationalStore 本地库)+ `CalendarDataService`(多源合并展示)
- **服务卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动卡片渲染 - **镜像**:`SystemCalendarMirror`(出站写入)+ `MirrorInbound`(入站回写)+ `MirrorSnapshot`(变更快照)
- **农历算法**:1901–2099 压缩表推算 - **后台调度**:`BackgroundSyncRunner`(后台同步唯一执行体)+ `BackgroundSyncService`(延迟任务/短时任务)+ `NetworkSyncWatcher`(网络恢复补同步)
- **权限**:INTERNET / READ_CALENDAR / WRITE_CALENDAR / PUBLISH_AGENT_REMINDER - **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
- **农历**:`LunarUtil`,1901–2099 压缩表推算
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER
- ⚠️ **LOCATION 已于 0.1.0 移除**:`geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,地理编码不需要定位权限;应用也从不调用 `getCurrentLocation`。
隐私政策/隐私标签里**不得**再声称读取位置。
## 📦 快速开始 ## 🔐 权限清单与用途
1. 使用 DevEco Studio 打开项目,连接鸿蒙真机或模拟器 | 权限 | 类型 | 用途 | 何时申请 |
2. 编译安装后进入「≡ → 账号管理」,添加你的 CalDAV 服务器地址、用户名和密码 |---|---|---|---|
| `ohos.permission.INTERNET` | 系统授权 | 访问你填写的 CalDAV 服务器 | 安装即授予 |
| `ohos.permission.READ_CALENDAR` | user_grant | 读取本应用写入系统日历的日程 | 打开「混合显示」时 |
| `ohos.permission.READ_WHOLE_CALENDAR` | **受限(需 ACL)** | 读取系统日历中**非本应用创建**的日程,实现混合展示 | 同上 |
| `ohos.permission.WRITE_CALENDAR` | user_grant | 把 CalDAV 日历本镜像到系统日历 | 开启「镜像到系统日历」时 |
| `ohos.permission.PUBLISH_AGENT_REMINDER` | 系统授权 | 发送日程提醒通知 | 保存带提醒的日程时 |
> 所有 user_grant 权限都**只在用户主动操作对应功能时**才弹窗,启动 / 首启 / 同意隐私政策时一律不申请。
> 拒绝后功能默认关闭,可随时在系统设置或本应用设置页重新开启。
---
## 🔑 自行编译并上架:权限要怎么处理
> 如果你 fork 本项目、修改后想上架华为应用市场,**仅修改 `module.json5` 是不够的**。
> 下面这些权限需要额外动作,否则审核会被驳回、或安装后权限静默失效。
### 一、必须自己去申请的(本项目无法代劳)
以下权限绑定在**华为开发者账号**上,与本仓库代码无关。你必须用自己的账号重新走一遍:
| 权限 | 需要做什么 | 审核周期 | 被拒风险 |
|---|---|---|---|
| `READ_WHOLE_CALENDAR` | 在 **AGC → 我的应用 → 权限管理** 提交 **ACL 申请**,说明为什么必须读取非本应用创建的日程 | 数个工作日 ~ 2 周 | 中。用途说明不充分会被驳回 |
| `PUBLISH_AGENT_REMINDER` | 属受限开放权限,需在 AGC 申请 | 数个工作日 | 低 |
**本项目已获批的 ACL 只对 `bundleName = synccalendar.yangyq.net` 生效。**
一旦你改了 `bundleName`(几乎必然要改,否则会与本项目冲突),ACL 立即失效,必须用新包名重新申请。
### 二、ACL 是怎么落地的
ACL **不写在 `module.json5` 里**,而是由 AGC 在审核通过后签发进 **签名 Profile(`.p7b`)**。
想确认手上这份 Profile 有没有带 ACL,可以这样查:
```bash
# 解出 profile 明文(p7b 本质是 PKCS#7 包裹的 JSON)
openssl smime -verify -inform DER -in your.release.p7b -noverify -out profile.json
# 查看 allowed-acls
python3 -c "import json;d=json.load(open('profile.json'));print(d.get('acls'))"
```
正常应输出类似:
```json
{"allowed-acls": ["ohos.permission.READ_WHOLE_CALENDAR"]}
```
**若 `acls` 为空或缺失,说明该 Profile 未获批 ACL** —— 此时即使 `module.json5` 声明了权限,
装到普通用户手机上也会被系统拒绝,功能静默失效。
### 三、不想申请 ACL 怎么办
如果你不想走 ACL 流程(例如只是自用、或嫌审核慢),可以把 `READ_WHOLE_CALENDAR` 从
`module.json5` 中删掉,代价是:
- 「混合显示系统日历」只能看到**本应用自己写入**的日历(`READ_CALENDAR` 足够)
- 看不到华为账号自带日历、其他 App 写入的日历
功能仍然可用,只是视图里少了其他来源的日程。这是一种被社区实践过的合规绕行方案。
### 四、其他与审核相关的注意点
- **应用图标**:AGC 要求分层图标的后景图(`background.png`)为 **100% 不透明、无圆角**(规范 7.1.1.4.3)。
本项目 `AppScope/resources/base/media/background.png` 已满足;若你要换图,务必重新校验 alpha 通道。
另外 `app_icon.png` 目前含透明像素,某些场景(如应用内展示)不受影响,但如要提交商店素材建议先转为不透明。
- **Demo / 免账号体验**:审核员通常没有你的 CalDAV 账号,无法测试核心功能,这是常见的驳回原因。
建议提供一个「一键载入演示数据」入口,或在上架备注中提供测试账号。
- **隐私政策一致性**:隐私政策里声明的权限、数据流向必须与 `module.json5` 完全一致。
本项目已移除 LOCATION,隐私政策中**不得**再出现"读取位置"之类表述。
### 目录结构
```
SyncCalendar/
├─ AppScope/ 应用级配置(包名、版本号)
└─ entry/src/main/
├─ ets/
│ ├─ common/ 核心能力
│ │ ├─ SyncEngine.ets 同步引擎(拉取 → 解析 → 落库 → 推送)
│ │ ├─ DavClient.ets CalDAV / WebDAV 协议客户端
│ │ ├─ EventDb.ets relationalStore 本地库
│ │ ├─ IcsUtil.ets ICS 解析与生成
│ │ ├─ RruleUtil.ets 重复规则展开
│ │ ├─ CalendarDataService.ets 多源合并、系统日历读取
│ │ ├─ SystemCalendarMirror.ets / MirrorInbound.ets / MirrorSnapshot.ets
│ │ ├─ ReminderService.ets 代理提醒
│ │ ├─ CardDataService.ets 卡片数据
│ │ ├─ AppSettings.ets / AccountStore.ets
│ │ └─ LunarUtil.ets / TimelineUtil.ets / DocViewer.ets / TipsPanel.ets …
│ ├─ pages/ Index / Accounts / AddAccount / CalendarList /
│ │ EditAccount / EventEdit / Settings + widget/*
│ ├─ entryability/ 主能力
│ ├─ entrybackupability/ 应用数据备份恢复
│ ├─ entryformability/ 桌面卡片
│ └─ syncwork/ 后台定时同步(WorkScheduler 扩展能力)
└─ resources/ base / dark 两套资源与颜色
```
---
## 📦 构建与运行
### 一、准备工程
1. 安装 **DevEco Studio**(建议 5.0 及以上,需配套 HarmonyOS NEXT SDK)
2. 用 DevEco Studio 打开工程根目录,等待自动同步(`ohpm install`)完成
3. 连接鸿蒙真机(需 HarmonyOS NEXT)或模拟器
### 二、首次构建:先补上签名配置(重要)
> **为什么需要自己配?**
> 鸿蒙应用在编译安装与上架时都必须签名。工程根目录的 **`build-profile.json5`** 里记录了
> 签名文件的**绝对路径与加密口令**,属于机器相关且敏感的凭据,因此该文件**被 `.gitignore` 排除、不在仓库中**。
> 你从仓库克隆下来的代码默认**不含任何签名信息**,直接编译会失败。下面是补全方法。
#### 1. 需要准备哪些文件
签名一共涉及 **4 个文件**,全部放在仓库**之外**的某个目录里(例如 `D:\DevEcoSign\`):
| 文件 | 格式 | 说明 |
|---|---|---|
| 密钥库 | `.p12` | 存放你的私钥与证书链,**最关键、绝不能泄露** |
| 证书 | `.cer` | 与密钥库配套的应用证书 |
| Profile | `.p7b` | 授权档案,决定该包能装到哪些设备 / 能否上架 |
| (口令) | — | 密钥库口令、私钥口令(在生成时自己设定,请务必记牢) |
#### 2. 这些文件怎么申请、怎么得到
有两种途径,按你的目的选一种:
**途径 A:调试用(自用真机安装)—— 全自动,推荐**
DevEco Studio 可以一键生成并自动完成签名配置,无需手工操作:
1. 打开 `File → Project Structure → Project → Signing Configs`
2. 勾选 **`Automatically generate signature`**(自动生成签名)
3. 按提示登录你的**华为开发者账号**(需先在 [AppGallery Connect](https://developer.huawei.com/consumer/cn/) 完成实名认证)
4. 等待自动完成——DevEco 会替你:生成 `.p12` 密钥库与 `.cer` 证书 → 向华为**申请调试 Profile(`.p7b`)**
并自动注册当前设备 → 把 `signingConfigs` 写入 `build-profile.json5`
5. 生成的文件默认落在 `C:\Users\<你>\.ohos\config\` 下
> 这是最省事的方式,**只要一个已实名认证的华为开发者账号**即可,不需要额外审批。
**途径 B:上架用(发布到应用市场)—— 需在 AGC 后台申请**
若要打**上架包**(`.app`),必须使用**发布证书 + 发布 Profile**,需手动申请:
1. 登录 [AppGallery Connect](https://developer.huawei.com/consumer/cn/),进入
`用户与访问 → 证书管理`,创建并下载:
- **发布证书(`.cer`)**——需先在本机用 `keytool` 或 DevEco 生成一个 `.p12` 密钥库,
再上传其生成的 **CSR 文件**申请证书;
- **发布 Profile(`.p7b`)**——在 `证书、APP ID 和 Profile → Profile` 中新建,
类型选 **发布(Release)**,关联上面的证书与你的 App ID。
2. 记住创建密钥库时设定的**密钥库口令**与**私钥口令**。
> 官方文档入口:AppGallery Connect → 帮助中心 → **「配置应用签名」** / 「证书管理」。
#### 3. 在哪个文件里填写
在**仓库根目录**新建 `build-profile.json5`(就是被忽略的那份),填入你的签名信息。
可直接复制仓库中的模板 **`build-profile.example.json5`** 改名为 `build-profile.json5` 后使用,格式如下:
```json5
{
"app": {
"signingConfigs": [
{
// 调试配置:真机安装用这项
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "D:/DevEcoSign/your.p12", // ← 改成你的密钥库绝对路径
"storePassword": "<你的密钥库口令>", // ← 自动生成时是一段密文
"keyAlias": "your_key_alias", // ← 密钥别名(生成时指定)
"keyPassword": "<你的私钥口令>", // ← 同上
"signAlg": "SHA256withECDSA",
"profile": "D:/DevEcoSign/yourDebug.p7b", // ← 调试 Profile
"certpath": "D:/DevEcoSign/yourDebug.cer" // ← 配套证书
}
},
{
// 上架配置:打 .app 包用这项(真机安装勿用)
"name": "release",
"type": "HarmonyOS",
"material": {
"storeFile": "D:/DevEcoSign/your.p12",
"storePassword": "<你的密钥库口令>",
"keyAlias": "your_key_alias",
"keyPassword": "<你的私钥口令>",
"signAlg": "SHA256withECDSA",
"profile": "D:/DevEcoSign/yourRelease.p7b", // ← 发布 Profile
"certpath": "D:/DevEcoSign/yourRelease.cer" // ← 发布证书
}
}
],
"products": [
{
"name": "default",
"signingConfig": "default", // 用哪套签名:debug 用 "default",上架改 "release"
"targetSdkVersion": "26.0.0",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": { "caseSensitiveCheck": true, "useNormalizedOHMUrl": true }
}
}
],
"buildModeSet": [ { "name": "debug" }, { "name": "release" } ]
},
"modules": [
{ "name": "entry", "srcPath": "./entry",
"targets": [ { "name": "default", "applyToProducts": [ "default" ] } ] }
]
}
```
**关键位置说明:**
| 位置 | 作用 |
|---|---|
| `signingConfigs[].material.*` | 四个签名文件的**绝对路径**与两个口令 |
| `products[0].signingConfig` | 选哪套签名:真机安装填 `"default"`,打上架包填 `"release"` |
> ⚠️ **`build-profile.json5` 已在 `.gitignore` 中,请勿提交**。
> 若用「途径 A」自动生成,DevEco 会自动创建并填好该文件,你通常无需手写。
> 注意:`entry/build-profile.json5` 是**模块级**普通构建配置(不含密钥),它在仓库中是**需要保留**的,两者不要混淆。
#### 4. 一个更省事的替代做法
如果不想手写,也可以直接在 DevEco Studio 里操作:
`File → Project Structure → Project → Signing Configs`,勾选 `Automatically generate signature`
并登录华为开发者账号——DevEco 会自动把 `build-profile.json5` 生成好,随后直接编译即可。
### 三、编译运行
1. 点击 DevEco Studio 的 **Run ▶**,或执行:
```bash
hvigorw assembleHap --mode module -p product=default
```
2. 安装后进入「≡ → 账号管理」,添加 CalDAV 服务器地址、用户名和密码
3. 选择要同步的日历本,等待首次同步完成 3. 选择要同步的日历本,等待首次同步完成
4. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格 4. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
### 四、打上架包(App Pack)
应用市场上架需要提交 **`.app`(App Pack)**,而不是 `.hap`(`.hap` 仅用于真机侧载):
```bash
hvigorw assembleApp --mode project -p product=default -p buildMode=release
```
- 产物:`build/outputs/default/SyncCalendar-default-signed.app`
- **前提**:请先把 `products[0].signingConfig` 改为 `"release"`(用发布证书 + 发布 Profile),
否则会因签名不匹配而无法通过审核。
- 校验签名(可选,`-inForm zip` 不可省,否则会误报 `can not find codesign block`):
```bash
java -jar hap-sign-tool.jar verify-app -inForm zip \
-inFile SyncCalendar-default-signed.app \
-outCertChain out.cer -outProfile out.p7b
```
出现 `verify-app success` 即表示签名有效。
---
## 📷 截图
| 月视图 | 周视图 |
|---|---|
| ![月视图](截图/微信图片_20260915083446_376_241.jpg) | ![周视图](截图/微信图片_20260915083448_379_241.jpg) |
| 列表 / 时间轴 | 日程详情 |
|---|---|
| ![列表](截图/微信图片_20260915083449_380_241.jpg) | ![详情](截图/微信图片_20260915083450_381_241.jpg) |
| 设置 | 账号管理 |
|---|---|
| ![设置](截图/微信图片_20260915083450_382_241.jpg) | ![账号](截图/微信图片_20260915083451_383_241.jpg) |
| 桌面卡片 | 深色模式 |
|---|---|
| ![卡片](截图/微信图片_20260915083452_384_241.jpg) | ![深色](截图/微信图片_20260915083452_385_241.jpg) |
---
## 🔗 相关链接
| | |
|---|---|
| 官方网站 | <https://synccalendar.yangyq.net> |
| 使用帮助 | <https://synccalendar.yangyq.net/uh.html> |
| 软件特性 | <https://synccalendar.yangyq.net/sf.html> |
| 隐私政策 | <https://synccalendar.yangyq.net/upa.html> |
| 用户服务协议 | <https://synccalendar.yangyq.net/usa.html> |
| 问题反馈 | <https://feedlite.yangyq.net/?sw=synccalendar> |
网站备案:**鲁ICP备17014997号-6A**
---
## 📄 开源说明 ## 📄 开源说明
本项目仅供学习交流使用。 本项目仅供学习交流使用,采用 **Apache License 2.0** 协议,详见 [LICENSE](LICENSE)。
该项目使用人工智能辅助完成。 该项目使用人工智能辅助完成。
+90
View File
@@ -0,0 +1,90 @@
/**
* build-profile.json5 模板(示例)
*
* 用法:
* 1. 把本文件复制为同目录下的 build-profile.json5
* 2. 按注释把 <尖括号> 内容替换成你自己的签名信息
* 3. 不要提交生成后的 build-profile.json5(它已在 .gitignore 中)
*
* 4 个签名文件(.p12 / .cer / .p7b ×2)需要放在仓库之外的任意目录,
* 这里用 D:/DevEcoSign/ 作为示例路径,请改成你自己的真实路径。
*
* 如何获取这些文件、在哪里填写,详见 README.md 的「📦 构建与运行 → 二、首次构建:先补上签名配置」。
*
* 最省事的替代方案:用 DevEco Studio 打开工程 →
* File → Project Structure → Project → Signing Configs →
* 勾选 "Automatically generate signature" 并登录华为开发者账号,
* IDE 会自动生成并填好本文件,无需手工编辑。
*/
{
"app": {
"signingConfigs": [
{
// ── 调试配置:真机安装 / 本地运行用这一项 ──
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "D:/DevEcoSign/<你的密钥库>.p12",
"storePassword": "<密钥库口令>",
"keyAlias": "<密钥别名>",
"keyPassword": "<私钥口令>",
"signAlg": "SHA256withECDSA",
"profile": "D:/DevEcoSign/<你的调试Profile>.p7b",
"certpath": "D:/DevEcoSign/<你的调试证书>.cer"
}
},
{
// ── 上架配置:打 .app 上架包用这一项(真机安装请勿使用)──
"name": "release",
"type": "HarmonyOS",
"material": {
"storeFile": "D:/DevEcoSign/<你的密钥库>.p12",
"storePassword": "<密钥库口令>",
"keyAlias": "<密钥别名>",
"keyPassword": "<私钥口令>",
"signAlg": "SHA256withECDSA",
"profile": "D:/DevEcoSign/<你的发布Profile>.p7b",
"certpath": "D:/DevEcoSign/<你的发布证书>.cer"
}
}
],
"products": [
{
"name": "default",
// 用哪套签名:真机安装填 "default";打上架包前改成 "release"
"signingConfig": "default",
"targetSdkVersion": "26.0.0",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
}
}
}
],
"buildModeSet": [
{
"name": "debug"
},
{
"name": "release"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
}
]
}
@@ -57,6 +57,14 @@ export class DavAccount {
calendarColors: string[] = []; calendarColors: string[] = [];
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */ /** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
calendarWritable: string[] = []; calendarWritable: string[] = [];
/**
* ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应:
* 'event' 纯日历本(只有 VEVENT)
* 'todo' 纯待办本(只有 VTODO)
* 'both' 混合本
* 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容)
*/
calendarComps: string[] = [];
itemCount: number = 0; itemCount: number = 0;
lastSyncTime: string = ''; lastSyncTime: string = '';
} }
@@ -69,6 +77,14 @@ export class CalSource {
source: string = 'dav'; // 'dav' | 'system' | 'local' source: string = 'dav'; // 'dav' | 'system' | 'local'
visible: boolean = true; visible: boolean = true;
writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑 writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑
/**
* 本支持哪些组件(来自服务器 COMP_SET 声明):
* 'event' 纯日历本(新建日程可选,新建待办不可选)
* 'todo' 纯待办本(反过来)
* 'both' 两者都可
* 'unknown' 服务器未声明 → 两边都按可用处理(旧数据兼容)
*/
comps: string = 'unknown';
} }
/** 与 UI 无关的调色板 */ /** 与 UI 无关的调色板 */
@@ -474,6 +490,9 @@ export class AccountStore {
if (parts.length >= 12) { if (parts.length >= 12) {
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';'); acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
} }
if (parts.length >= 13) {
acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';');
}
return acc; return acc;
} }
@@ -565,6 +584,7 @@ export class AccountStore {
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : []; a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : []; a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : []; a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : [];
a.calendarComps = o['calendarComps'] !== undefined ? o['calendarComps'] as string[] : [];
a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0; a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : ''; a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
if (a.id === '') { if (a.id === '') {
@@ -699,4 +719,45 @@ export class AccountStore {
return false; return false;
} }
} }
/**
* ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。
*
* 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`,
* 于是调用方无法从返回值区分两种情况:
* ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**);
* ② 密文存在但解不开,或读取过程抛错(临时故障)。
*
* 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理,
* 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。
*
* 判定口径(三者同时成立才算"确认空白"):
* - 偏好里**没有**账号密文(`data_b64` 为空);
* - 旧版明文存储也没有账号;
* - 读取过程没有抛异常。
*
* @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过
*/
static async isEmptyVaultConfirmed(context: common.Context): Promise<boolean> {
try {
const store: preferences.Preferences = await Vault.prefs(context);
const data: string = await store.get(VAULT_DATA, '') as string;
if (data !== '') {
// 有密文:只有能解出"0 个账号"才算确认空白;解不开一律视为拿不准
const plain: string | null = await Vault.decrypt(context, data);
if (plain === null) {
return false;
}
const arr: Array<Record<string, Object>> = JSON.parse(plain) as Array<Record<string, Object>>;
return arr.length === 0;
}
// 无密文:再看旧版明文存储是否还有残留账号
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
return legacy === null || legacy.length === 0;
} catch (err) {
const e = err as BusinessError;
console.error(`[AccountStore] isEmptyVaultConfirmed 判定失败(按"拿不准"处理): ${e.message}`);
return false;
}
}
} }
+289 -11
View File
@@ -11,13 +11,32 @@ export class AppSettings {
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes'; private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync'; private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync';
private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup' private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup'
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本 // ⚠️ 备份目标**必须**用稳定标识 bookId 存(由服务器 href 派生,见 SystemCalendarMirror.listBooks)。
// 老键存的是 calKey(`accId_序号`),序号会随"取消勾选某个日历本/服务器新增日历本"整体漂移
// → 目标本会**悄悄变成另一个本**,把系统日历导进用户没选过的本(用户实测发现)。
// KEY_BACKUP_KEY 仅保留用于老值一次性迁移,读到后立即换存 KEY_BACKUP_BOOK_ID 并清空。
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 【已废弃】备份目标 calKey(仅迁移用)
private static readonly KEY_BACKUP_BOOK_ID: string = 'sys_backup_book_id'; // ⭐ 备份目标稳定标识 bookId
private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey
private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳 private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳
// ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(用于节流)。
// 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1),
// 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。
private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at';
/** ⭐ 诊断开关:卡片驱动同步的时间预算(毫秒)。0/未设置 = 用内置默认(见 BackgroundSyncRunner)。
* 留给真机排查用 —— 不用重编译就能做"9 秒 vs 长窗口"对照实验。 */
private static readonly KEY_CARD_BUDGET_MS: string = 'card_budget_ms';
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list' private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list'
private static readonly KEY_MIRROR_ENABLED: string = 'mirror_enabled'; // 是否把选中 CalDAV 日历本镜像到系统日历(默认关)
private static readonly KEY_MIRROR_KEYS: string = 'mirror_cal_keys'; // 要镜像的 DAV 日历本 calKey 列表
private static readonly KEY_MIRROR_BOOK_IDS: string = 'mirror_book_ids'; // ⭐ 要镜像的日历本**稳定标识** bookId(由服务器 href 派生,不随序号变)
private static readonly KEY_MIRROR_INBOUND: string = 'mirror_inbound'; // 是否把系统日历的改动回写 CalDAV(默认关)
// ⭐ 系统日历账户的**显示名格式**(1/2/3,默认 3=`<账号>·<本名>`)。
// 因平台无"改账户名"API,切换格式需删账户重建(用户显式点「应用」才执行)。
private static readonly KEY_MIRROR_DISPLAY_FORMAT: string = 'mirror_display_format';
private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议 private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。 // 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**—— // 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**——
@@ -26,8 +45,13 @@ export class AppSettings {
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。 // 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致: // ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / LOCATION / APPROXIMATELY_LOCATION(均前台), // 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(均前台)。
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)与"添加/修改/删除日历活动"(WRITE_CALENDAR)。 // 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)。
// ⚠️ 0.1.0 起已**移除 LOCATION / APPROXIMATELY_LOCATION**:实测本 SDK(API 26)的
// `geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,
// 地理编码不需要定位权限;且应用从不调用 getCurrentLocation,本就不读实时位置。
// ⇒ 隐私政策/隐私标签里都**不得**再声称"读取设备精确位置或大致位置"。
// ⇒ 若将来真要用实时定位,必须同时改 module.json5、本注释、upa.html 与 AGC 隐私标签。
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。 // 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
/** 隐私政策网址 */ /** 隐私政策网址 */
static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html'; static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html';
@@ -37,11 +61,20 @@ export class AppSettings {
static readonly FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html'; static readonly FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html';
/** 使用帮助网址 */ /** 使用帮助网址 */
static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.html'; static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.html';
/**
* ⭐ 问题反馈网址。
*
* 换到 FeedLite(多站点共用一套反馈平台),`?sw=` 指定本项目站点标识。
* 旧地址 synccalendar.yangyq.net/feedback/ 是自建 PHP 论坛,
* 里面还有 3 条历史反馈(含一条完整 PR 贡献),服务暂不删除。
*/
static readonly FEEDBACK_URL: string = 'https://feedlite.yangyq.net/?sw=synccalendar';
/** 文档标题(内置浏览器标题栏用) */ /** 文档标题(内置浏览器标题栏用) */
static readonly PRIVACY_TITLE: string = '隐私政策'; static readonly PRIVACY_TITLE: string = '隐私政策';
static readonly AGREEMENT_TITLE: string = '用户服务协议'; static readonly AGREEMENT_TITLE: string = '用户服务协议';
static readonly FEATURES_TITLE: string = '软件特性'; static readonly FEATURES_TITLE: string = '软件特性';
static readonly HELP_TITLE: string = '使用帮助'; static readonly HELP_TITLE: string = '使用帮助';
static readonly FEEDBACK_TITLE: string = '问题反馈';
/** /**
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。 * 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
@@ -149,6 +182,202 @@ export class AppSettings {
} }
} }
/**
* 是否把选中的 CalDAV 日历本镜像到系统日历(默认关)。
* ⚠️ 该开关必须与 WRITE_CALENDAR 授权状态一致:未授权即为关(权限按需申请约束)。
*/
static async getMirrorEnabled(context: common.Context): Promise<boolean> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_MIRROR_ENABLED, false) as boolean;
} catch (err) {
return false;
}
}
static async setMirrorEnabled(context: common.Context, value: boolean): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_MIRROR_ENABLED, value);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存镜像开关失败: ${e.message}`);
}
}
/**
* ⚠️ **已废弃**:早期用 calKey 存"要镜像哪些本",但 calKey 是 `<账号ID>_<序号>`,
* 日历本重排后序号会变 → 选中的本串成另一个本、镜像账户名也变 → 系统日历出现重复账户与串本。
* 新代码一律用 `getMirrorBookIds()`。这里只为读取历史值做迁移。
*/
static async getMirrorKeys(context: common.Context): Promise<string[]> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
const raw: string = await store.get(AppSettings.KEY_MIRROR_KEYS, '') as string;
if (raw === '') {
return [];
}
const arr: string[] = JSON.parse(raw) as string[];
if (!Array.isArray(arr)) {
return [];
}
const out: string[] = [];
for (let i: number = 0; i < arr.length; i++) {
const v: string = arr[i];
if (typeof v === 'string' && v !== '') {
out.push(v);
}
}
return out;
} catch (err) {
return [];
}
}
static async setMirrorKeys(context: common.Context, keys: string[]): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_MIRROR_KEYS, JSON.stringify(keys));
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存镜像日历本失败: ${e.message}`);
}
}
/**
* ⭐ 要镜像到系统日历的日历本**稳定标识** bookId 列表,空 = 未选择。
* bookId = `<账号ID>-<href哈希>`,由服务器分配的 href 派生 —— 日历本增删改序都不会变。
*/
static async getMirrorBookIds(context: common.Context): Promise<string[]> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
const raw: string = await store.get(AppSettings.KEY_MIRROR_BOOK_IDS, '') as string;
if (raw === '') {
return [];
}
const arr: string[] = JSON.parse(raw) as string[];
if (!Array.isArray(arr)) {
return [];
}
const out: string[] = [];
for (let i: number = 0; i < arr.length; i++) {
const v: string = arr[i];
if (typeof v === 'string' && v !== '') {
out.push(v);
}
}
return out;
} catch (err) {
return [];
}
}
static async setMirrorBookIds(context: common.Context, ids: string[]): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_MIRROR_BOOK_IDS, JSON.stringify(ids));
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存镜像日历本(bookId)失败: ${e.message}`);
}
}
/**
* 是否把"用户在系统日历里的改动"回写到 CalDAV(**默认关**)。
* ⚠️ 默认关是有意的:这个功能会写用户的服务器数据,首次上线必须先单向观察一轮再开。
*/
static async getMirrorInbound(context: common.Context): Promise<boolean> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_MIRROR_INBOUND, false) as boolean;
} catch (err) {
return false;
}
}
static async setMirrorInbound(context: common.Context, value: boolean): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_MIRROR_INBOUND, value);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存回写开关失败: ${e.message}`);
}
}
/**
* ⭐ 系统日历账户显示名格式:1=`同步日历·本名`,2=`同步日历·账号·本名`,3=`账号·本名`(默认)。
* 只接受 1/2/3,其它值一律回落到默认 3(防止脏数据导致拼出意外名字)。
*/
static async getMirrorDisplayFormat(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
const v = await store.get(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, 3) as number;
return (v === 1 || v === 2 || v === 3) ? v : 3;
} catch (err) {
return 3;
}
}
static async setMirrorDisplayFormat(context: common.Context, value: number): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, value);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存显示名格式失败: ${e.message}`);
}
}
/** ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(0 = 从未执行) */
static async getCardSyncAt(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_CARD_SYNC_AT, 0) as number;
} catch (err) {
return 0;
}
}
static async setCardSyncAt(context: common.Context, ts: number): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_CARD_SYNC_AT, ts);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存卡片同步时间失败: ${e.message}`);
}
}
/** ⭐ 诊断用:卡片驱动同步的时间预算(毫秒)。0 = 未设置(用内置默认值)。 */
static async getCardBudgetMs(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_CARD_BUDGET_MS, 0) as number;
} catch (err) {
return 0;
}
}
/** /**
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
@@ -286,9 +515,14 @@ export class AppSettings {
if (muted2.length !== muted.length) { if (muted2.length !== muted.length) {
await AppSettings.setMutedReminderKeys(context, muted2); await AppSettings.setMutedReminderKeys(context, muted2);
} }
const backupKey: string = await AppSettings.getBackupCalKey(context); // 备份目标指向该账号名下某个本 → 目标必失效:清空目标**并关闭备份功能**
if (backupKey.startsWith(`${accId}_`)) { // (只清空目标会让"备份到 CalDAV"开关停在开着却什么都不做的状态,状态与实际不符)
await AppSettings.setBackupCalKey(context, ''); const bookId: string = await AppSettings.getBackupBookId(context);
const legacyKey: string = await AppSettings.getBackupCalKey(context);
if (bookId.startsWith(`${accId}-`) || legacyKey.startsWith(`${accId}_`)) {
await AppSettings.clearBackupTarget(context);
await AppSettings.setSysCalMode(context, 'display');
console.log(`删除账号 ${accId}:其名下的系统日历备份目标已失效,备份功能自动关闭`);
} }
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
@@ -319,7 +553,12 @@ export class AppSettings {
} }
} }
/** 备份目标日历本 calKey(accId_序号),空 = 未选择 */ /**
* 【已废弃,仅供老值迁移】备份目标 calKey(`accId_序号`)。
* ⚠️ 不要再用它做判断!序号会漂移(见 KEY_BACKUP_KEY 处注释)。
* 新代码一律用 `SystemCalendarMirror.backupTargetRef()` / `checkBackupTarget()`,
* 它内部会把这个老值翻译成 bookId 并清空本键。
*/
static async getBackupCalKey(context: common.Context): Promise<string> { static async getBackupCalKey(context: common.Context): Promise<string> {
try { try {
const store: preferences.Preferences = const store: preferences.Preferences =
@@ -330,18 +569,49 @@ export class AppSettings {
} }
} }
/** 【已废弃】写老键。新代码请用 setBackupBookId()。 */
static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> { static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> {
try { try {
const store: preferences.Preferences = const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE); await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_BACKUP_KEY, calKey); await store.put(AppSettings.KEY_BACKUP_KEY, calKey);
await store.flush(); await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存备份目标(老键)失败: ${e.message}`);
}
}
/** ⭐ 备份目标日历本的稳定标识 bookId(`<accId>-<href哈希>`),空 = 未选择 */
static async getBackupBookId(context: common.Context): Promise<string> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_BACKUP_BOOK_ID, '') as string;
} catch (err) {
return '';
}
}
/** ⭐ 保存备份目标(bookId)。传空字符串 = 取消选择。 */
static async setBackupBookId(context: common.Context, bookId: string): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_BACKUP_BOOK_ID, bookId);
await store.flush();
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
console.error(`保存备份目标失败: ${e.message}`); console.error(`保存备份目标失败: ${e.message}`);
} }
} }
/** 一次清空备份目标(新键 + 老键一起清,避免迁移残留又复活) */
static async clearBackupTarget(context: common.Context): Promise<void> {
await AppSettings.setBackupBookId(context, '');
await AppSettings.setBackupCalKey(context, '');
}
/** 是否开启后台持续同步(长时任务,默认关) */ /** 是否开启后台持续同步(长时任务,默认关) */
static async getBackgroundSync(context: common.Context): Promise<boolean> { static async getBackgroundSync(context: common.Context): Promise<boolean> {
try { try {
@@ -392,15 +662,23 @@ export class AppSettings {
} }
} }
/** 自动同步间隔(分钟),默认 1 分钟 */ /**
* 自动同步间隔(分钟),默认 15 分钟。
*
* ⭐ 0.0.8:默认值由 1 分钟改为 15 分钟。原因(真机日志 2026-10-16 17:16–17:20 实测):
* 1 分钟间隔会让「App 打开期间」几乎不间断地跑"12 个日历本全量比对 + 出站镜像 + 提醒刷新",
* 5 分钟内连跑 5 轮后触发系统提示「短时任务超时(配额用尽),后台同步将被挂起」——
* 反而把真正的后台同步通道一起拖垮。改为 15 分钟后,前台同步仍足够及时,且不再烧配额。
* 用户仍可在设置页自行改成 1 / 5 / 15 / 30 分钟 或 1 小时。
*/
static async getSyncIntervalMinutes(context: common.Context): Promise<number> { static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
try { try {
const store: preferences.Preferences = const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE); await preferences.getPreferences(context, AppSettings.STORE);
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number; const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 15) as number;
return v > 0 ? v : 1; return v > 0 ? v : 15;
} catch (err) { } catch (err) {
return 1; return 15;
} }
} }
@@ -0,0 +1,260 @@
// entry/src/main/ets/common/BackgroundSyncRunner.ets
// ⭐ 后台同步的**唯一**执行体:延迟任务 / 短时任务 / 网络恢复 / 回到前台 都调它。
//
// 设计要点(2026-10-10 定时同步方案调研结论):
// - 不再依赖"进程常驻"(系统不允许,dataTransfer 长时任务会被强制取消)
// - 而是让"最合适的机制在最合适的时机把数据对齐"
// - 本类只负责"跑一次完整同步",幂等(基于 etag 全量比对 + dirty 标记)
//
// ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。
import { common } from '@kit.AbilityKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore';
import { SyncEngine } from './SyncEngine';
import { CardDataService } from './CardDataService';
import { ReminderService } from './ReminderService';
import { SystemCalendarImport } from './SystemCalendarImport';
import { SystemCalendarMirror } from './SystemCalendarMirror';
import { MirrorInbound } from './MirrorInbound';
import { AppSettings } from './AppSettings';
import { LogUtil } from './LogUtil';
/** 一次后台同步的结果(供日志/埋点,不做 UI) */
export class BackgroundSyncResult {
ok: number = 0;
attempted: number = 0;
failed: number = 0;
}
export class BackgroundSyncRunner {
/**
* ⭐ 卡片的系统定时刷新间隔(与 form_config.json 的 updateDuration=1 保持一致,单位 30 分钟)。
* ⚠️ 两处必须同步修改:改这里也要改 form_config.json,否则节流窗口对不上。
*/
static readonly CARD_TICK_MS: number = 30 * 60 * 1000;
/** 距上次同步不足该时长则跳过(略小于卡片周期,避免系统刷新漂移导致被误跳过) */
private static readonly CARD_MIN_GAP_MS: number = 25 * 60 * 1000;
/**
* 卡片进程的软超时(**未申请到短时任务时**):官方规定 FormExtensionAbility
* 生命周期回调后进程**仅存续 10 秒**,超过就被回收(异步任务被强制中止)。
* 这里主动在 9 秒收手,让"这一次同步"**干净地放弃**而不是被硬杀在半途。
*/
private static readonly CARD_SOFT_TIMEOUT_MS: number = 9 * 1000;
/**
* 申请到短时任务后的软超时:短时任务单次最长 3 分钟(低电量 1 分钟),
* 但实际时长以 `actualDelayTime` 为准 → 留 15 秒余量提前收手,保证能干净地
* `cancelSuspendDelay`(否则超时不取消会被系统强制终止进程、并浪费当日配额)。
*/
private static readonly CARD_EXT_SOFT_RESERVE_MS: number = 15 * 1000;
/**
* 卡片驱动同步可用的**总时长上限**:即使申请到短时任务也不超过这个值。
* 理由:短时任务**单日总配额仅 10 分钟**,30 分钟一次的话每次平均只能分到约 20 秒,
* 全用光会让"退后台那一次同步"(EntryAbility.onBackground 申请)无配额可用。
*/
private static readonly CARD_MAX_TOTAL_MS: number = 60 * 1000;
/** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */
private static cardTransientId: number = 0;
/** 当前进程 pid(取不到返回 -1)。用来判断卡片回调是否与主 App 同进程。 */
private static pidOf(): number {
try {
return process.pid;
} catch (err) {
return -1;
}
}
/**
* ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。
*
* 为什么用它:系统给普通应用的后台调度很受限 ——
* `workScheduler` 的周期被"应用活跃分组"压制到 **≥2 小时**;
* 而服务卡片配置了 `updateDuration: 1`(**单位 30 分钟**),系统会**每 30 分钟**
* 触发一次 `onUpdateForm()`,且不受活跃分组压制(每天上限 50 次)。
* 借这个时机跑一次完整同步,即可得到"约 30 分钟一次"的定期后台同步。
*
* ⚠️ 节流:系统刷新本身可能有漂移、也可能因故重入 → 用时间戳挡一道,
* 保证两次真实同步之间至少间隔 `CARD_MIN_GAP_MS`。
* ⚠️ 本方法**永不抛错**(后台路径没有 UI 承担异常)。
*/
static async runOnceThrottledByCard(context: common.Context): Promise<void> {
try {
const last: number = await AppSettings.getCardSyncAt(context);
const now: number = Date.now();
if (last > 0 && now - last < BackgroundSyncRunner.CARD_MIN_GAP_MS) {
const mins: number = Math.round((now - last) / 60000);
LogUtil.write(`卡片驱动同步:距上次仅 ${mins} 分钟,跳过(节流 ${BackgroundSyncRunner.CARD_MIN_GAP_MS / 60000} 分钟)`);
return;
}
// 先写时间戳:避免同步执行期间卡片再次刷新导致并发重入
await AppSettings.setCardSyncAt(context, now);
// ⭐ 尝试申请**短时任务**,把 10 秒窗口延长到最多 3 分钟。
// ⚠️ 官方规定 requestSuspendDelay 应在"应用处于前台"或"onBackground 回调"里调用;
// 卡片进程(FormExtensionAbility)是否符合**未知** → 故此处**容错尝试**:
// 成功则按实际时长延长,失败(抛错/时长为 0)则退回 9 秒软超时。
// ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟),
// 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。
const windowMs: number = await BackgroundSyncRunner.acquireCardWindow();
let budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS);
// ⭐ 诊断开关(preferences:card_budget_ms):>0 时**覆盖**上面算出的可用窗口。
// 用途:在真机上回答"卡片进程究竟能活多久""一次完整同步要多久",
// 从而决定卡片这条路该做多少事 —— 不必为此重新编译。
const overrideMs: number = await AppSettings.getCardBudgetMs(context);
if (overrideMs > 0) {
budget = overrideMs;
LogUtil.write(`卡片驱动同步:预算被诊断开关覆盖为 ${Math.round(budget / 1000)} 秒`);
}
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,pid=${BackgroundSyncRunner.pidOf()},可用窗口 ${Math.round(budget / 1000)} 秒)`);
// 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试)
const worker: Promise<void> =
BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {});
const guard: Promise<void> = new Promise<void>((_res: () => void, rej: (e: Error) => void) => {
setTimeout((): void => rej(new Error('本次同步时间预算用尽,主动收手')),
budget);
});
try {
await Promise.race<void>([worker, guard]);
} catch (err) {
const e = err as Error;
LogUtil.write(`卡片驱动同步提前收手:${e.message}(幂等,下轮会重试)`);
} finally {
// 无论成败都释放短时任务:① 归还当日配额 ② 避免超时不取消被系统强杀进程
BackgroundSyncRunner.releaseCardWindow();
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`卡片驱动同步异常: ${e.message}`);
}
}
/**
* 为卡片同步申请短时任务,返回**实际可用窗口(毫秒)**。
* 申请失败/异常/时长为 0 → 返回 0,由调用方退回默认的 9 秒软超时(不阻塞流程)。
*/
private static async acquireCardWindow(): Promise<number> {
try {
const info: backgroundTaskManager.DelaySuspendInfo =
backgroundTaskManager.requestSuspendDelay('正在同步日历数据', (): void => {
// 超时回调:配额即将用尽,清掉本地记录(系统会自行处理挂起)
LogUtil.write('卡片同步的短时任务即将超时');
BackgroundSyncRunner.cardTransientId = 0;
});
BackgroundSyncRunner.cardTransientId = info.requestId;
const actual: number = info.actualDelayTime ?? 0;
if (actual <= 0) {
// 拿到了 requestId 但没有实际时长 → 视为不可用
BackgroundSyncRunner.releaseCardWindow();
LogUtil.write('卡片同步:短时任务时长为 0,退回 9 秒软超时');
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
}
// 留 15 秒余量提前收手,保证能干净地取消
const usable: number = Math.max(actual - BackgroundSyncRunner.CARD_EXT_SOFT_RESERVE_MS,
BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS);
LogUtil.write(`卡片同步:短时任务已获批,实际时长 ${Math.round(actual / 1000)} 秒`);
return usable;
} catch (err) {
// 例如"未在前台/退后台时机申请"被拒 —— 退回 9 秒软超时,不影响流程
const e = err as BusinessError;
LogUtil.write(`卡片同步:短时任务申请失败(${e.code ?? ''} ${e.message}),退回 9 秒软超时`);
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
}
}
/** 释放卡片同步的短时任务(幂等) */
private static releaseCardWindow(): void {
if (BackgroundSyncRunner.cardTransientId === 0) {
return;
}
try {
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncRunner.cardTransientId);
} catch (err) {
// 取消失败无需处理
}
BackgroundSyncRunner.cardTransientId = 0;
}
/**
* 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。
* @param context 应用上下文
* @param tag 日志标签,用于区分触发来源(deferred/transient/netRecover/foreground)
*/
static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> {
const r = new BackgroundSyncResult();
// ⭐ 阶段计时:后台同步被时间预算切断时,这些 `+Xms` 就是"钱花在哪了"的直接证据。
const t0: number = Date.now();
const el = (): number => Date.now() - t0;
try {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
LogUtil.write(`后台同步[${tag}] 阶段:载入账号 ${accounts.length} 个 +${el()}ms`);
if (accounts.length === 0) {
LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
return r;
}
// 备份模式:先导入系统本地日历(幂等),再随同步推送
try {
await SystemCalendarImport.importIfNeeded(context);
} catch (err) {
// 导入失败不影响正常同步
}
LogUtil.write(`后台同步[${tag}] 阶段:系统日历导入 +${el()}ms`);
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
try {
await MirrorInbound.reconcile(context);
} catch (err) {
// 对账失败不影响正常同步
}
LogUtil.write(`后台同步[${tag}] 阶段:入站对账 +${el()}ms`);
for (const acc of accounts) {
if (acc.type !== TYPE_CALDAV) {
continue;
}
r.attempted++;
try {
await SyncEngine.syncAccount(context, acc);
r.ok++;
} catch (err) {
const e = err as BusinessError;
// "已有同步进行中"不算失败(前台可能正在同步)
r.failed++;
LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`);
}
LogUtil.write(`后台同步[${tag}] 阶段:账号 ${acc.id} 同步 +${el()}ms(ok=${r.ok} failed=${r.failed})`);
}
// 全部账号同步成功才结束"一次性全量重拉"状态
if (r.attempted > 0 && r.ok === r.attempted) {
await AppSettings.markFullRefetchDone(context);
}
await SyncEngine.settleLocalEvents(context);
LogUtil.write(`后台同步[${tag}] 阶段:本地收尾 +${el()}ms`);
await SyncEngine.pruneOrphanRows(context, accounts);
LogUtil.write(`后台同步[${tag}] 阶段:孤儿行清理 +${el()}ms`);
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。
await AccountStore.saveAll(context, accounts);
// 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点)
try {
if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) {
const mr = await SystemCalendarMirror.syncNow(context);
LogUtil.write(`后台同步[${tag}] 自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
}
} catch (err) {
// 镜像失败不影响同步
}
LogUtil.write(`后台同步[${tag}] 阶段:出站镜像 +${el()}ms`);
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
try {
await CardDataService.pushToAllForms(context);
await ReminderService.refreshReminders(context as common.UIAbilityContext);
} catch (err) {
// 卡片/提醒刷新失败不影响同步结果
}
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号,总耗时 ${Math.round(el() / 1000)} 秒`);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}(已耗时 ${Math.round(el() / 1000)} 秒)`);
}
return r;
}
}
@@ -1,58 +1,46 @@
// entry/src/main/ets/common/BackgroundSyncService.ets // entry/src/main/ets/common/BackgroundSyncService.ets
// 后台同步管理: // ⭐ 后台同步调度(2026-10-10 重构,依据《定时同步方案调研.md》)
// 1) 长时任务(DATA_TRANSFER):进程不被挂起,前台定时器在后台继续跑(需用户在设置中开启) //
// 2) 延迟任务(workScheduler):App 被杀/重启后由系统按条件拉起兜底同步(最小周期 20 分钟) // 为什么不再用长时任务(重要):
// 旧版申请 `dataTransfer` 长时任务想让进程常驻,但系统对 DATA_TRANSFER 有**一致性校验**——
// "超过 10 分钟未更新传输进度 → 任务被强制取消"。日历同步是"间歇跑、平时无进度",
// 本质不符合"持续传输中"的语义 → 必被取消(常驻通知消失就是这个原因),
// 且频繁触发校验失败可能被系统列入管控名单。故**彻底移除**。
//
// 现在的分层调度(每层各司其职):
// ① 前台:Index 里的 setInterval 按用户设定间隔同步(最准,已在前台实现)
// ② 退后台瞬间:**短时任务**(requestSuspendDelay)兜住"退后台后那一次"
// ③ 长期后台:**延迟任务**(workScheduler,≥2 小时,对齐系统活跃分组下限)
// ④ 网络恢复 / 回到前台:各补一次(性价比最高,见 NetworkSyncWatcher / Index.onForeground)
//
// ⚠️ 关键约束(踩过的坑,勿改):
// - workScheduler 的 repeatCycleTime **不得低于 2 小时**:系统按"应用活跃分组"压制
// (活跃≥2h / 经常使用≥4h / 常用≥24h / 极少使用≥48h / 受限·从未使用禁止调度),
// 请求更短不报错但会被压制,形同虚设。
// - WorkSchedulerExtensionAbility 单次回调**上限 2 分钟**,超时进程被杀。
// - 短时任务配额:单日 10 分钟、单次最长 3 分钟(低电量 1 分钟)→ 只做短收尾,用完必须 cancel。
import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { wantAgent, common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { workScheduler } from '@kit.BackgroundTasksKit'; import { workScheduler } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
const WORK_ID: number = 1001; const WORK_ID: number = 1001;
/** 延迟任务周期:2 小时(系统"活跃分组"下限,不可再短) */
const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000;
export class BackgroundSyncService { export class BackgroundSyncService {
/** 申请长时任务(DATA_TRANSFER):申请成功后进程在后台不被挂起,失败抛错由调用方提示用户 */ /** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */
static async startContinuousTask(context: common.UIAbilityContext): Promise<void> { private static transientId: number = 0;
try {
const info: wantAgent.WantAgentInfo = {
wants: [{
bundleName: context.abilityInfo.bundleName,
abilityName: 'EntryAbility'
}],
operationType: wantAgent.OperationType.START_ABILITY,
requestCode: 0
};
const agent = await wantAgent.getWantAgent(info);
await backgroundTaskManager.startBackgroundRunning(
context, backgroundTaskManager.BackgroundMode.DATA_TRANSFER, agent);
LogUtil.write('长时任务已申请:后台持续同步开启');
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`长时任务申请失败: ${e.code} - ${e.message}`);
throw new Error(`长时任务申请失败(错误码 ${e.code})`);
}
}
/** 停止长时任务 */
static async stopContinuousTask(context: common.UIAbilityContext): Promise<void> {
try {
await backgroundTaskManager.stopBackgroundRunning(context);
LogUtil.write('长时任务已停止');
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`长时任务停止失败: ${e.code} - ${e.message}`);
}
}
/** /**
* 注册延迟任务兜底同步:无论长时任务开关与否都注册。 * 注册延迟任务兜底同步:周期 2 小时、重启后自动恢复(isPersisted)。
* 条件:有任意网络即执行;周期重复;重启后自动恢复。 * 条件:有任意网络即执行。
* 注:repeatCycleTime 各版本下限不同(20 分钟 ~ 2 小时),先用 20 分钟, * ⚠️ 周期**不得低于 2 小时**(系统按"应用活跃分组"压制:活跃≥2h / 经常使用≥4h / 常用≥24h /
* 被系统拒绝(9700003 条件无效)时降级为 2 小时再试。 * 极少使用≥48h / 受限·从未使用禁止调度)。请求更短不报错但会被压制,形同虚设。
*/ */
static scheduleDeferredSync(context: common.UIAbilityContext): void { static scheduleDeferredSync(context: common.UIAbilityContext): void {
const build = (cycleMs: number): workScheduler.WorkInfo => {
const info: workScheduler.WorkInfo = { const info: workScheduler.WorkInfo = {
workId: WORK_ID, workId: WORK_ID,
bundleName: context.abilityInfo.bundleName, bundleName: context.abilityInfo.bundleName,
@@ -60,56 +48,105 @@ export class BackgroundSyncService {
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
isRepeat: true, isRepeat: true,
isPersisted: true, isPersisted: true,
repeatCycleTime: cycleMs repeatCycleTime: DEFERRED_CYCLE_MS
};
return info;
}; };
try { try {
workScheduler.startWork(build(20 * 60 * 1000)); workScheduler.startWork(info);
LogUtil.write('延迟任务已注册:20 分钟兜底同步'); LogUtil.write('延迟任务已注册:2 小时兜底同步(系统活跃分组下限)');
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
if (e.code === 9700003) {
try {
workScheduler.startWork(build(2 * 60 * 60 * 1000));
LogUtil.write('延迟任务降级注册:2 小时兜底同步');
return;
} catch (err2) {
const e2 = err2 as BusinessError;
LogUtil.write(`延迟任务注册失败(2h): ${e2.code} - ${e2.message}`);
return;
}
}
LogUtil.write(`延迟任务注册失败: ${e.code} - ${e.message}`); LogUtil.write(`延迟任务注册失败: ${e.code} - ${e.message}`);
} }
} }
/** App 启动时统一入口:注册延迟任务 + 按设置恢复长时任务(恢复失败静默,不影响启动) */ /** 取消延迟任务(用户关闭"后台自动同步"时调用) */
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> { static cancelDeferredSync(context: common.UIAbilityContext): void {
BackgroundSyncService.scheduleDeferredSync(context); const info: workScheduler.WorkInfo = {
const enabled: boolean = await AppSettings.getBackgroundSync(context); workId: WORK_ID,
if (enabled) { bundleName: context.abilityInfo.bundleName,
abilityName: 'SyncWorkAbility',
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
isRepeat: true,
isPersisted: true,
repeatCycleTime: DEFERRED_CYCLE_MS
};
try { try {
await BackgroundSyncService.startContinuousTask(context); workScheduler.stopWork(info, true);
LogUtil.write('延迟任务已取消(后台自动同步关闭)');
} catch (err) { } 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) { * 必须在**前台或退后台 5 秒内**调用,否则申请失败。
// 先申请任务,成功才落盘设置(失败时设置页回滚开关) * 单次最长 3 分钟(实际申请到的时长以返回值为准),单日总配额 10 分钟。
await BackgroundSyncService.startContinuousTask(context); */
await AppSettings.setBackgroundSync(context, true); static requestTransientSync(reason: string): void {
} else { if (BackgroundSyncService.transientId !== 0) {
await AppSettings.setBackgroundSync(context, false); return; // 已有短时任务在跑,不重复申请(重复申请会浪费配额)
}
try { 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) { } 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 { export class DisplayEvent {
id: number = 0; // 本地库 id 或系统日历事件 id id: number = 0; // 本地库 id 或系统日历事件 id
uid: string = ''; // 服务器 UID(待办父子关系用它匹配 parentUid)
isSystem: boolean = false; isSystem: boolean = false;
title: string = ''; title: string = '';
description: string = ''; description: string = '';
@@ -25,6 +26,21 @@ export class DisplayEvent {
color: string = '#007DFF'; color: string = '#007DFF';
completed: boolean = false; // 仅待办使用 completed: boolean = false; // 仅待办使用
recurring: boolean = false; // 是否为重复日程展开出的发生 recurring: boolean = false; // 是否为重复日程展开出的发生
/** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同:
* recurring 只说"重不重复",这里带的是"多久重复一次"。 */
recurRule: string = '';
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
dtstart: number = 0;
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
priority: number = 0;
/** 分类标签,逗号分隔(对应 CATEGORIES) */
categories: string = '';
/** 提醒提前分钟数(多选),空 = 不提醒 */
reminders: number[] = [];
/** 父任务 UID(空 = 顶层任务),对应 RELATED-TO */
parentUid: string = '';
/** 附件 JSON 数组字符串(含 base64),空 = 无附件 */
attachments: string = '';
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑) writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
} }
@@ -112,6 +128,9 @@ export class CalendarDataService {
const detected: boolean = acc.calendarWritable.length > i const detected: boolean = acc.calendarWritable.length > i
? acc.calendarWritable[i] !== '0' : true; ? acc.calendarWritable[i] !== '0' : true;
s.writable = detected && !manualKeys.includes(s.calKey); s.writable = detected && !manualKeys.includes(s.calKey);
// 透传本支持的类型(新建待办/日程时据此过滤,避免把待办写进纯日历本)
s.comps = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
? acc.calendarComps[i] : 'unknown';
sources.push(s); sources.push(s);
} }
} }
@@ -202,12 +221,14 @@ export class CalendarDataService {
} }
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。 // 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。
// 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本); // 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本);
// ②只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留) // ②键必须用 **recurrenceId(原始发生时刻)**——覆盖行的 startTime 是"被改到的新时间",
// 用它做键时主事件在原时间的那次发生不会被排除 → 同一日程显示两条(幽灵日程);
// ③只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
// 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示" // 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示"
const overrideKeys: string[] = []; const overrideKeys: string[] = [];
for (const e of rows) { for (const e of rows) {
if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) { if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) {
overrideKeys.push(`${e.calKey}_${e.uid}_${e.startTime}`); overrideKeys.push(EventDb.overrideKey(e.calKey, e.uid, e.recurrenceId, e.startTime));
} }
} }
let occTotal: number = 0; let occTotal: number = 0;
@@ -232,7 +253,7 @@ export class CalendarDataService {
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500); const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
if (occs.length > 0) { if (occs.length > 0) {
times = occs.filter((occ: number): boolean => times = occs.filter((occ: number): boolean =>
!overrideKeys.includes(`${e.calKey}_${e.uid}_${occ}`)); !overrideKeys.includes(EventDb.overrideKey(e.calKey, e.uid, 0, occ)));
} }
} }
for (const occ of times) { for (const occ of times) {
@@ -339,6 +360,11 @@ export class CalendarDataService {
const found = sources.find((s: CalSource): boolean => s.calKey === key); const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.color : '#9AA0A6'; return found !== undefined ? found.color : '#9AA0A6';
}; };
// ⭐ 待办按日历本分组显示需要"本名";原来只带 calKey,分组标题无法显示
const nameOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.name : '';
};
try { try {
const rows: LocalEvent[] = await EventDb.queryTodos(context); const rows: LocalEvent[] = await EventDb.queryTodos(context);
for (const e of rows) { for (const e of rows) {
@@ -347,6 +373,7 @@ export class CalendarDataService {
} }
const d = new DisplayEvent(); const d = new DisplayEvent();
d.id = e.id; d.id = e.id;
d.uid = e.uid;
d.isSystem = false; d.isSystem = false;
d.title = e.title; d.title = e.title;
d.description = e.description; d.description = e.description;
@@ -355,8 +382,17 @@ export class CalendarDataService {
d.endTime = e.endTime; d.endTime = e.endTime;
d.isAllDay = e.isAllDay; d.isAllDay = e.isAllDay;
d.calKey = e.calKey; d.calKey = e.calKey;
d.calName = nameOf(e.calKey);
d.color = colorOf(e.calKey); d.color = colorOf(e.calKey);
d.completed = e.completed; d.completed = e.completed;
d.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); result.push(d);
} }
} catch (err) { } catch (err) {
+172 -6
View File
@@ -36,6 +36,24 @@ export class CardAllDay {
isAllDay: boolean = true; 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 { export class CardData {
eventsJson: string = '[]'; eventsJson: string = '[]';
@@ -62,6 +80,15 @@ export class CardData {
isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线) isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线)
selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天) selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天)
dayCount: number = 0; // 当前显示日的日程条数(含全天) dayCount: number = 0; // 当前显示日的日程条数(含全天)
// ===== 待办卡片专用字段(isTodoCard=true 时才有效)=====
/** 待办卡片列表:TodoCardItem[] JSON(按截止时间升序,未完成在前) */
todoListJson: string = '[]';
/** 未完成待办总数(2x2 / 2x4 卡片大数字) */
todoCount: number = 0;
/** 今日到期待办数(卡片副标题用) */
todoTodayCount: number = 0;
/** 是否待办卡片(卡片侧据此切换渲染分支) */
isTodoCard: boolean = false;
} }
export class CardDataService { export class CardDataService {
@@ -127,25 +154,57 @@ export class CardDataService {
} }
/** App 内同步完成后调用:刷新所有已添加的卡片 */ /** 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 重启后内存注册表为空) // 先恢复持久化的 formId(App 重启后内存注册表为空)
await CardDataService.loadPersistedFormIds(context); await CardDataService.loadPersistedFormIds(context);
if (CardDataService.formIds.length === 0) { if (CardDataService.formIds.length === 0) {
return; 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> = const cache: Map<number, formBindingData.FormBindingData> =
new 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 = const fallback: formBindingData.FormBindingData =
formBindingData.createFormBindingData(await CardDataService.buildCardData(context, 0)); formBindingData.createFormBindingData(fallbackData);
cache.set(0, fallback); cache.set(0, fallback);
const stale: string[] = []; const stale: string[] = [];
for (const formId of CardDataService.formIds) { for (const formId of CardDataService.formIds) {
const off: number = CardDataService.getDayOffset(formId); const off: number = CardDataService.getDayOffset(formId);
let binding: formBindingData.FormBindingData | undefined = cache.get(off); let binding: formBindingData.FormBindingData | undefined = cache.get(off);
if (binding === undefined) { if (binding === undefined) {
binding = formBindingData.createFormBindingData( const d: CardData = await CardDataService.buildCardData(context, off);
await CardDataService.buildCardData(context, off)); if (todoData !== null) {
CardDataService.mergeTodoInto(d, todoData);
}
binding = formBindingData.createFormBindingData(d);
cache.set(off, binding); cache.set(off, binding);
} }
try { try {
@@ -305,14 +364,16 @@ export class CardDataService {
// 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中) // 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中)
data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length; data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length;
// ===== 2x4 卡片右侧:**当前时间之后最近的 2 条**日程 ===== // ===== 2x4 卡片右侧:**当前时间之后最近的若干条**日程(卡片可滚动,最多 15 条)=====
// 规则:只取"还没开始"的有时间日程(startTime >= now), // 规则:只取"还没开始"的有时间日程(startTime >= now),
// **排除全天 / 跨天**,按开始时间升序取前 2 条。 // **排除全天 / 跨天**,按开始时间升序取前 2 条。
// 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。 // 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。
const upcomingNext: DisplayEvent[] = upcoming const upcomingNext: DisplayEvent[] = upcoming
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs) .filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime); .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[] = []; const ongoingItems: CardItem[] = [];
for (const e of ongoingPick) { for (const e of ongoingPick) {
const dayLong: boolean = isDayLong(e); const dayLong: boolean = isDayLong(e);
@@ -381,4 +442,109 @@ export class CardDataService {
} }
return data; 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;
}
} }
+156 -5
View File
@@ -18,6 +18,15 @@ export class DavColorEntry {
color: string = ''; // 规范化后的 #RRGGBB,可能为空 color: string = ''; // 规范化后的 #RRGGBB,可能为空
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写) writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测) privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
/**
* ⭐ COMP_SET 该集合支持的组件类型(来自 supported-calendar-component-set)。
* 'event' = 纯日历本(只有 VEVENT)
* 'todo' = 纯待办本(只有 VTODO)
* 'both' = 混合本(两者都有)
* 'unknown' = 服务器未声明该属性(老实现常见)→ 按日历对待,保持兼容
*/
comps: string = 'unknown';
} }
/** 日历本集合条目("添加/编辑账号"页列日历本用) */ /** 日历本集合条目("添加/编辑账号"页列日历本用) */
@@ -25,6 +34,8 @@ export class DavCalendarEntry {
href: string = ''; // 完整 URL(已补全域名) href: string = ''; // 完整 URL(已补全域名)
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段) displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
color: string = ''; // 规范化后的 #RRGGBB,可能为空 color: string = ''; // 规范化后的 #RRGGBB,可能为空
/** ⭐ COMP_SET 'event' 纯日历 / 'todo' 纯待办 / 'both' 混合 / 'unknown' 服务器未声明 */
comps: string = 'unknown';
} }
/** /**
@@ -36,6 +47,20 @@ export class DavCalendarDiscovery {
entries: DavCalendarEntry[] = []; entries: DavCalendarEntry[] = [];
} }
/**
* 带 HTTP 状态码的 DAV 请求错误。
* 同步引擎据此区分「404 日历本/资源在服务器上已不存在」(可降级处理:
* 单本跳过、自动从账号移除)与其它网络/服务器故障(整个账号同步失败)。
*/
export class DavHttpError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export class DavClient { export class DavClient {
/** /**
* PROPFIND 拉取某路径下所有集合的 calendar-color * PROPFIND 拉取某路径下所有集合的 calendar-color
@@ -47,6 +72,8 @@ export class DavClient {
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' + 'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' + '<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'<d:current-user-privilege-set/>' + '<d:current-user-privilege-set/>' +
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
'</d:prop></d:propfind>'; '</d:prop></d:propfind>';
const httpRequest = http.createHttp(); const httpRequest = http.createHttp();
try { try {
@@ -90,6 +117,7 @@ export class DavClient {
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color')); color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
} }
entry.color = color; entry.color = color;
entry.comps = DavClient.parseComps(block);
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读 // 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set'); const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
if (privSet !== '') { if (privSet !== '') {
@@ -109,6 +137,33 @@ export class DavClient {
} }
} }
/**
* ⭐ COMP_SET 解析 supported-calendar-component-set,归一成四类:
* event / todo / both / unknown
*
* 为什么 unknown 要单独存在:很多服务器(含自研实现)压根不返回这个属性。
* 这时不能武断判成"不是待办本",否则用户原本能用的日历会被当成待办、
* 或者反过来把纯日历标成待办 —— 保守按"日历"对待,不破坏既有行为。
*/
static parseComps(block: string): string {
const seg: string = DavClient.extractTag(block, 'supported-calendar-component-set');
if (seg === '') {
return 'unknown';
}
const event: boolean = /name\s*=\s*["']VEVENT["']/i.test(seg);
const todo: boolean = /name\s*=\s*["']VTODO["']/i.test(seg);
if (event && todo) {
return 'both';
}
if (todo) {
return 'todo';
}
if (event) {
return 'event';
}
return 'unknown';
}
/** /**
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。 * PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
* *
@@ -122,6 +177,7 @@ export class DavClient {
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' + '<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' + 'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' + '<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
'</d:prop></d:propfind>'; '</d:prop></d:propfind>';
const httpRequest = http.createHttp(); const httpRequest = http.createHttp();
const out = new DavCalendarDiscovery(); const out = new DavCalendarDiscovery();
@@ -183,6 +239,9 @@ export class DavClient {
entry.href = href.startsWith('http') ? href : origin + href; entry.href = href.startsWith('http') ? href : origin + href;
entry.displayName = name; entry.displayName = name;
entry.color = color; entry.color = color;
entry.comps = DavClient.parseComps(block);
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
out.entries.push(entry); out.entries.push(entry);
} }
return out; return out;
@@ -236,6 +295,37 @@ export class DavClient {
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64'); return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
} }
/**
* GET 拉取单个资源原文,**失败抛错**(与 getRaw 相反)。
*
* 为什么需要严格版:待办写回要"先 GET 原文、改字段、再 PUT"。
* 若 GET 失败却拿到一个 `"HTTP 404"` 之类的字符串并当 ICS 内容 PUT 回去,
* 会把服务器上那条待办**覆盖成一句错误信息**(数据破坏)。
* 原 getRaw 保留原行为不动(诊断用,已有调用方)。
*/
static async getRawStrict(url: string, auth: string): Promise<string> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: http.RequestMethod.GET,
header: {
'Authorization': auth,
'Content-Type': 'text/calendar; charset=utf-8',
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 10000,
readTimeout: 20000,
expectDataType: http.HttpDataType.STRING
});
if (resp.responseCode < 200 || resp.responseCode >= 300) {
throw new DavHttpError(resp.responseCode, `拉取待办原文失败,服务器返回 ${resp.responseCode}`);
}
return resp.result as string;
} finally {
httpRequest.destroy();
}
}
/** GET 拉取单个资源的原始 ICS(调试诊断用) */ /** GET 拉取单个资源的原始 ICS(调试诊断用) */
static async getRaw(url: string, auth: string): Promise<string> { static async getRaw(url: string, auth: string): Promise<string> {
const httpRequest = http.createHttp(); const httpRequest = http.createHttp();
@@ -346,7 +436,7 @@ export class DavClient {
}); });
LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`);
if (resp.responseCode < 200 || resp.responseCode >= 300) { if (resp.responseCode < 200 || resp.responseCode >= 300) {
throw new Error(`服务器返回状态码 ${resp.responseCode}`); throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
} }
const xml: string = resp.result as string; const xml: string = resp.result as string;
const items: RemoteItem[] = []; const items: RemoteItem[] = [];
@@ -375,6 +465,52 @@ export class DavClient {
return DavClient.reportComponents(href, auth, 'VTODO', ''); 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 */ /** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */
private static async reportComponents(href: string, auth: string, private static async reportComponents(href: string, auth: string,
compName: string, timeRange: string): Promise<RemoteItem[]> { compName: string, timeRange: string): Promise<RemoteItem[]> {
@@ -385,6 +521,12 @@ export class DavClient {
`<c:comp-filter name="${compName}">` + `<c:comp-filter name="${compName}">` +
timeRange + timeRange +
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>'; '</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(); const httpRequest = http.createHttp();
try { try {
const resp: http.HttpResponse = await httpRequest.request(href, { const resp: http.HttpResponse = await httpRequest.request(href, {
@@ -403,14 +545,16 @@ export class DavClient {
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
if (resp.responseCode === 401) { if (resp.responseCode === 401) {
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`); LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
throw new Error('服务器拒绝凭据(401)'); throw new DavHttpError(401, '服务器拒绝凭据(401)');
} }
if (resp.responseCode < 200 || resp.responseCode >= 300) { if (resp.responseCode < 200 || resp.responseCode >= 300) {
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`);
throw new Error(`服务器返回状态码 ${resp.responseCode}`); throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
} }
const xml: string = resp.result as string; const xml: string = resp.result as string;
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`); LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
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 originMatch = /https?:\/\/[^/]+/i.exec(href);
const origin: string = originMatch !== null ? originMatch[0] : ''; const origin: string = originMatch !== null ? originMatch[0] : '';
const items: RemoteItem[] = []; const items: RemoteItem[] = [];
@@ -435,6 +579,13 @@ export class DavClient {
item.ics = block.substring(start, end + 'END:VCALENDAR'.length); item.ics = block.substring(start, end + 'END:VCALENDAR'.length);
items.push(item); 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; return items;
} finally { } finally {
httpRequest.destroy(); httpRequest.destroy();
@@ -459,7 +610,7 @@ export class DavClient {
console.info(`PUT ${url} 响应码: ${resp.responseCode}`); console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`); LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
if (resp.responseCode < 200 || resp.responseCode >= 300) { if (resp.responseCode < 200 || resp.responseCode >= 300) {
throw new Error(`推送失败,服务器返回 ${resp.responseCode}`); throw new DavHttpError(resp.responseCode, `推送失败,服务器返回 ${resp.responseCode}`);
} }
const headers = resp.header as Record<string, string>; const headers = resp.header as Record<string, string>;
if (headers !== undefined && headers !== null) { if (headers !== undefined && headers !== null) {
@@ -489,7 +640,7 @@ export class DavClient {
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`); LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
// 404 视为已删除,成功 // 404 视为已删除,成功
if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) { if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) {
throw new Error(`删除失败,服务器返回 ${resp.responseCode}`); throw new DavHttpError(resp.responseCode, `删除失败,服务器返回 ${resp.responseCode}`);
} }
} finally { } finally {
httpRequest.destroy(); httpRequest.destroy();
+458
View File
@@ -0,0 +1,458 @@
// entry/src/main/ets/common/DavDiscovery.ets
// ⭐ CalDAV 地址自动发现与智能补全(0.0.7)
//
// 解决的用户问题(见《CalDAV排查指南-405与读不出日历.md》):
// 用户习惯只填域名或 DAV 根,而 Nextcloud 等要求填「日历家目录」
// (/remote.php/dav/calendars/<用户名>/)。填错表现为"读不出日历"或 405。
// DAVx5 能用是因为它内置 .well-known 自动发现 —— 本模块补上这一能力。
//
// 发现顺序(逐级降级,任一成功即停):
// ① 用户填的地址原样探测(保持既有行为,兼容已能用的用户)
// ② .well-known/caldav(RFC 6764 标准;Nextcloud/Radicale/Baïkal 均支持)
// ③ 常见候选路径拼接(remote.php/dav/calendars/<用户名>/ 等)
//
// ⚠️ 设计约束:
// - 全部探测都是 PROPFIND + Depth:0,只判断「是不是能列出日历的端点」,不做副作用;
// - 单次探测失败不抛错(网络异常吞掉继续试下一个),保证不会因某个候选不可达而整体失败;
// - 最终由调用方用 listCalendars 校验(能列出 calendar 集合才算真成功)。
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { LogUtil } from './LogUtil';
/** 发现结果:拿到一个可用的"日历集合列表"端点 */
export class DiscoverResult {
ok: boolean = false;
url: string = ''; // 最终可用的端点 URL
via: string = ''; // 来源:direct / well-known / candidate / public
readOnly: boolean = false; // 是否只读(公开日历分享);本版本仅识别,不做写入
hint: string = ''; // 失败时给用户的可操作提示
}
/** 415/405 之外:用于判断"不是 DAV 端点"的状态码集合 */
const NOT_DAV_CODES: number[] = [404, 405, 501];
export class DavDiscovery {
// ───────────────────────── 公开工具:URL 形态识别 ─────────────────────────
/** 取 origin(scheme://host[:port]) */
static originOf(url: string): string {
const m = /^https?:\/\/[^/]+/i.exec(url.trim());
return m !== null ? m[0] : '';
}
/** 规范化:去掉末尾多余斜杠、去空白 */
static tidy(url: string): string {
let s: string = url.trim();
// 去掉查询与片段(发现阶段不保留)
s = s.replace(/[?#].*$/, '');
while (s.endsWith('/') && s.length > DavDiscovery.originOf(s).length + 1) {
s = s.substring(0, s.length - 1);
}
return s;
}
/**
* ⭐ 识别「网页分享链接」——这类地址对 PROPFIND 一律 405,必须提前拦下并提示。
* 覆盖:
* - Nextcloud 日历分享页:/apps/calendar/p/<token>
* - Nextcloud 文件分享页:/s/<token>、/index.php/s/<token>
* - Nextcloud 公开日历(只读,可用):/remote.php/dav/public-calendars/<token>
*/
static classify(url: string): string {
const u: string = url.trim();
const path = u.replace(/^https?:\/\/[^/]+/i, '');
if (/^\/apps\/calendar\/p\//i.test(path)) {
return 'share_calendar'; // 日历分享页面(网页)
}
if (/^\/(index\.php\/)?s\//i.test(path)) {
return 'share_file'; // 文件分享页面(网页)
}
if (/^\/remote\.php\/dav\/public-calendars\//i.test(path)) {
return 'public_caldav'; // 公开日历(DAV,只读)
}
if (/^\/remote\.php\/dav\/public-calendars$/i.test(path.replace(/\/$/, ''))) {
return 'public_caldav';
}
return 'normal';
}
/** 分享链接 → 给用户的人话解释 */
static shareHint(kind: string): string {
if (kind === 'share_calendar') {
return '这是 Nextcloud 的「日历分享链接」(网页地址),CalDAV 客户端不能直接使用。'
+ '请在 Nextcloud 网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」,'
+ '把得到的私有链接填到「服务器地址」。';
}
if (kind === 'share_file') {
return '这是文件分享链接(网页地址),不是 CalDAV 地址。'
+ '请改用日历的「私有链接」:Nextcloud 网页版「日历 → 设置 → 复制私有链接」。';
}
return '';
}
/**
* 是否为"只可能返回 405/404 的网页地址"——用于在探测前直接拦截,省一次请求。
*/
static isWebPage(url: string): boolean {
const k: string = DavDiscovery.classify(url);
return k === 'share_calendar' || k === 'share_file';
}
/**
* DAV 根 / 站点根 → 需要补全。用于判断是否值得走候选路径。
* `/remote.php/dav` 或仅 origin 时,多半缺"日历家目录"这一层。
*/
static looksIncomplete(url: string): boolean {
const path: string = DavDiscovery.tidy(url).replace(/^https?:\/\/[^/]+/i, '').replace(/\/$/, '');
if (path === '' || path === '/') {
return true; // 只填了域名
}
if (/\/remote\.php\/dav$/i.test(path)) {
return true; // DAV 根
}
if (/\/remote\.php\/dav\/(calendars|addressbooks)$/i.test(path)) {
return true; // 少了用户名一层
}
return false;
}
// ───────────────────────── 探测原语 ─────────────────────────
/**
* PROPFIND Depth:0 探测端点:只关心"能不能列、是不是 DAV"。
* 不抛错:网络异常/状态码异常都返回可判定的结果。
*/
static async probe(url: string, auth: string): Promise<number> {
const body: string =
'<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '0',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: body,
connectTimeout: 8000,
readTimeout: 12000
});
LogUtil.write(`HTTP PROPFIND(发现) ${url} → ${resp.responseCode}`);
return resp.responseCode;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`PROPFIND(发现) ${url} 异常: ${e.code} - ${e.message}`);
return 0; // 0 = 网络层失败
} finally {
httpRequest.destroy();
}
}
/**
* ⭐ OPTIONS 探测服务器的 DAV 能力:
* - 返回 `DAV:` 响应头 → 标准 WebDAV
* - 返回 `Allow` 里含 PROPFIND/REPORT → 支持 CalDAV 所需方法
* 用于区分"这不是 DAV 端点"(如网页路由)与"服务端把方法挡了"。
*/
static async optionsCapability(url: string, auth: string): Promise<DavCapability> {
const cap = new DavCapability();
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: http.RequestMethod.OPTIONS,
header: {
'Authorization': auth,
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 8000,
readTimeout: 12000
});
cap.status = resp.responseCode;
const headers = resp.header as Record<string, string>;
if (headers !== undefined && headers !== null) {
// 头名大小写不统一,逐个大小写变体试
const dav = headers['DAV'] ?? headers['Dav'] ?? headers['dav'] ?? '';
const allow = headers['Allow'] ?? headers['allow'] ?? '';
cap.davHeader = typeof dav === 'string' ? dav : '';
cap.allow = typeof allow === 'string' ? allow : '';
cap.isDav = cap.davHeader !== '' || /PROPFIND/i.test(cap.allow);
cap.supportsPropfind = /PROPFIND/i.test(cap.allow) || cap.isDav;
cap.supportsReport = /REPORT/i.test(cap.allow);
// Allow 明确列出但两者都没有 → 大概率不是 DAV
if (cap.allow !== '' && !cap.supportsPropfind) {
cap.isDav = false;
}
}
LogUtil.write(`OPTIONS ${url} → ${resp.responseCode},DAV=${cap.davHeader || '(无)'},Allow=${cap.allow || '(无)'}`);
return cap;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`OPTIONS ${url} 异常: ${e.code} - ${e.message}`);
return cap;
} finally {
httpRequest.destroy();
}
}
// ───────────────────────── 候选路径生成 ─────────────────────────
/**
* 生成候选「日历家目录 / 集合列表」端点。
* @param base 用户填入并规范化后的地址
* @param username 用户名(用于 Nextcloud 的 calendars/<用户名>/ 等模式)
*/
static candidates(base: string, username: string): string[] {
const origin: string = DavDiscovery.originOf(base);
const out: string[] = [];
if (origin === '') {
return out;
}
const path: string = DavDiscovery.tidy(base).substring(origin.length);
// 站点可能部署在子目录(如 /nextcloud)——保留用户填的前缀,只补 DAV 相关部分
// 例:填 https://h/nextcloud → 前缀 /nextcloud
let prefix: string = '';
const idx: number = path.toLowerCase().indexOf('/remote.php');
if (idx >= 0) {
prefix = path.substring(0, idx);
} else if (path !== '' && path !== '/') {
prefix = path.replace(/\/$/, '');
}
const u: string = encodeURIComponent(username);
const push = (p: string): void => {
const full: string = origin + p;
if (out.indexOf(full) < 0 && DavDiscovery.tidy(full) !== DavDiscovery.tidy(base)) {
out.push(full);
}
};
// Nextcloud / ownCloud
push(`${prefix}/remote.php/dav/calendars/${u}/`);
push(`${prefix}/remote.php/dav`);
// Radicale(通常直接就是 /<用户名>/)
push(`${prefix}/${u}/`);
// Baïkal
push(`${prefix}/dav.php/calendars/${u}/`);
// 通用:直接在根下找
push(`${prefix}/`);
return out;
}
/**
* ⭐ 主入口:在给定端点集合上依次探测,返回第一个"能列出日历"的端点。
* 判据=PROPFIND 得 207/200。真正的"日历集合筛选"仍由 DavClient.listCalendars 完成
* (本方法只负责找到"哪个地址对",避免与既有解析逻辑重复)。
*
* @param rawUrl 用户填入的原始地址(未规范化也可)
* @param username 用户名
* @param auth Authorization 头
* @param verify 可选的校验函数:传入端点,返回是否真的能列出日历(由调用方注入 listCalendars)
*/
static async resolve(rawUrl: string, username: string, auth: string,
verify?: (url: string) => Promise<boolean>): Promise<DiscoverResult> {
const res = new DiscoverResult();
const base: string = DavDiscovery.tidy(rawUrl);
// ① 网页分享链接:直接拦下,不浪费请求
const kind: string = DavDiscovery.classify(base);
if (kind === 'share_calendar' || kind === 'share_file') {
res.ok = false;
res.hint = DavDiscovery.shareHint(kind);
LogUtil.write(`发现:识别为网页分享链接,已拦截(${kind})`);
return res;
}
const check = async (url: string, via: string): Promise<boolean> => {
const code: number = await DavDiscovery.probe(url, auth);
if (code !== 207 && code !== 200) {
return false;
}
if (verify !== undefined) {
const good: boolean = await verify(url);
if (!good) {
return false;
}
}
res.ok = true;
res.url = url;
res.via = via;
res.readOnly = (kind === 'public_caldav');
LogUtil.write(`发现成功(${via}):${url}`);
return true;
};
// ① 用户填的地址优先(保持既有行为)
if (await check(base, 'direct')) {
return res;
}
// ①b ⭐ 公开日历(只读订阅):Nextcloud 的 public-calendars 链接本身即端点。
// 此类端点通常无需认证,且只能读——后续由 SyncEngine 的写权限探测自动标记为只读。
if (kind === 'public_caldav') {
// 若用户填的是 public-calendars 根(少了 token),无法继续推断,直接给提示
res.ok = false;
res.hint = '这是「公开日历」链接但似乎不完整。请使用完整的公开日历地址'
+ '(形如 https://你的域名/remote.php/dav/public-calendars/<令牌>/),'
+ '或改用日历的「私有链接」以获得可读可写体验。';
return res;
}
// ② .well-known/caldav(RFC 6764)——标准自动发现
const origin: string = DavDiscovery.originOf(base);
if (origin !== '') {
const wk: string = await DavDiscovery.resolveWellKnown(origin, auth);
if (wk !== '') {
if (await check(wk, 'well-known')) {
return res;
}
// .well-known 给了地址但直接探测不通过 → 也纳入候选由 ③ 继续尝试其下级
LogUtil.write(`.well-known 指向 ${wk},但该地址未通过日历校验,继续尝试常见路径`);
}
}
// ③ 常见候选路径
const cands: string[] = DavDiscovery.candidates(base, username);
for (const c of cands) {
if (await check(c, 'candidate')) {
return res;
}
}
// 全部失败 → 给可操作提示(区分"不是 DAV 端点"与"能连但列不出日历")
res.ok = false;
const cap: DavCapability = await DavDiscovery.optionsCapability(base, auth);
if (cap.status === 405 || (!cap.isDav && cap.allow !== '' && !cap.supportsPropfind)) {
res.hint = '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。'
+ '常见原因是填了网页分享链接、或只填了站点的 DAV 根。'
+ 'Nextcloud 请填「日历 → 设置 → 复制私有链接」得到的地址,例如:'
+ '`https://你的域名/remote.php/dav/calendars/你的用户名/`';
} else if (cap.status === 401) {
res.hint = '服务器拒绝凭据(401),请检查用户名与密码。'
+ 'Nextcloud 若开启了二次验证,需要使用「应用密码」。';
} else if (cap.status === 301 || cap.status === 302) {
res.hint = '服务器发生了跳转,通常说明 `.well-known/caldav` 未正确配置'
+ '(常见于反向代理或子目录部署)。可请 NAS 管理员把 `.well-known/caldav` '
+ '指向 `/remote.php/dav`,或直接填写完整的「日历家目录」地址。';
} else {
res.hint = '已连接上服务器,但在该地址下未发现任何日历本。'
+ '请确认填的是「日历家目录」而不是 DAV 根,例如:'
+ '`https://你的域名/remote.php/dav/calendars/你的用户名/`';
}
return res;
}
/**
* 解析 .well-known/caldav:
* - 直接返回 207/200 → 该 URL 本身即端点
* - 返回 301/302/307/308 → 读 Location 头作为端点
* - 返回 401 时也读 Location(Nextcloud 常在认证后跳转)
*/
static async resolveWellKnown(origin: string, auth: string): Promise<string> {
const url: string = `${origin}/.well-known/caldav`;
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '0',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',
connectTimeout: 8000,
readTimeout: 12000
});
LogUtil.write(`HTTP PROPFIND(.well-known) ${url} → ${resp.responseCode}`);
if (resp.responseCode === 207 || resp.responseCode === 200) {
return url;
}
if (resp.responseCode === 301 || resp.responseCode === 302
|| resp.responseCode === 307 || resp.responseCode === 308 || resp.responseCode === 401) {
const headers = resp.header as Record<string, string>;
if (headers !== undefined && headers !== null) {
const loc = headers['Location'] ?? headers['location'] ?? '';
if (typeof loc === 'string' && loc !== '') {
const full: string = loc.startsWith('http') ? loc : origin + (loc.startsWith('/') ? '' : '/') + loc;
LogUtil.write(`.well-known 跳转到:${full}`);
return DavDiscovery.tidy(full);
}
}
}
return '';
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`.well-known 探测异常: ${e.code} - ${e.message}`);
return '';
} finally {
httpRequest.destroy();
}
}
/** 状态码是否表示"该地址不是 DAV 端点" */
static isNotDav(code: number): boolean {
return NOT_DAV_CODES.indexOf(code) >= 0;
}
// ───────────────────────── 给用户的"人话提示"(两个页面共用) ─────────────────────────
/**
* ⭐ 把 HTTP 状态码翻译成"人话 + 可操作指引"。
* 用户看到"服务器返回 405"无从下手,这里直接告诉他下一步做什么。
*/
static statusHint(code: number): string {
if (code === 405) {
return '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
+ '常见原因:填了「分享链接」(网页地址),或只填到 DAV 根。\n'
+ '请填「日历家目录」,例如:\n'
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
+ 'Nextcloud 可在网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」拿到这个地址。';
}
if (code === 401) {
return '服务器拒绝凭据(401):请检查用户名与密码。\n'
+ '若服务器开启了二次验证(2FA),需要使用「应用密码」而不是登录密码。';
}
if (code === 403) {
return '服务器拒绝访问(403):账号可能无权限,或 IP 被限制。请检查账号权限与服务器访问限制。';
}
if (code === 404) {
return '地址不存在(404):请检查路径是否写错。\n'
+ '正确形式通常为 https://你的域名/remote.php/dav/calendars/你的用户名/';
}
if (code === 301 || code === 302 || code === 307 || code === 308) {
return '服务器发生跳转:通常说明 `.well-known/caldav` 未正确配置(常见于反向代理或子目录部署)。\n'
+ '请直接填写完整的「日历家目录」地址,或请 NAS 管理员配置 `.well-known/caldav`。';
}
if (code === 0) {
return '无法连接服务器:请确认地址、端口与网络(内网地址需在同一局域网)。';
}
return `获取日历列表失败,服务器返回:${code}`;
}
/** ⭐ 能连上但"未发现日历本"时的提示:区分"地址差一层"与"确实是空的" */
static emptyHint(url: string, username: string): string {
if (DavDiscovery.looksIncomplete(url)) {
const cands: string[] = DavDiscovery.candidates(url, username);
const example: string = cands.length > 0 ? cands[0] : '';
return '该地址下没有日历本 —— 多半是地址「少了一层」。\n\n'
+ '你填的是服务器根路径,但 CalDAV 需要填到「日历家目录」。请试试:\n'
+ (example !== '' ? example + '\n\n' : '\n')
+ '(Nextcloud 可在网页版「日历 → 设置 → 复制私有链接」直接拿到)';
}
return '该地址下未发现日历本。请确认:\n'
+ '1) 服务器上确实已创建日历;\n'
+ '2) 填的是「日历家目录」(形如 .../remote.php/dav/calendars/用户名/)而不是 DAV 根。';
}
}
/** OPTIONS 探测结果 */
export class DavCapability {
status: number = 0;
davHeader: string = ''; // `DAV:` 响应头(含 "1", "2", "calendar-access" 等)
allow: string = ''; // `Allow` 头
isDav: boolean = false;
supportsPropfind: boolean = false;
supportsReport: boolean = false;
}
+617 -13
View File
@@ -24,9 +24,30 @@ export class LocalEvent {
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示) kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED) completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生 rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
/** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。
* 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**,
* 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */
dtstart: number = 0;
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔 exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
recurrenceId: number = 0;
/**
* 优先级,用 **iCal 原生值**:1=最高 … 9=最低,0=未设置。
* 直接存原生值,写回时零转换,也不会与服务器上其他客户端写下的值语义不一致。
*/
priority: number = 0;
/** 分类标签,逗号分隔(对应 iCal CATEGORIES,多值用逗号连接) */
categories: string = '';
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒 reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序 reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
/** 父任务 UID(空 = 顶层任务)。对应 VTODO 的 RELATED-TO:RELTYPE=PARENT。
* 用 UID 而非本地 id:父子关系是服务器资源属性,跨设备同步时 UID 才稳定。 */
parentUid: string = '';
/** 附件 JSON 数组字符串:[{name,mime,size,base64}],空 = 无附件。
* base64 直接存库,同步时拼 ATTACH 行零转换。 */
attachments: string = '';
} }
/** 远端事件条目(REPORT 解析结果) */ /** 远端事件条目(REPORT 解析结果) */
@@ -36,6 +57,10 @@ export class RemoteEvent {
title: string = ''; title: string = '';
description: string = ''; description: string = '';
location: string = ''; location: string = '';
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
priority: number = 0;
/** 分类标签,逗号分隔(对应 CATEGORIES) */
categories: string = '';
startTime: number = 0; startTime: number = 0;
endTime: number = 0; endTime: number = 0;
isAllDay: boolean = false; isAllDay: boolean = false;
@@ -43,14 +68,59 @@ export class RemoteEvent {
isTodo: boolean = false; // VTODO 待办 isTodo: boolean = false; // VTODO 待办
completed: boolean = false; // VTODO STATUS:COMPLETED completed: boolean = false; // VTODO STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE rrule: string = ''; // 原始 RRULE
dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开
exdate: string = ''; // 原始 EXDATE(分号分隔) exdate: string = ''; // 原始 EXDATE(分号分隔)
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
reminders: number[] = []; // 全部提醒提前分钟数(多选) reminders: number[] = []; // 全部提醒提前分钟数(多选)
/** 父任务 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 { export class EventDb {
private static db: relationalStore.RdbStore | null = null; private static db: relationalStore.RdbStore | null = null;
/**
* ⭐ 统计"是覆盖实例(RECURRENCE-ID 行)但还没回填 recurrence_id"的行数。
*
* 语义:`recurring=1 且 rrule=''` ⟺ 这一行的 .ics 里有 RECURRENCE-ID。
* 老库升级后这些行的 recurrence_id 一律是 0(字段以前被丢弃,无法从本地反推),
* 必须靠一次全量重拉(GET 每个 .ics)才能填上。
* **用数据本身当标志,而不是内存变量** —— 卡片进程/进程重启会把内存标志吃掉
*(实测 2026-09-19 18:30/18:36 两次同步都没触发全量重拉)。
* 回填完成后返回值自然变 0,不会反复触发。
*/
static async countOverridesMissingRecurrenceId(context: common.Context): Promise<number> {
try {
const store = await EventDb.getDb(context);
const rs = await store.querySql(
"select count(*) c from events " +
"where deleted = 0 and recurring = 1 and rrule = '' and recurrence_id = 0");
let n: number = 0;
try {
if (rs.goToNextRow()) {
n = rs.getLong(rs.getColumnIndex('c'));
}
} finally {
rs.close();
}
return n;
} catch (err) {
return 0;
}
}
static async getDb(context: common.Context): Promise<relationalStore.RdbStore> { static async getDb(context: common.Context): Promise<relationalStore.RdbStore> {
if (EventDb.db !== null) { if (EventDb.db !== null) {
return EventDb.db; return EventDb.db;
@@ -89,6 +159,31 @@ export class EventDb {
} catch (err) { } 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 { try {
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''"); await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
} catch (err) { } catch (err) {
@@ -104,6 +199,12 @@ export class EventDb {
} catch (err) { } catch (err) {
// 列已存在 // 列已存在
} }
// ⭐ 覆盖实例的"原始发生时刻"(RECURRENCE-ID)。老库升级后该列一律为 0,需要一次全量重拉回填
try {
await store.executeSql('ALTER TABLE events ADD COLUMN recurrence_id INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
EventDb.db = store; EventDb.db = store;
return store; return store;
} }
@@ -124,16 +225,32 @@ export class EventDb {
return EventDb.normalizeReminders(list).join(','); return EventDb.normalizeReminders(list).join(',');
} }
/**
* ⭐ 覆盖实例(RECURRENCE-ID 独立行)"排除主事件哪一次发生"用的键。
*
* 必须优先用 **recurrenceId**(原始发生时刻):覆盖行的 startTime 是**被改到的新时间**,
* 用它做键时主事件在**原时间**的那次发生不会被排除 → App 与系统日历里同一日程出现两条
* (2026-09-19 真机实测:窗口内 63 条这种"幽灵日程")。
* recurrenceId = 0 表示旧库数据(还没回填)→ 回退到 startTime,保持老行为不变。
*/
static overrideKey(calKey: string, uid: string, recurrenceId: number, startTime: number): string {
return `${calKey}_${uid}_${recurrenceId > 0 ? recurrenceId : startTime}`;
}
private static fromRow(rs: relationalStore.ResultSet): LocalEvent { private static fromRow(rs: relationalStore.ResultSet): LocalEvent {
const e = new LocalEvent(); const e = new LocalEvent();
e.id = rs.getLong(rs.getColumnIndex('id')); e.id = rs.getLong(rs.getColumnIndex('id'));
e.uid = rs.getString(rs.getColumnIndex('uid')); e.uid = rs.getString(rs.getColumnIndex('uid'));
e.dtstart = rs.getLong(rs.getColumnIndex('dtstart'));
e.calKey = rs.getString(rs.getColumnIndex('cal_key')); e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
e.href = rs.getString(rs.getColumnIndex('href')); e.href = rs.getString(rs.getColumnIndex('href'));
e.remotePath = rs.getString(rs.getColumnIndex('remote_path')); e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
e.title = rs.getString(rs.getColumnIndex('title')); e.title = rs.getString(rs.getColumnIndex('title'));
e.description = rs.getString(rs.getColumnIndex('description')); e.description = rs.getString(rs.getColumnIndex('description'));
e.location = rs.getString(rs.getColumnIndex('location')); 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.startTime = rs.getLong(rs.getColumnIndex('start_time'));
e.endTime = rs.getLong(rs.getColumnIndex('end_time')); e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1; e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
@@ -146,8 +263,13 @@ export class EventDb {
e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1; e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1;
e.rrule = rs.getString(rs.getColumnIndex('rrule')); e.rrule = rs.getString(rs.getColumnIndex('rrule'));
e.exdate = rs.getString(rs.getColumnIndex('exdate')); e.exdate = rs.getString(rs.getColumnIndex('exdate'));
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
e.reminder = rs.getLong(rs.getColumnIndex('reminder')); e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒 // 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
const 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')); const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
if (remStr !== '') { if (remStr !== '') {
e.reminders = remStr.split(',').map((s: string): number => Number(s)) e.reminders = remStr.split(',').map((s: string): number => Number(s))
@@ -178,8 +300,13 @@ export class EventDb {
'completed': e.completed ? 1 : 0, 'completed': e.completed ? 1 : 0,
'rrule': e.rrule, 'rrule': e.rrule,
'exdate': e.exdate, 'exdate': e.exdate,
'recurrence_id': e.recurrenceId,
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0, 'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
'reminders': e.reminders.map((n: number): string => String(n)).join(',') 'reminders': e.reminders.map((n: number): string => String(n)).join(','),
'priority': e.priority,
'categories': e.categories,
'parent_uid': e.parentUid,
'attachments': e.attachments
}; };
return bucket; return bucket;
} }
@@ -281,6 +408,90 @@ export class EventDb {
} }
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */ /** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
/** 保存循环规则与基准(设置/取消循环后调用) */
static async updateTodoRecurrence(context: common.Context, id: number,
rrule: string, dtstart: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'rrule': rrule,
'dtstart': dtstart,
'recurring': rrule !== '' ? 1 : 0,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
/**
* 循环待办"完成一轮"后更新本地:推后的基准/截止 + 清除完成态。
*
* ⚠️ 必须同时把 completed 置 0、dirty 清掉:
* 否则界面会显示成"已完成",而服务器上其实是"下一轮未完成",两边不一致。
*/
static async updateTodoAfterRound(context: common.Context, id: number,
dtstart: number, dueMs: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'dtstart': dtstart,
'start_time': dueMs,
'end_time': dueMs,
'completed': 0,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
/** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */
static async updateTodoFields(context: common.Context, id: number, title: string,
desc: string, dueMs: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'title': title,
'description': desc,
'start_time': dueMs,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
static async setTodoCompleted(context: common.Context, id: number, completed: boolean,
etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'completed': completed ? 1 : 0,
'dirty': 0,
'etag': etag
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
static async queryById(context: common.Context, id: number): Promise<LocalEvent | null> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
const rs = await store.query(predicates);
try {
if (rs.goToNextRow()) {
return EventDb.fromRow(rs);
}
return null;
} finally {
rs.close();
}
}
static async queryTodos(context: common.Context): Promise<LocalEvent[]> { static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context); const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events'); const predicates = new relationalStore.RdbPredicates('events');
@@ -322,6 +533,72 @@ export class EventDb {
} }
/** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */ /** 未来 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[]> { static async queryRemindable(context: common.Context, from: number, to: number): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context); const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events'); const predicates = new relationalStore.RdbPredicates('events');
@@ -370,20 +647,66 @@ export class EventDb {
rs.close(); rs.close();
} }
const remoteKeys: string[] = []; // ⭐ 2226(2026-10-15 提速):下方逐条要判"这条远端事件本地有没有对应行"。
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
// dirty 行不入 `keyed`、同键取**最先**出现的那行。
// ⭐ 但仅排除出 keyed不够(见下方 REVIVE_GUARD):还需单独索引 dirty 行并主动拦截,
// 否则远端数据查不到 key 会走新增分支,反而插出重复行。
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
// ⭐ REVIVE_GUARD dirty 行**单独**建索引,而不是只从 keyed 里排除。
// 只排除不够:远端数据 keyed 查不到 → 走"新增"分支 → 又插一行,
// 本地就出现两行(用户改的 + 远端旧版),表现为"改完又变回去/出现两条"。
const dirtyKeyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
// ⭐ 本地已标记删除、还没推给服务器的 uid 集合。
// 按 uid 而非 key:删重复系列时本地只留主事件标 deleted,
// 但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住。
const dirtyDeletedUids: Set<string> = new Set<string>();
for (const e of existing) {
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
if (e.dirty && e.deleted) {
dirtyDeletedUids.add(e.uid);
}
if (e.dirty) {
if (!dirtyKeyed.has(k)) {
dirtyKeyed.set(k, e);
}
continue;
}
if (!keyed.has(k)) {
keyed.set(k, e);
}
}
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
const seenKeys: Set<string> = new Set<string>();
for (const r of remote) { for (const r of remote) {
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间 // 重复日程实例共享 UID,唯一标识 = uid + 开始时间 + RECURRENCE-ID
const key: string = `${r.uid}_${r.startTime}`; //(同一时刻可能同时存在"主事件"与"覆盖该次发生的实例行",只带上 recurrenceId 才唯一)
remoteKeys.push(key); const key: string = `${r.uid}_${r.startTime}_${r.recurrenceId}`;
const found = existing.find((x: LocalEvent): boolean => if (seenKeys.has(key)) {
!x.dirty && x.uid === r.uid && x.startTime === r.startTime); continue; // 同一资源内的重复条目 → 只保留一条
}
seenKeys.add(key);
// ⭐ REVIVE_GUARD 本地已删待推送 → 远端旧版本一律忽略,防止死灰复燃
if (dirtyDeletedUids.has(r.uid)) {
continue;
}
// ⭐ 本地有未推送的修改 → 保留本地版本,绝不能让远端旧版覆盖或重复插入
if (dirtyKeyed.has(key)) {
continue;
}
const found: LocalEvent | undefined = keyed.get(key);
if (found === undefined) { if (found === undefined) {
// 新增 // 新增
const e = new LocalEvent(); const e = new LocalEvent();
e.uid = r.uid; e.uid = r.uid;
e.calKey = calKey; e.calKey = calKey;
e.href = href; 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.title = r.title;
e.description = r.description; e.description = r.description;
e.location = r.location; e.location = r.location;
@@ -398,14 +721,30 @@ export class EventDb {
e.completed = r.completed; e.completed = r.completed;
e.rrule = r.rrule; e.rrule = r.rrule;
e.exdate = r.exdate; e.exdate = r.exdate;
e.recurrenceId = r.recurrenceId;
e.reminder = r.reminder; e.reminder = r.reminder;
e.reminders = r.reminders; e.reminders = r.reminders;
e.priority = r.priority;
e.categories = r.categories;
e.parentUid = r.parentUid;
e.attachments = r.attachments;
await store.insert('events', EventDb.toBucket(e)); await store.insert('events', EventDb.toBucket(e));
added++; added++;
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate } else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|| found.reminder !== r.reminder || found.recurring !== r.recurring || found.reminder !== r.reminder || found.recurring !== r.recurring
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)) { || EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填) || found.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.title = r.title;
found.description = r.description; found.description = r.description;
found.location = r.location; found.location = r.location;
@@ -420,14 +759,39 @@ export class EventDb {
found.completed = r.completed; found.completed = r.completed;
found.rrule = r.rrule; found.rrule = r.rrule;
found.exdate = r.exdate; found.exdate = r.exdate;
found.recurrenceId = r.recurrenceId;
found.reminder = r.reminder; found.reminder = r.reminder;
found.reminders = r.reminders; found.reminders = r.reminders;
found.priority = r.priority;
found.categories = r.categories;
found.parentUid = r.parentUid;
found.attachments = r.attachments;
const up = new relationalStore.RdbPredicates('events'); const up = new relationalStore.RdbPredicates('events');
up.equalTo('id', found.id); up.equalTo('id', found.id);
await store.update(EventDb.toBucket(found), up); await store.update(EventDb.toBucket(found), up);
updated++; updated++;
} else { } else {
unchanged++; unchanged++;
// ⭐ UNCHANGED_REMOTE_PATH etag 未变是**常态路径**(REPORT 只给 etag,
// 未变就直接复用本地行、不 GET)。实测 742 行里 99% 走这里。
// 若只在"更新分支"回填 remotePath,对存量数据等于无效 ——
// 历史行里存的是旧的 uid 猜测值,而 DELETE/PUT 正是用它拼 URL,
// 拼错就 404(服务器上删不掉)。
// 所以这里必须一并校正 href 与 remotePath。
const hb: relationalStore.ValuesBucket = {};
if (found.href !== href) {
found.href = href;
hb['href'] = href;
}
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
found.remotePath = r.remotePath;
hb['remote_path'] = r.remotePath;
}
if (Object.keys(hb).length > 0) {
const hp = new relationalStore.RdbPredicates('events');
hp.equalTo('id', found.id);
await store.update(hb, hp);
}
} }
} }
// 删除远端已不存在的(排除本地修改未推送的) // 删除远端已不存在的(排除本地修改未推送的)
@@ -435,8 +799,9 @@ export class EventDb {
if (local.dirty) { if (local.dirty) {
continue; continue;
} }
const localKey: string = `${local.uid}_${local.startTime}`; const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
if (!remoteKeys.includes(localKey)) { // ⭐ 同样从 O(m) 的数组 includes 换成 Set 查询(见上方 keyed 的说明)
if (!seenKeys.has(localKey)) {
const del = new relationalStore.RdbPredicates('events'); const del = new relationalStore.RdbPredicates('events');
del.equalTo('id', local.id); del.equalTo('id', local.id);
await store.delete(del); await store.delete(del);
@@ -446,11 +811,72 @@ export class EventDb {
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`; return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
} }
/** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行) */ /**
* 清理"同一资源"的冗余重复行(**纯本地去重,绝不触碰服务器**)。
*
* 起因:旧版 applyRemote 用循环前的一次性 existing 快照判重,同一个 .ics 里若含
* 多条相同 (UID, DTSTART) 的 VEVENT,第二条仍被判为"新增"再插一行 →
* 本地库出现 href / etag / remote_path 完全相同的重复行(2026-09-19 实测 10 组)。
* 影响:① App 里同一日程显示两条;② 镜像到系统日历后同一 identifier 互相覆盖,
* 出现"计划 984 条、系统只写出 983 条"的口径差。
*
* 口径:同 (cal_key, uid, start_time, recurrence_id) 且在**同一资源**(href 非空)下,
* 只保留 id 最小的一行。
* 含本地未推送修改(dirty=1)的行整个不参与,绝不会连带删掉待推送数据。
* 被删行**不置 dirty/deleted**,因此不会向服务器推送任何删除。
* ⚠️ 分组必须带上 recurrence_id:主事件与"覆盖该次发生的实例行"可能同 uid 同 start_time,
* 它们是两条**合法**的不同发生,不能被当成冗余行删掉。
* @returns 删除的冗余行数
*/
static async dedupeDuplicateRows(context: common.Context): Promise<number> {
let removed: number = 0;
try {
const store = await EventDb.getDb(context);
// 只处理"远端资源、已删标记为 0、且没有本地未推送修改(dirty=0)"的行。
// 某个组里只要有一行是 dirty,它就不参与分组 → 组内只剩 1 行 → count(*)>1 不成立 → 不动。
// 这样绝不会把"待推送的本地修改"连带删掉。
const rs = await store.querySql(
"select group_concat(id) ids from events " +
"where deleted = 0 and dirty = 0 and href <> '' " +
"group by cal_key, uid, start_time, recurrence_id having count(*) > 1");
const groups: string[] = [];
try {
while (rs.goToNextRow()) {
const v: string = rs.getString(rs.getColumnIndex('ids'));
if (v !== undefined && v !== null && v !== '') {
groups.push(v);
}
}
} finally {
rs.close();
}
for (const g of groups) {
const ids: number[] = g.split(',')
.map((s: string): number => Number(s.trim()))
.filter((n: number): boolean => !Number.isNaN(n));
ids.sort((a: number, b: number): number => a - b);
for (let i: number = 1; i < ids.length; i++) { // 0 号(最小 id)保留
const del = new relationalStore.RdbPredicates('events');
del.equalTo('id', ids[i]);
await store.delete(del);
removed++;
}
}
} catch (err) {
// 去重失败不影响主流程
return removed;
}
return removed;
}
/** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行)
* ⭐ 固定按 id 升序:镜像/展示遇到"同 uid 多行"时谁先谁后必须稳定,
* 否则同一 identifier 每次换一行 → 系统日历在两处之间反复翻转(实测过)。 */
static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise<LocalEvent[]> { static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context); const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events'); const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind); predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
predicates.orderByAsc('id');
const rs = await store.query(predicates); const rs = await store.query(predicates);
const list: LocalEvent[] = []; const list: LocalEvent[] = [];
try { try {
@@ -482,6 +908,144 @@ export class EventDb {
await store.delete(predicates); await store.delete(predicates);
} }
/**
* ⭐ 取同一个 UID 在同一个日历本下的**全部行**(主事件 + 覆盖实例)。
* 用于"重复日程的单次/后续/全部"编辑与删除:重建整个系列后一次性推送。
* 只返回未删除的行。
*/
static async queryByUid(context: common.Context, calKey: string, uid: string): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid);
predicates.orderByAsc('id');
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
const e = EventDb.fromRow(rs);
if (!e.deleted) {
list.push(e);
}
}
} finally {
rs.close();
}
return list;
}
/** 物理删除某 uid 在某个日历本下的全部行("删除全部"用) */
static async purgeByUid(context: common.Context, calKey: string, uid: string): Promise<number> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid);
return await store.delete(predicates);
}
/** 物理删除某 uid 在某个日历本下、开始时间 >= from 的全部行("此次及以后"删除用) */
static async purgeByUidFrom(context: common.Context, calKey: string, uid: string,
from: number): Promise<number> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid)
.and().greaterThanOrEqualTo('start_time', from);
return await store.delete(predicates);
}
/**
* ⭐ 把某个 uid 系列下、开始时间 >= from 的行搬到另一个 uid("此次及以后"改期的实现):
* 主事件被截断(加 UNTIL)后,剩余部分作为**新的独立系列**存在,否则会被原系列覆盖。
* 返回被搬迁的行数。
*/
static async reassignUidFrom(context: common.Context, calKey: string, oldUid: string,
newUid: string, newPath: string, from: number): Promise<number> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'uid': newUid,
'remote_path': newPath,
'etag': '',
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid)
.and().greaterThanOrEqualTo('start_time', from);
return await store.update(bucket, predicates);
}
/** 给单行写入 EXDATE("仅删除这一次"→ 排除主事件的该次发生) */
static async setExdate(context: common.Context, id: number, exdate: string): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = { 'exdate': exdate, 'dirty': 1 };
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/**
* ⭐ 把某 uid 系列下、**RECURRENCE-ID >= from** 的覆盖实例行搬到新 uid
* ("此次及以后"拆分系列时用;主事件行 recurrenceId=0,天然不会被命中)。
*/
static async reassignOverridesFrom(context: common.Context, calKey: string, oldUid: string,
newUid: string, newPath: string, from: number): Promise<number> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'uid': newUid,
'remote_path': newPath,
'etag': '',
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid)
.and().greaterThanOrEqualTo('recurrence_id', from);
return await store.update(bucket, predicates);
}
/**
* ⭐ 给某个 uid 系列(指定日历本)的主事件写入 RRULE("此次及以后"截断/改期时用)。
* 只作用于有 RRULE 的主事件行。
*/
static async setRruleForUid(context: common.Context, calKey: string, uid: string,
rrule: string): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = { 'rrule': rrule, 'dirty': 1 };
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid)
.and().notEqualTo('rrule', '');
await store.update(bucket, predicates);
}
/** 新增一行(供拆分系列时插入新主事件;已置 dirty) */
static async insertRow(context: common.Context, e: LocalEvent): Promise<number> {
return await EventDb.insertLocal(context, e);
}
/**
* ⭐ 用一份原始快照**还原**某一行的内容(仅内容字段,不动 id)。
* 用途:编辑重复日程的"仅这一次 / 此次及以后"时,`doSave` 会把当前行就地写成新值,
* 但该行属于"原系列"(被 EXDATE 排除或被 UNTIL 截断),必须还原,
* 否则原系列里会残留一条被改过的幽灵行。
*/
static async restoreRowById(context: common.Context, id: number, src: LocalEvent): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'title': src.title,
'description': src.description,
'location': src.location,
'start_time': src.startTime,
'end_time': src.endTime,
'is_all_day': src.isAllDay ? 1 : 0,
'rrule': src.rrule,
'exdate': src.exdate,
'recurrence_id': src.recurrenceId,
'recurring': src.recurring ? 1 : 0,
'reminder': src.reminders.length > 0 ? src.reminders[0] : src.reminder,
'reminders': src.reminders.map((n: number): string => String(n)).join(','),
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/** /**
* 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。 * 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。
* 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。 * 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。
@@ -517,6 +1081,46 @@ export class EventDb {
await store.delete(predicates); await store.delete(predicates);
} }
/**
* 删除某账号下、属于指定日历本集合(按 href 匹配)的全部本地日程/待办。
*
* 用途:日历本在服务器上被删除(同步 REPORT 返回 404)或用户取消勾选时,
* 必须在 calKey 序号迁移**之前**先删掉这些行——否则序号收窄后,被移除日历本的
* 旧行会与新搬到该序号下的其它日历本行混在同一个 calKey 里(2026-09-29 实测:
* 「读书」被服务器删除后重选,其 988 行混进「轻舟」的 calKey)。
* 行上自带 href(日历本集合 URL),按 href 删比按 calKey 删更稳——
* 历史原因(旧版迁移缺陷)行可能挂在错误的 calKey 下,href 才是行的真实归属。
*/
static async pruneByHrefs(context: common.Context, accId: string, hrefs: string[]): Promise<number> {
if (hrefs.length === 0) {
return 0;
}
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.like('cal_key', `${accId}_%`);
predicates.and().in('href', hrefs);
return await store.delete(predicates);
}
/**
* 清理「href 已不属于任何已选日历本」的残留行(跨账号兜底清扫)。
* 典型:服务器删除日历本后,旧版把其残留行混进其它日历本的 calKey(见 pruneByHrefs 注释),
* 这类行 calKey 看似有效、pruneOrphanCalKeys 抓不到,但 href 暴露了真实归属。
* ⚠️ 跳过 dirty 行(用户未推送的本地修改绝不能被清扫掉)与 local 行。
*/
static async pruneStaleHrefs(context: common.Context, validHrefs: string[]): Promise<number> {
if (validHrefs.length === 0) {
return 0;
}
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.notEqualTo('cal_key', 'local');
predicates.and().notEqualTo('href', '');
predicates.and().notIn('href', validHrefs);
predicates.and().equalTo('dirty', 0);
return await store.delete(predicates);
}
/** /**
* 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。 * 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。
* *
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 // entry/src/main/ets/common/LogUtil.ets
// 同步日志:写入应用 filesDir/sync.log(含时间戳),超 512KB 自动截断保留后半。 // 日志工具:默认**只输出到 console(hilog)**,不落盘。
// 设置页「同步日志」卡片可查看/复制/清空,方便真机测试时诊断同步与提醒问题。 //
// ⭐ 0.0.8 起:发行版**不再写 sync.log**(原「同步记录」/诊断落盘功能已移除)。
// 需要真机排查同步/提醒问题时,把下面的 ENABLE_FILE_LOG 改成 true 重新编译即可恢复
// 落盘(含 512KB 自动截断保留后半),其余调用点一行都不用动。
// 关闭状态下会在 init 时顺手删掉历史版本遗留的 sync.log 与 mirror_diag.txt,
// 保证升级上来的设备沙箱也是干净的。
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { fileIo as fs } from '@kit.CoreFileKit'; import { fileIo as fs } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS'; import { util } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
export class LogUtil { export class LogUtil {
/** ⭐ 落盘开关:发行版置 false(仅 console);真机排查时改 true 恢复文件日志 */
private static readonly ENABLE_FILE_LOG: boolean = false;
private static dir: string = '';
private static path: string = ''; private static path: string = '';
private static readonly MAX_BYTES: number = 512 * 1024; private static readonly MAX_BYTES: number = 512 * 1024;
static init(context: common.Context): void { static init(context: common.Context): void {
try { try {
LogUtil.path = `${context.filesDir}/sync.log`; LogUtil.dir = context.filesDir;
LogUtil.path = `${LogUtil.dir}/sync.log`;
} catch (err) { } catch (err) {
LogUtil.dir = '';
LogUtil.path = ''; LogUtil.path = '';
} }
if (!LogUtil.ENABLE_FILE_LOG && LogUtil.dir !== '') {
LogUtil.removeLegacyDebugFiles();
}
}
/** 删除历史版本遗留的调试落盘文件(sync.log / mirror_diag.txt) */
private static removeLegacyDebugFiles(): void {
for (const name of ['sync.log', 'mirror_diag.txt']) {
try {
fs.unlinkSync(`${LogUtil.dir}/${name}`);
} catch (err) {
// 文件不存在属正常,忽略
}
}
} }
static logFilePath(): string { static logFilePath(): string {
return LogUtil.path; return LogUtil.path;
} }
/** 写一行日志:console + 文件(文件失败静默,不影响业务) */ /** 写一行日志:console + 文件(发行版不落盘;文件失败静默,不影响业务) */
static write(msg: string): void { static write(msg: string): void {
console.info(`SyncLog ${msg}`); console.info(`SyncLog ${msg}`);
if (!LogUtil.ENABLE_FILE_LOG) {
return;
}
if (LogUtil.path === '') { if (LogUtil.path === '') {
return; return;
} }
+346
View File
@@ -0,0 +1,346 @@
// entry/src/main/ets/common/MirrorInbound.ets
// 入站对账:把"用户在系统日历里做的改动"读回来,写回本地库并置 dirty,随下一次同步推送到 CalDAV。
//
// 这是「系统日历 ↔ 同步日历 App ↔ CalDAV 服务器」闭环的**回程**。
// 出站(CalDAV → 系统日历)在 SystemCalendarMirror,本文件负责反向。
//
// ⚠️ 防回灌是这里唯一的核心难题:系统日历里既有"我们写进去的",也有"用户改的/新建的/删的",
// 靠 `MirrorSnapshot`(上次写入值的内容指纹)区分:
//
// | 系统日历当前状态 | 判定 | 动作 |
// |-----------------------------------|------------------|---------------------------|
// | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 |
// | 有 identifier,指纹 != 快照 | 用户改了 | 写回本地库 → 置 dirty 推送 |
// | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) |
// | 快照有,系统里没了 | 用户删了 | markDeleted → 推送 DELETE |
// | identifier 不是我们的格式 | 用户在系统日历新建 | 新建 LocalEvent → 推送 |
//
// ⚠️ "没有快照就补快照后跳过"这条非常关键:首次启用回写时快照是空的,
// 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。
import { common } from '@kit.AbilityKit';
import { calendarManager } from '@kit.CalendarKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { EventDb, LocalEvent } from './EventDb';
import { LogUtil } from './LogUtil';
import { AppSettings } from './AppSettings';
import { MirrorSnapshot, MirrorSnapshotRow } from './MirrorSnapshot';
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
/** 一次入站对账的统计 */
export class InboundResult {
created: number = 0; // 系统日历新建 → 加进 CalDAV
updated: number = 0; // 系统日历修改 → 回写 CalDAV
deleted: number = 0; // 系统日历删除 → 从 CalDAV 删
skipped: number = 0;
failed: number = 0;
}
export class MirrorInbound {
private static readonly DAY: number = 86400000;
private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [
'id', 'type', 'title', 'startTime', 'endTime', 'isAllDay',
'description', 'location', 'identifier', 'reminderTime', 'recurrenceRule'
];
/**
* 入站对账主入口。
* ⚠️ 必须在 `SyncEngine.syncAccount()` **之前**调用 —— 它产生的 dirty 行要借本次同步一起推送。
*/
static async reconcile(context: common.Context): Promise<InboundResult> {
const res: InboundResult = new InboundResult();
try {
const on: boolean = await AppSettings.getMirrorInbound(context);
if (!on) {
return res;
}
if (!await SystemCalendarMirror.hasPermission()) {
return res;
}
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
// ⭐ 与出站镜像用**同一套**稳定引用(bookId 由服务器 href 派生),否则两边对不上号
const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context);
for (const ref of refs) {
try {
await MirrorInbound.reconcileBook(context, cm, ref, res);
} catch (err) {
const e = err as BusinessError;
res.failed++;
LogUtil.write(`入站对账失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
}
}
if (res.created + res.updated + res.deleted > 0) {
LogUtil.write(
`入站对账:新建=${res.created} 修改=${res.updated} 删除=${res.deleted} 跳过=${res.skipped}`);
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`入站对账异常: ${e.message}`);
}
return res;
}
/* ==================== 单个日历本 ==================== */
/**
* 取回该账户下已有日程。
*
* 🔴 2026-10-15 实测(DEL-AL20 / 7.0.0.109):`getEvents(undefined, 字段数组)` 在这个
* 系统版本上**必抛** `401 type error!`。而本文件原先正是这么调的 ⇒ "入站对账"对
* **每一个**日历本都失败(真机日志 12/12 全是 `401 type error!`),
* 整个"把系统日历里的改动回写 CalDAV"等于死的(还白费 12 次调用)。
* 出站镜像(SystemCalendarMirror.queryEvents)早就踩过同一个坑,改成"先无参、失败再带字段"。
* 这里沿用同一策略:无参调用返回的 Event 里 identifier 同样有值。
*/
private static async queryEvents(cal: calendarManager.Calendar): Promise<calendarManager.Event[]> {
try {
return await cal.getEvents();
} catch (err) {
LogUtil.write(`入站对账 getEvents() 失败,退回带字段:${(err as BusinessError).message}`);
return await cal.getEvents(undefined, MirrorInbound.QUERY_FIELDS);
}
}
private static async reconcileBook(
context: common.Context,
cm: calendarManager.CalendarManager,
ref: BookRef,
res: InboundResult
): Promise<void> {
const calKey: string = ref.calKey; // 仅用于查本地库
const bucket: string = ref.bookId; // ⭐ 系统日历侧的一切标识都用 bookId
const accountName: string = ref.sysAccountName;
let cal: calendarManager.Calendar | undefined = undefined;
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const c of calendars) {
if (c.getAccount().name === accountName) {
cal = c;
break;
}
}
if (cal === undefined) {
return; // 还没镜像过,无从对账
}
const href: string = ref.href;
if (href === '') {
LogUtil.write(`入站对账跳过 bookId=${bucket}:找不到对应的日历本集合 URL`);
return;
}
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
const events: calendarManager.Event[] = await MirrorInbound.queryEvents(cal);
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
const others: calendarManager.Event[] = [];
for (const ev of events) {
const id: string = ev.identifier ?? '';
if (id !== '' && id.startsWith(`${bucket}|`)) {
mine.set(id, ev);
} else {
others.push(ev);
}
}
// 本地库现有事件:uid -> LocalEvent
const locals: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
const byUid: Map<string, LocalEvent> = new Map<string, LocalEvent>();
for (const e of locals) {
if (e.uid !== '') {
byUid.set(e.uid, e);
}
}
// ① 修改
for (const id of mine.keys()) {
const ev: calendarManager.Event | undefined = mine.get(id);
if (ev === undefined) {
continue;
}
const fp: string = MirrorSnapshot.fingerprintOf(ev);
const snap: MirrorSnapshotRow | undefined = snaps.get(id);
if (snap === undefined) {
// ⚠️ 没有快照:认作是我们自己写的,补一份后跳过 —— 绝不误判成"用户改了"
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
res.skipped++;
continue;
}
snaps.delete(id); // 还活着,稍后剩下的才是"被删了"
if (snap.fingerprint === fp) {
res.skipped++; // 没动
continue;
}
// ⚠️ 重复日程的**某一次发生**(identifier 形如 `<bookId>|<uid>@<发生时间戳>`):
// 它是我们从一条重复日程展开出来的,不是独立事件 —— 改它没法映射回服务器上的
// 那一条 VEVENT,直接跳过,避免把服务器数据搞乱。
if (MirrorInbound.isOccurrence(id)) {
res.skipped++;
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
continue;
}
const uid: string = MirrorSnapshot.uidOf(id);
const local: LocalEvent | undefined = byUid.get(uid);
if (local === undefined) {
res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站镜像处理
continue;
}
if (local.dirty || local.deleted) {
res.skipped++; // 本地本来就有待推送的改动 → 不拿系统值覆盖,避免两边打架
continue;
}
MirrorInbound.applyToLocal(local, ev);
await EventDb.updateLocal(context, local);
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
res.updated++;
LogUtil.write(`入站修改回写:${id}(${local.title})`);
}
// ② 删除:快照里剩下的(系统日历里已经没有了)
for (const id of snaps.keys()) {
const snap: MirrorSnapshotRow | undefined = snaps.get(id);
if (snap === undefined) {
continue;
}
// 同上:展开出来的"某一次发生"消失(多半是滑出镜像窗口),不能当成用户删除
if (MirrorInbound.isOccurrence(id)) {
res.skipped++;
await MirrorSnapshot.remove(context, id);
continue;
}
const local: LocalEvent | undefined = byUid.get(snap.uid);
if (local !== undefined && !local.deleted) {
await EventDb.markDeleted(context, local.id);
res.deleted++;
LogUtil.write(`入站删除回写:${id}(${local.title})`);
} else {
res.skipped++;
}
await MirrorSnapshot.remove(context, id);
}
// ③ 新建:系统日历里 identifier 不是我们格式的条目(用户在系统日历里手动加的)
for (const ev of others) {
if (ev.id === undefined) {
continue;
}
const uid: string = `sc-${bucket}-${ev.id}`;
if (byUid.has(uid)) {
continue;
}
// ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。
// 否则下一次出站镜像会把同一条再加一遍 → 系统日历里出现重复。
const identifier: string = `${bucket}|${uid}`;
const stamped: calendarManager.Event = ev;
stamped.identifier = identifier;
try {
await cal.updateEvent(stamped);
} catch (err) {
const e = err as BusinessError;
res.skipped++;
LogUtil.write(`入站新建跳过:无法写回 identifier(${e.message}),避免重复`);
continue;
}
const e: LocalEvent = new LocalEvent();
e.uid = uid;
e.calKey = calKey;
e.href = href;
e.remotePath = encodeURIComponent(uid) + '.ics';
e.kind = 'event';
MirrorInbound.applyToLocal(e, ev);
await EventDb.insertLocal(context, e);
await MirrorSnapshot.put(context, MirrorInbound.rowOf(
bucket, identifier, ev, MirrorSnapshot.fingerprintOf(ev)));
res.created++;
LogUtil.write(`入站新建回写:${identifier}(${e.title})`);
}
}
/* ==================== 辅助 ==================== */
/**
* 是不是"重复系列里的某一次发生"?identifier 形如 `<bookId>|<uid>@<发生时间戳>`。
*
* ⚠️ 不能用 `includes('@')` 判断:**uid 本身就常含 @**(如 `20260906T163329-d13e0cb6@172.17.0.1`),
* 会误判。只有"最后一段是纯数字"才是我们加的发生时间戳。
*/
private static isOccurrence(id: string): boolean {
const pos: number = id.lastIndexOf('@');
if (pos < 0) {
return false;
}
const tail: string = id.substring(pos + 1);
if (tail === '') {
return false;
}
for (let i: number = 0; i < tail.length; i++) {
const c: string = tail[i];
if (c < '0' || c > '9') {
return false;
}
}
return true;
}
private static rowOf(
bucket: string,
identifier: string,
ev: calendarManager.Event,
fp: string
): MirrorSnapshotRow {
const row: MirrorSnapshotRow = new MirrorSnapshotRow();
row.identifier = identifier;
row.calKey = bucket;
row.uid = MirrorSnapshot.uidOf(identifier);
row.sysId = ev.id ?? -1;
row.fingerprint = fp;
return row;
}
/** 系统日历 Event → 本工程 LocalEvent(只覆盖可映射字段) */
private static applyToLocal(e: LocalEvent, ev: calendarManager.Event): void {
e.title = ev.title ?? '';
e.description = ev.description ?? '';
e.location = ev.location?.location ?? '';
e.startTime = ev.startTime;
e.isAllDay = ev.isAllDay === true;
// 系统全天 endTime 可能是"次日 00:00",也可能是"当日 23:59:59.999"
// → 统一成本工程约定:"排他结束日前一毫秒"
e.endTime = e.isAllDay
? MirrorInbound.dayStart(ev.endTime - 1) + MirrorInbound.DAY - 1
: ev.endTime;
e.rrule = MirrorInbound.rruleOf(ev);
e.recurring = e.rrule !== '';
if (ev.reminderTime !== undefined && ev.reminderTime.length > 0) {
e.reminders = [...ev.reminderTime];
e.reminder = ev.reminderTime[0];
}
}
private static dayStart(ts: number): number {
const d: Date = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** 系统 RecurrenceRule → RRULE 字符串(仅 FREQ/INTERVAL;复杂规则以服务器端为准) */
private static rruleOf(ev: calendarManager.Event): string {
try {
const rr = ev.recurrenceRule;
if (rr === undefined) {
return '';
}
const freqMap: string[] = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'];
const fi: number = rr.recurrenceFrequency as number;
if (Number.isNaN(fi) || fi < 0 || fi >= freqMap.length) {
return '';
}
let rrule: string = `FREQ=${freqMap[fi]}`;
if (rr.interval !== undefined && rr.interval > 1) {
rrule += `;INTERVAL=${rr.interval}`;
}
return rrule;
} catch (err) {
return '';
}
}
}
+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,241 @@
// entry/src/main/ets/common/MirrorSnapshot.ets
// 镜像快照:记录"上一次我们把什么写进了系统日历"。
//
// 为什么必须有它(防回灌 / 防死循环):
// 流程是 CalDAV → 本地库 → 写进系统日历。下一轮同步如果直接读系统日历当"用户改动",
// 就会把我们刚写进去的当成用户改的 → 回写服务器 → 服务器变了 → 又镜像回系统日历 …… 死循环。
// 有了快照就能三方比对:
// · 系统当前值 == 快照 → 没动,是我们自己写的,跳过
// · 系统当前值 != 快照 → 用户在系统日历改了 → 回写 CalDAV
// · 快照有、系统里没了 → 用户在系统日历删了 → 从 CalDAV 删
// · 系统里有、快照没有且 identifier 不是我们的格式 → 用户在系统日历新建 → 加进 CalDAV
//
// ⚠️ 只存"状态指纹",**不存日程内容**(内容仍在系统日历与本地库各一份,这是阶段 2 的取舍)。
// 日程内容只有一份的形态(阶段 3)需要配合"影子表 + 系统日历为内容源",那是另一件事。
import { common } from '@kit.AbilityKit';
import { relationalStore } from '@kit.ArkData';
import { calendarManager } from '@kit.CalendarKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { LogUtil } from './LogUtil';
/**
* 快照行。
* ⚠️ `calKey` 这一列实际存的是**镜像桶标识 = bookId**(由服务器 href 派生的稳定标识),
* 与系统日历侧 identifier 前缀、账户名后缀完全一致。列名沿用未改,别再传会变的 calKey。
*/
export class MirrorSnapshotRow {
identifier: string = ''; // `<bookId>|<uid>`
calKey: string = '';
uid: string = '';
sysId: number = -1; // 系统日历里的 event id(删除时用)
fingerprint: string = ''; // 写入值的内容指纹
}
export class MirrorSnapshot {
private static readonly TABLE: string = 'mirror_snapshot';
private static ready: boolean = false;
/* ==================== 表 ==================== */
private static async db(context: common.Context): Promise<relationalStore.RdbStore> {
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(context, {
name: 'sync_calendar.db',
securityLevel: relationalStore.SecurityLevel.S1
});
if (!MirrorSnapshot.ready) {
await store.executeSql(
'CREATE TABLE IF NOT EXISTS mirror_snapshot (' +
'identifier TEXT PRIMARY KEY, cal_key TEXT, uid TEXT, ' +
'sys_id INTEGER, fingerprint TEXT, updated_at INTEGER)'
);
MirrorSnapshot.ready = true;
}
return store;
}
/* ==================== 读写 ==================== */
/** 取某个日历本的全部快照,key = identifier。**bucket 必须是 bookId** */
static async loadMap(context: common.Context, bucket: string): Promise<Map<string, MirrorSnapshotRow>> {
const out: Map<string, MirrorSnapshotRow> = new Map<string, MirrorSnapshotRow>();
try {
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
predicates.equalTo('cal_key', bucket);
const rs = await store.query(predicates);
try {
while (rs.goToNextRow()) {
const row: MirrorSnapshotRow = new MirrorSnapshotRow();
row.identifier = rs.getString(rs.getColumnIndex('identifier'));
row.calKey = bucket;
row.uid = rs.getString(rs.getColumnIndex('uid'));
row.sysId = rs.getLong(rs.getColumnIndex('sys_id'));
row.fingerprint = rs.getString(rs.getColumnIndex('fingerprint'));
out.set(row.identifier, row);
}
} finally {
rs.close();
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`读取镜像快照失败 bucket=${bucket}: ${e.message}`);
}
return out;
}
/**
* **整体重建**某个日历本的快照:先删光旧行,再写入新行。
* 为什么不用增量:出站镜像后直接把系统日历里该账户的实际内容重新读一遍再落快照最稳,
* 不用在写入过程中追踪 addEvent 返回的 id(批量 addEvents 还不返回 id)。
*/
static async replaceAll(
context: common.Context,
bucket: string,
events: calendarManager.Event[]
): Promise<number> {
let n: number = 0;
try {
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
const del = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
del.equalTo('cal_key', bucket);
await store.delete(del);
const now: number = Date.now();
for (const ev of events) {
const id: string = ev.identifier ?? '';
if (id === '') {
continue; // 用户在系统日历自己加的、没有我们 identifier 的 → 不进快照(由入站对账新建)
}
const vb: relationalStore.ValuesBucket = {
'identifier': id,
'cal_key': bucket,
'uid': MirrorSnapshot.uidOf(id),
'sys_id': ev.id ?? -1,
'fingerprint': MirrorSnapshot.fingerprintOf(ev),
'updated_at': now
};
await store.insert(MirrorSnapshot.TABLE, vb);
n++;
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`重建镜像快照失败 bucket=${bucket}: ${e.message}`);
}
return n;
}
/** 写入/更新单条快照(入站回写成功后同步指纹,避免下次重复判定为"用户改了") */
static async put(context: common.Context, row: MirrorSnapshotRow): Promise<void> {
try {
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
const bucket: relationalStore.ValuesBucket = {
'identifier': row.identifier,
'cal_key': row.calKey,
'uid': row.uid,
'sys_id': row.sysId,
'fingerprint': row.fingerprint,
'updated_at': Date.now()
};
await store.insert(MirrorSnapshot.TABLE, bucket);
} catch (err) {
// 主键冲突时改成 update
try {
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
const bucket: relationalStore.ValuesBucket = {
'sys_id': row.sysId,
'fingerprint': row.fingerprint,
'updated_at': Date.now()
};
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
predicates.equalTo('identifier', row.identifier);
await store.update(bucket, predicates);
} catch (err2) {
const e = err2 as BusinessError;
LogUtil.write(`写入镜像快照失败 ${row.identifier}: ${e.message}`);
}
}
}
/** 删除单条(用户删了 / 本地已不存在) */
static async remove(context: common.Context, identifier: string): Promise<void> {
try {
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
predicates.equalTo('identifier', identifier);
await store.delete(predicates);
} catch (err) {
// 忽略
}
}
/** 删除整个日历本的快照(取消勾选 / 关闭镜像时调用)。bucket = bookId */
static async removeByCalKey(context: common.Context, bucket: string): Promise<void> {
try {
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
predicates.equalTo('cal_key', bucket);
await store.delete(predicates);
} catch (err) {
// 忽略
}
}
/** 清空全部快照(关闭镜像总开关时用,顺带清掉历史遗留的 calKey 键) */
static async clearAll(context: common.Context): Promise<void> {
try {
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
await store.delete(predicates);
} catch (err) {
// 忽略
}
}
/* ==================== 指纹 ==================== */
/**
* 事件内容指纹。用于判断"系统日历里这条,是不是还是我们上次写进去的样子"。
* ⚠️ 全天日程的 endTime 会被系统归一化(我们写次日 00:00,可能读回 23:59:59.999),
* 所以全天一律折成"日"再算指纹,否则每次都判定成"用户改了"。
*/
static fingerprintOf(ev: calendarManager.Event): string {
const allDay: boolean = ev.isAllDay === true;
let start: number = ev.startTime;
let end: number = ev.endTime;
if (allDay) {
start = MirrorSnapshot.dayStart(start);
end = MirrorSnapshot.dayStart(end - 1);
}
const raw: string = [
ev.title ?? '',
`${start}`,
`${end}`,
allDay ? '1' : '0',
ev.location?.location ?? '',
ev.description ?? '',
MirrorSnapshot.hash(ev.recurrenceRule !== undefined ? JSON.stringify(ev.recurrenceRule) : '')
].join('\u0001');
return `${MirrorSnapshot.hash(raw)}:${MirrorSnapshot.hash(raw, 5381)}`;
}
/** identifier (`<calKey>|<uid>`) → uid */
static uidOf(identifier: string): string {
const pos: number = identifier.indexOf('|');
return pos < 0 ? '' : identifier.substring(pos + 1);
}
private static dayStart(ts: number): number {
const d: Date = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** 简单字符串哈希(djb2 变体)。两个不同种子各算一次拼起来,降低碰撞概率 */
private static hash(s: string, seed?: number): string {
let h: number = seed === undefined ? 0 : seed;
for (let i: number = 0; i < s.length; i++) {
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(36);
}
}
@@ -0,0 +1,80 @@
// entry/src/main/ets/common/NetworkSyncWatcher.ets
// ⭐ 网络恢复补同步:从"无网"变为"有网"时立刻同步一次。
//
// 为什么这条最有价值(《定时同步方案调研.md》第 ④ 层):
// 延迟任务最短 2 小时、还会随应用活跃度降级,长时间在外(飞行模式/地铁/弱网)期间
// 基本不会跑。用户一旦回到有网环境,**立刻对齐一次**,才能做到
// "我要看的时候数据是最新的"。这比追求"进程常驻"实际得多。
//
// 实现:net.connection 的 netAvailable / netLost 事件 + 去抖,避免网络抖动时连打多次。
import { connection } from '@kit.NetworkKit';
import { common } from '@kit.AbilityKit';
import { BackgroundSyncRunner } from './BackgroundSyncRunner';
import { LogUtil } from './LogUtil';
/** 网络恢复后的去抖间隔:避免弱网下反复"断开→恢复"导致连续同步 */
const DEBOUNCE_MS: number = 20 * 1000;
export class NetworkSyncWatcher {
private static netConn: connection.NetConnection | null = null;
/** 上一次触发同步的时间,用于去抖 */
private static lastTriggerAt: number = 0;
/** 当前是否处于"无网"状态(只有从无网恢复才触发,避免频繁切换网络时误触发) */
private static offline: boolean = false;
/** 开始监听网络变化(幂等:重复调用不会重复注册) */
static start(context: common.UIAbilityContext): void {
if (NetworkSyncWatcher.netConn !== null) {
return;
}
try {
const conn: connection.NetConnection = connection.createNetConnection();
conn.on('netLost', (): void => {
NetworkSyncWatcher.offline = true;
LogUtil.write('网络断开:标记为无网状态');
});
conn.on('netAvailable', (): void => {
// ⭐ 只有"从无网恢复"才补同步:单纯切换 Wi-Fi/蜂窝时不重复触发
if (!NetworkSyncWatcher.offline) {
return;
}
NetworkSyncWatcher.offline = false;
NetworkSyncWatcher.triggerDebounced(context, 'netRecover');
});
conn.register((): void => {
LogUtil.write('网络监听已注册(网络恢复补同步)');
});
NetworkSyncWatcher.netConn = conn;
} catch (err) {
LogUtil.write(`网络监听注册失败:${(err as Error).message}`);
}
}
/** 停止监听(一般不需要,App 存活期间持续有效) */
static stop(): void {
if (NetworkSyncWatcher.netConn === null) {
return;
}
try {
NetworkSyncWatcher.netConn.unregister((err: object): void => {
// 忽略取消失败
});
} catch (err) {
// 忽略
}
NetworkSyncWatcher.netConn = null;
}
/** 去抖后触发一次同步 */
private static triggerDebounced(context: common.UIAbilityContext, tag: string): void {
const now: number = Date.now();
if (now - NetworkSyncWatcher.lastTriggerAt < DEBOUNCE_MS) {
LogUtil.write(`网络恢复补同步:距上次过快(<20s),跳过`);
return;
}
NetworkSyncWatcher.lastTriggerAt = now;
LogUtil.write('网络已恢复 → 触发补同步');
// 不 await:事件回调里不能阻塞;BackgroundSyncRunner 内部全 try-catch,不会抛出
BackgroundSyncRunner.runOnce(context, tag);
}
}
+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 { EventDb, LocalEvent } from './EventDb';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { AccountStore } from './AccountStore'; import { AccountStore } from './AccountStore';
import { SystemCalendarMirror } from './SystemCalendarMirror';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
export class ReminderService { export class ReminderService {
@@ -26,6 +27,39 @@ export class ReminderService {
private static calNameCache: Map<string, string> = new Map(); private static calNameCache: Map<string, string> = new Map();
private static calNameLoadedAt: number = 0; private static calNameLoadedAt: number = 0;
/** 已镜像到系统日历的 calKey 集合缓存(避免重复排提醒),30 秒刷新一次 */
private static mirroredCalKeys: Set<string> = new Set();
private static mirroredLoadedAt: number = 0;
/**
* ⭐⭐ 取"已镜像到系统日历"的日历本 calKey 集合(0.0.8 修复双通道重复提醒)。
*
* 镜像到系统日历后,**系统日历自己会提醒**(条目的 reminderTime);
* 若 App 再为同一个本排代理提醒 → 同一条日程提醒两次。
* 因此这里返回"该由系统日历负责提醒"的本,App 侧**完全不再提醒**它们。
*
* ⚠️ 判据是"**镜像账户真实存在**"(`SystemCalendarMirror.mirroredCalKeys`),
* 而不是"用户勾选了要镜像"—— 后者只是意图,本可能还没被真正写进系统日历;
* 按意图跳过会导致"两边都不提醒"(漏提醒)。
* ⚠️ 返回空集 = 不跳过任何本(保守:宁可多提醒,不可漏提醒)。
*/
private static async mirroredKeys(context: common.Context): Promise<Set<string>> {
if (Date.now() - ReminderService.mirroredLoadedAt < 30000
&& ReminderService.mirroredCalKeys.size > 0) {
return ReminderService.mirroredCalKeys;
}
const out: Set<string> = await SystemCalendarMirror.mirroredCalKeys(context);
ReminderService.mirroredCalKeys = out;
ReminderService.mirroredLoadedAt = Date.now();
return out;
}
/** 镜像开关变化后调用,让缓存立即失效(如用户关闭镜像 → 立刻恢复 App 提醒) */
static invalidateMirrorCache(): void {
ReminderService.mirroredLoadedAt = 0;
ReminderService.mirroredCalKeys = new Set();
}
/** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */ /** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */
private static async calName(context: common.Context, calKey: string): Promise<string> { private static async calName(context: common.Context, calKey: string): Promise<string> {
if (Date.now() - ReminderService.calNameLoadedAt > 60000 if (Date.now() - ReminderService.calNameLoadedAt > 60000
@@ -127,14 +161,27 @@ export class ReminderService {
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`); LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
} }
const now: number = Date.now(); const now: number = Date.now();
const rows: LocalEvent[] = // ⭐ #38 日程 + 待办一起排提醒。两类分别查(过滤条件不同:
// 待办要求 completed=0,日程无此概念),合并后统一处理。
const eventRows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); 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); const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
if (mirrored.size > 0) {
LogUtil.write(`提醒跳过 ${mirrored.size} 个已镜像到系统日历的日历本(交由系统日历提醒)`);
}
for (const e of rows) { for (const e of rows) {
if (muted.includes(e.calKey)) { if (muted.includes(e.calKey)) {
continue; continue;
} }
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复
}
if (published >= ReminderService.MAX_REMINDERS) { if (published >= ReminderService.MAX_REMINDERS) {
break; break;
} }
@@ -151,6 +198,8 @@ export class ReminderService {
} }
const d = new Date(remindAt); const d = new Date(remindAt);
const calName: string = await ReminderService.calName(context, e.calKey); const calName: string = await ReminderService.calName(context, e.calKey);
// #38 文案按类型区分:待办是"到期",日程是"开始"
const isTodo: boolean = e.kind === 'todo';
const request: reminderAgentManager.ReminderRequestCalendar = { const request: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR, reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: { dateTime: {
@@ -161,10 +210,14 @@ export class ReminderService {
minute: d.getMinutes(), minute: d.getMinutes(),
second: 0 second: 0
}, },
title: '日程提醒', title: isTodo ? '待办提醒' : '日程提醒',
content: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`, content: isTodo
? `「${calName}」中的待办「${e.title}」将在 ${rem} 分钟后到期`
: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
// ⚠️ 通知 id 必须唯一:日程与待办的 id 来自同一张表(自增主键),
// 天然不冲突,所以这里不用额外加偏移,否则会与 rem 的乘加混淆。
notificationId: (e.id * 10 + rem) % 100000, notificationId: (e.id * 10 + rem) % 100000,
expiredContent: '该日程已开始', expiredContent: isTodo ? '该待办已到期' : '该日程已开始',
ringDuration: 5, ringDuration: 5,
snoozeTimes: 0, snoozeTimes: 0,
timeInterval: 0 timeInterval: 0
@@ -211,11 +264,16 @@ export class ReminderService {
const rows: LocalEvent[] = const rows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
const muted: string[] = await AppSettings.getMutedReminderKeys(context); const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
let fired: number = 0; let fired: number = 0;
for (const e of rows) { for (const e of rows) {
if (muted.includes(e.calKey)) { if (muted.includes(e.calKey)) {
continue; continue;
} }
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 交由系统日历提醒
}
const rems: number[] = e.reminders.length > 0 ? e.reminders const rems: number[] = e.reminders.length > 0 ? e.reminders
: (e.reminder > 0 ? [e.reminder] : []); : (e.reminder > 0 ? [e.reminder] : []);
for (const rem of rems) { for (const rem of rems) {
+436 -25
View File
@@ -2,12 +2,29 @@
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量) // 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount } from './AccountStore'; import { DavAccount, AccountStore } from './AccountStore';
import { EventDb, LocalEvent, RemoteEvent } from './EventDb'; import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
import { IcsUtil } from './IcsUtil'; import { IcsUtil } from './IcsUtil';
import { DavClient, DavColorEntry, RemoteItem } from './DavClient'; import { DavClient, DavColorEntry, DavHttpError, RemoteItem } from './DavClient';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { SystemCalendarMirror } from './SystemCalendarMirror';
/**
* ⭐ "已有同步任务正在进行"专用错误类型。
*
* 背景(用户反馈):后台同步进行中时,用户再点「手动同步」或在账号管理点「保存并同步」,
* 会并发触发第二轮同步;互斥锁把它拦下来,但那时抛的是普通 Error → 界面统一显示
* "同步失败:该账号正在同步中,请稍后再试"。用户看到"失败"会误以为出了问题。
*
* 正确做法:并发被拦下时**不是失败**,而应提示"有同步任务正在进行中,请耐心等待"。
* 因此用这个独立类型与真正的同步故障区分开,界面据此改提示语(而非报错)。
*/
export class SyncBusyError extends Error {
constructor() {
super('SYNC_BUSY');
}
}
export class SyncEngine { export class SyncEngine {
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */ /** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
@@ -15,6 +32,27 @@ export class SyncEngine {
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */ /** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000; private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000;
/** 某账号当前是否正在同步(供界面判断,不触发任何操作) */
static isSyncing(accId: string): boolean {
const heldSince: number | undefined = SyncEngine.activeSyncs.get(accId);
if (heldSince === undefined) {
return false;
}
return Date.now() - heldSince < SyncEngine.LOCK_STALE_MS;
}
/** 当前是否有任意账号正在同步 */
static isAnySyncing(): boolean {
const now: number = Date.now();
for (const id of SyncEngine.activeSyncs.keys()) {
const since: number | undefined = SyncEngine.activeSyncs.get(id);
if (since !== undefined && now - since < SyncEngine.LOCK_STALE_MS) {
return true;
}
}
return false;
}
/** /**
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈 * 给异步操作加超时保护,防止网络挂起导致界面一直转圈
*/ */
@@ -37,7 +75,8 @@ export class SyncEngine {
const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id); const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id);
if (heldSince !== undefined) { if (heldSince !== undefined) {
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) { if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
throw new Error('该账号正在同步中,请稍后再试'); // ⭐ 抛专用类型:界面据此提示"同步进行中,请耐心等待"而非"失败"
throw new SyncBusyError();
} }
LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`); LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`);
} }
@@ -46,6 +85,18 @@ export class SyncEngine {
// 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位 // 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位
LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`); LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
try { try {
// ⭐ 库结构升级(recurrence_id 列):老库里覆盖实例的该列全是 0,而被丢弃的
// RECURRENCE-ID 无法从本地任何字段反推 → 必须靠一次全量重拉(GET 每个 .ics)回填。
// 不回填的后果:把某次发生改期的覆盖实例无法排除主事件在原时间的那次发生,
// App 与系统日历里同一日程会显示两条(幽灵日程)。
// ⚠️ 判据必须是**数据库里的真实状态**:内存标志会被卡片进程/进程重启吃掉
// (2026-09-19 实测两次同步都没触发)。回填完成后计数自然归 0,不会反复重拉。
await EventDb.getDb(context);
const ridMissing: number = await EventDb.countOverridesMissingRecurrenceId(context);
if (ridMissing > 0) {
LogUtil.write(`recurrence_id 待回填 ${ridMissing} 条覆盖实例 → 重置"一次性全量重拉"`);
await AppSettings.resetFullRefetch(context);
}
// 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟 // 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context); const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
const timeoutMs: number = fullRefetch ? 600000 : 300000; const timeoutMs: number = fullRefetch ? 600000 : 300000;
@@ -58,6 +109,10 @@ export class SyncEngine {
LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`); LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
return r; return r;
} catch (err) { } catch (err) {
// ⭐ "正在同步中"原样抛出(界面据此提示"请耐心等待",不当成失败)
if (err instanceof SyncBusyError) {
throw err;
}
const e = err as BusinessError; const e = err as BusinessError;
LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`); LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`); throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
@@ -67,7 +122,23 @@ export class SyncEngine {
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> { private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
const auth: string = DavClient.authHeader(acc.username, acc.password); const auth: string = DavClient.authHeader(acc.username, acc.password);
// 0) 刷新服务器端日历本颜色(每次同步都校正) // 0) 刷新服务器端日历本颜色(每次同步都校正)
await SyncEngine.refreshCalendarColors(acc, auth); // ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
if (colorChanged) {
try {
const all: DavAccount[] = await AccountStore.loadAll(context);
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
if (idx >= 0) {
all[idx] = acc;
await AccountStore.saveAll(context, all);
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
}
}
// 1) 推送该账号日历本下的本地修改 // 1) 推送该账号日历本下的本地修改
await SyncEngine.pushDirtyForAccount(context, acc, auth); await SyncEngine.pushDirtyForAccount(context, acc, auth);
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。 // 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
@@ -79,23 +150,78 @@ export class SyncEngine {
LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS'); LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS');
} }
let changed: number = 0; let changed: number = 0;
// 服务器上已不存在的日历本(REPORT 404):先跳过继续同步其它日历本,
// 循环结束后统一从账号移除(见 removeDeadBooks)。
// 2026-09-29 实测:服务器上删掉一个日历本后,本地仍勾选它 → 每次同步 404 →
// 旧版直接抛错让整个账号同步失败,且后台同步每轮都失败(一天半没同步成功过)。
const deadIdxs: number[] = [];
for (let i = 0; i < acc.calendarHrefs.length; i++) { for (let i = 0; i < acc.calendarHrefs.length; i++) {
const href: string = acc.calendarHrefs[i]; const href: string = acc.calendarHrefs[i];
const calKey: string = `${acc.id}_${i}`; const calKey: string = `${acc.id}_${i}`;
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`); LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
const t1: number = Date.now(); const t1: number = Date.now();
const items: RemoteItem[] = await DavClient.reportEtags(href, auth); // ⭐ 纯待办本(COMP_SET 只有 VTODO)不跑 event 通道。
// 实测群晖:对纯待办本做 event 的 REPORT 会返回文件列表,
// 里面其实是 VTODO 文件,被 parseEvents 解析成 0 条日程却仍落库
// → 本地库出现 kind='event' 记录,而所在本是待办本。
// 后果:这条"假日程"在待办列表看不到(列表只查 kind='todo'),
// 但点它编辑会走日程通道,报"需要联网 + 日历本需可写"。
const thisComps: string = i < acc.calendarComps.length ? acc.calendarComps[i] : '';
if (thisComps === 'todo') {
LogUtil.write(`日历本[${i}]「${calName}」是纯待办本(COMP_SET=todo),跳过 event 通道`);
} else {
let items: RemoteItem[] = [];
try {
items = await DavClient.reportEtags(href, auth);
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 404) {
LogUtil.write(`日历本[${i}]「${calName}」在服务器上已不存在(404),本轮跳过,收尾时自动从账号移除`);
deadIdxs.push(i);
continue;
}
throw err as Error;
}
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`); LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
// ⭐ SYNC_TODOS 同步该本里的待办(VTODO)。
// 原来整个同步流程只处理 VEVENT,`DavClient.reportTodos` 写了却没人调用,
// 导致待办数据从来不会从服务器拉下来 ——
// 用户取消勾选本(触发 pruneStaleHrefs 清行)后再勾回来,待办就永久消失了。
// 放在这里(VEVENT 处理之前)单独走一遍,不影响原有日程逻辑。
try {
const todoItems: RemoteItem[] = await DavClient.reportTodos(href, auth);
if (todoItems.length > 0) {
const todoRemote: RemoteEvent[] = [];
for (const ti of todoItems) {
try {
const raw: string = await DavClient.getRawStrict(ti.href, auth);
const parsed: RemoteEvent[] = IcsUtil.parseTodos(raw);
for (const pr of parsed) {
pr.etag = ti.etag;
todoRemote.push(pr);
}
} catch (e) {
LogUtil.write(`待办 GET 失败(跳过) ${ti.href}`);
}
}
if (todoRemote.length > 0) {
await EventDb.applyRemote(context, calKey, href, todoRemote, true);
LogUtil.write(`日历本[${i}]「${calName}」待办同步:${todoRemote.length} 条`);
}
}
} catch (e) {
// 待办同步失败不能影响日程同步(很多服务器对 VTODO 查询返回 403/不支持)
LogUtil.write(`日历本[${i}]「${calName}」待办同步失败(忽略):${(e as BusinessError).message ?? ''}`);
}
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag) // 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
const byUid: Map<string, LocalEvent[]> = new Map(); const byUid: Map<string, LocalEvent[]> = new Map();
// ⭐ 再按 **etag** 分组,给下面"要不要 GET 这个资源"做兜底(见 §复用判定 注释)
const byEtag: Map<string, LocalEvent[]> = new Map();
for (const row of existingRows) { for (const row of existingRows) {
const arr: LocalEvent[] | undefined = byUid.get(row.uid); SyncEngine.groupPush(byUid, row.uid, row);
if (arr === undefined) { if (row.etag !== '') {
byUid.set(row.uid, [row]); SyncEngine.groupPush(byEtag, row.etag, row);
} else {
arr.push(row);
} }
} }
const originMatch = /https?:\/\/[^/]+/i.exec(href); const originMatch = /https?:\/\/[^/]+/i.exec(href);
@@ -104,19 +230,39 @@ export class SyncEngine {
let fetched: number = 0; let fetched: number = 0;
let parseFail: number = 0; let parseFail: number = 0;
const needFetch: RemoteItem[] = []; const needFetch: RemoteItem[] = [];
let hitUid: number = 0; // 按"资源名 = uid"命中
let hitEtag: number = 0; // 按 etag 内容哈希兜底命中
for (const it of items) { for (const it of items) {
const resUid: string = SyncEngine.resUidFromHref(it.href); const resUid: string = SyncEngine.resUidFromHref(it.href);
const group: LocalEvent[] | undefined = byUid.get(resUid); let group: LocalEvent[] | undefined = byUid.get(resUid);
if (group !== undefined) {
hitUid++;
} else if (it.etag !== '') {
// ⭐ 复用判定(2026-10-15 修复):**服务器不保证"资源文件名 = ICS 里的 UID"**。
// 家书 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/', '_', $uid) 生成文件名:
// UID `movie-188@familybook` 的资源叫 `movie-188_familybook.ics`。
// 旧版只按资源名去 `byUid` 查 → 永远查不到 → 该资源**每轮都被判成"变了"**,
// 于是每次都全量 GET(实测 2184 个资源 → 单次同步 200~300 秒:卡片后台同步
// 那 9 秒窗口连一个日历本都跑不完)。
// etag 是**内容哈希**(服务端由正文 md5 得出):相等即内容一致,
// 与"资源叫什么名字"无关 —— 用它兜底既安全又与服务器实现解耦。
group = byEtag.get(it.etag);
if (group !== undefined) {
hitEtag++;
}
}
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
&& group[0].etag === it.etag) { && group[0].etag === it.etag) {
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段) // etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
const rp: string = it.href.substring(it.href.lastIndexOf('/') + 1);
for (const row of group) { for (const row of group) {
remote.push(SyncEngine.rowToRemote(row, it.etag)); remote.push(SyncEngine.rowToRemote(row, it.etag, rp));
} }
continue; continue;
} }
needFetch.push(it); needFetch.push(it);
} }
LogUtil.write(`日历本[${i}]「${calName}」复用判定:uid 命中 ${hitUid}、etag 兜底命中 ${hitEtag}、需补拉 ${needFetch.length}(共 ${items.length})`);
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时 // 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
const batchSize: number = 5; const batchSize: number = 5;
for (let b: number = 0; b < needFetch.length; b += batchSize) { for (let b: number = 0; b < needFetch.length; b += batchSize) {
@@ -129,6 +275,12 @@ export class SyncEngine {
return []; return [];
} }
const resUid: string = SyncEngine.resUidFromHref(it.href); 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 parsed: RemoteEvent[] = IcsUtil.parse(raw);
const out: RemoteEvent[] = []; const out: RemoteEvent[] = [];
for (const r of parsed) { for (const r of parsed) {
@@ -138,6 +290,7 @@ export class SyncEngine {
if (r.uid === '') { if (r.uid === '') {
r.uid = resUid; r.uid = resUid;
} }
r.remotePath = fileName;
r.etag = it.etag; r.etag = it.etag;
out.push(r); out.push(r);
} }
@@ -164,6 +317,8 @@ export class SyncEngine {
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false); const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`); LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
changed += remote.length; changed += remote.length;
} // end else(非纯待办本才跑 event 通道)
// 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步) // 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步)
try { try {
const todoItems = await DavClient.reportTodos(href, auth); const todoItems = await DavClient.reportTodos(href, auth);
@@ -185,14 +340,102 @@ export class SyncEngine {
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`); LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`);
} }
} }
// 收尾:把本轮发现的"服务器上已删除"的日历本从账号移除并清理本地数据。
// 放在循环后(而非循环中)是因为移除会让后续序号前移,循环中途改数组会打乱 calKey。
if (deadIdxs.length > 0) {
await SyncEngine.removeDeadBooks(context, acc, deadIdxs);
}
return changed; return changed;
} }
/**
* 从账号移除「服务器上已不存在(404)」的日历本:收窄配置、迁移序号、清理本地数据并持久化。
* 只要有至少一个日历本保留就自动移除;若全部失效则不动配置(避免把账号清空),
* 抛错交给上层提示用户去服务器/编辑账号处理。
*/
private static async removeDeadBooks(context: common.Context, acc: DavAccount,
deadIdxs: number[]): Promise<void> {
if (deadIdxs.length >= acc.calendarHrefs.length) {
throw new Error('该账号下所有日历本在服务器上均已不存在(404),请检查服务器或重新编辑账号');
}
const deadSet: Set<number> = new Set<number>();
for (const d of deadIdxs) {
deadSet.add(d);
}
const removedHrefs: string[] = [];
const newHrefs: string[] = [];
const newNames: string[] = [];
const newColors: string[] = [];
const newWritable: string[] = [];
for (let i = 0; i < acc.calendarHrefs.length; i++) {
if (deadSet.has(i)) {
removedHrefs.push(acc.calendarHrefs[i]);
continue;
}
newHrefs.push(acc.calendarHrefs[i]);
newNames.push(i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`);
newColors.push(i < acc.calendarColors.length ? acc.calendarColors[i] : '');
newWritable.push(i < acc.calendarWritable.length ? acc.calendarWritable[i] : '1');
}
LogUtil.write(`自动移除 ${removedHrefs.length} 个在服务器上不存在的日历本,保留 ${newHrefs.length} 个`);
// 1) 先按 href 删掉被移除日历本的本地行——必须在序号迁移之前,
// 否则序号收窄后这些行会混进接替该序号的其它日历本(pruneByHrefs 注释详述)
await EventDb.pruneByHrefs(context, acc.id, removedHrefs);
// 2) 序号迁移:保留的日历本按原顺序收紧(旧序号 → 新序号)
const oldHrefs: string[] = acc.calendarHrefs.slice();
const pairs: Array<[string, string]> = [];
let ni: number = 0;
for (let oi = 0; oi < oldHrefs.length; oi++) {
if (deadSet.has(oi)) {
continue;
}
if (ni !== oi) {
pairs.push([`${acc.id}_${oi}`, `${acc.id}_${ni}`]);
}
ni++;
}
if (pairs.length > 0) {
const moved: number = await EventDb.remapCalKeys(context, pairs);
LogUtil.write(`移除失效日历本后迁移日程 ${moved} 行(${pairs.length} 个日历本序号前移)`);
}
// 3) 收窄账号配置并持久化
acc.calendarHrefs = newHrefs;
acc.calendarNames = newNames;
acc.calendarColors = newColors;
acc.calendarWritable = newWritable;
const all: DavAccount[] = await AccountStore.loadAll(context);
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
if (idx >= 0) {
all[idx] = acc;
await AccountStore.saveAll(context, all);
}
// 4) 兜底:该账号下 calKey 不在有效列表的行全部清掉
const validKeys: string[] = [];
for (let i = 0; i < newHrefs.length; i++) {
validKeys.push(`${acc.id}_${i}`);
}
await EventDb.pruneAccountEvents(context, acc.id, validKeys);
// 5) 若被移除的日历本正是「系统日历 → CalDAV」的备份目标,当场判失效并关闭备份
try {
const backupCheck = await SystemCalendarMirror.checkBackupTarget(context);
if (backupCheck.closed) {
LogUtil.write('备份目标日历本已在服务器上删除,备份功能已自动关闭');
}
} catch (err) {
// 检查失败不影响主流程
}
}
/** /**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors * 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性) * 失败静默(颜色不影响数据正确性)
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
*/ */
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> { static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
let changed: boolean = false;
let entries: DavColorEntry[] = []; let entries: DavColorEntry[] = [];
try { try {
entries = await DavClient.propfindColors(acc.serverUrl, auth); entries = await DavClient.propfindColors(acc.serverUrl, auth);
@@ -210,9 +453,11 @@ export class SyncEngine {
if (found !== undefined && found.color !== '') { if (found !== undefined && found.color !== '') {
while (acc.calendarColors.length <= i) { while (acc.calendarColors.length <= i) {
acc.calendarColors.push(''); acc.calendarColors.push('');
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
} }
if (acc.calendarColors[i] !== found.color) { if (acc.calendarColors[i] !== found.color) {
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`); LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
changed = true;
} }
acc.calendarColors[i] = found.color; acc.calendarColors[i] = found.color;
} else if (found === undefined) { } else if (found === undefined) {
@@ -224,16 +469,32 @@ export class SyncEngine {
acc.calendarWritable.push('1'); acc.calendarWritable.push('1');
} }
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
let writable: string;
if (found !== undefined && found.privilegeKnown) { if (found !== undefined && found.privilegeKnown) {
// 服务器声明了权限:直接采用 // 服务器声明了权限:直接采用
acc.calendarWritable[i] = found.writable ? '1' : '0'; writable = found.writable ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
} else { } else {
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测 // 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
const w: boolean = await DavClient.probeWritable(target, auth); const w: boolean = await DavClient.probeWritable(target, auth);
acc.calendarWritable[i] = w ? '1' : '0'; writable = w ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
} }
if (acc.calendarWritable[i] !== writable) {
changed = true;
}
acc.calendarWritable[i] = writable;
}
return changed;
}
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
private static groupPush(m: Map<string, LocalEvent[]>, key: string, row: LocalEvent): void {
const arr: LocalEvent[] | undefined = m.get(key);
if (arr === undefined) {
m.set(key, [row]);
} else {
arr.push(row);
} }
} }
@@ -248,8 +509,7 @@ export class SyncEngine {
} }
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */ /** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
private static resUidFromHref(href: string): string { private static resUidFromHref(href: string): string { const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
if (segs.length === 0) { if (segs.length === 0) {
return String(Date.now()); return String(Date.now());
} }
@@ -266,10 +526,18 @@ export class SyncEngine {
} }
/** 本地行 → 远端事件(etag 未变的资源复用本地完整数据,含 reminder) */ /** 本地行 → 远端事件(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(); const r = new RemoteEvent();
r.uid = e.uid; r.uid = e.uid;
r.etag = etag; r.etag = etag;
r.remotePath = remotePath !== '' ? remotePath : e.remotePath;
r.title = e.title; r.title = e.title;
r.description = e.description; r.description = e.description;
r.location = e.location; r.location = e.location;
@@ -280,6 +548,7 @@ export class SyncEngine {
r.completed = e.completed; r.completed = e.completed;
r.rrule = e.rrule; r.rrule = e.rrule;
r.exdate = e.exdate; r.exdate = e.exdate;
r.recurrenceId = e.recurrenceId;
r.reminder = e.reminder; r.reminder = e.reminder;
r.reminders = e.reminders.slice(); r.reminders = e.reminders.slice();
return r; return r;
@@ -292,6 +561,9 @@ export class SyncEngine {
const dirty: LocalEvent[] = await EventDb.getDirty(context); const dirty: LocalEvent[] = await EventDb.getDirty(context);
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href)); const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`); LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`);
// ⭐ 同一个重复系列(uid + 日历本)在一次推送里只处理一次:整份 .ics(主事件 + 各覆盖实例)
// 一起重建后推送,避免"先 PUT 覆盖实例、后 PUT 主事件"互相覆盖。
const pushedSeries: Set<string> = new Set<string>();
for (const e of mine) { for (const e of mine) {
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推) // 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
const bookIdx: number = acc.calendarHrefs.indexOf(e.href); const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
@@ -305,19 +577,42 @@ export class SyncEngine {
await EventDb.clearDirty(context, e.id, e.etag); await EventDb.clearDirty(context, e.id, e.etag);
continue; continue;
} }
if (e.recurring && e.rrule === '') { // ⭐ 重复系列(含覆盖实例):按 uid 取全系列行,重建整份 .ics 后一次性 PUT。
// 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持 // 旧版对"覆盖实例"直接跳过(不推送),导致 App 里的单次改期/删除无法上传到服务器;
await EventDb.clearDirty(context, e.id, e.etag); // 现在支持标准写法(同资源内多 VEVENT + RECURRENCE-ID)。
LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`); const seriesKey: string = `${e.calKey}|${e.uid}`;
if ((e.recurring || e.rrule !== '') && !pushedSeries.has(seriesKey)) {
pushedSeries.add(seriesKey);
await SyncEngine.pushSeries(context, acc, auth, e);
continue; continue;
} }
if (e.recurring) { if ((e.recurring || e.rrule !== '') && pushedSeries.has(seriesKey)) {
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送 continue; // 该系列已在上面整体推送过
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
} }
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`; const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
try {
if (e.deleted) { if (e.deleted) {
// ⭐ DELETE_404_PURGE 单条路径:deleteRemote 内部已把 404 视作成功,
// 这里再显式兜一层 410(Gone),并保证无论哪种"远端已无"都清本地。
let gone: boolean = false;
try {
await DavClient.deleteRemote(url, auth); 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); await EventDb.purge(context, e.id);
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`); LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
} else { } else {
@@ -326,6 +621,99 @@ export class SyncEngine {
await EventDb.clearDirty(context, e.id, etag); await EventDb.clearDirty(context, e.id, etag);
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`); LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`);
} }
} catch (err) {
// 单条推送失败不再中断整个账号(旧行为:一条 404 就让整个账号同步失败)。
// 401(凭据失效)例外:后续每条也必然失败,直接抛出让上层提示。
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
}
const pe = err as Error;
LogUtil.write(`推送单条失败(跳过,保留待推送):uid=${e.uid} → ${pe.message}`);
}
}
}
/**
* ⭐ 推送一个"重复系列":取该 uid 下的主事件与全部覆盖实例,合成整份 .ics 后 PUT。
*
* - 主事件行若被标记删除(deleted)→ 视为"整个系列删除",DELETE 整个资源并清掉本地该 uid 全部行。
* - 否则重建(主事件可能带上了 EXDATE / 被截断的 UNTIL,覆盖实例带 RECURRENCE-ID),
* 一次性 PUT 覆盖服务器上的同一资源;成功后清掉本系列全部行的 dirty。
*/
private static async pushSeries(context: common.Context, acc: DavAccount, auth: string,
trigger: LocalEvent): Promise<void> {
const calKey: string = trigger.calKey;
const uid: string = trigger.uid;
const rows: LocalEvent[] = await EventDb.queryByUid(context, calKey, uid);
// 主事件:优先取带 RRULE 的行;退而取最早一行
let master: LocalEvent | null = null;
for (const r of rows) {
if (r.rrule !== '') {
master = r;
break;
}
}
if (master === null && rows.length > 0) {
master = rows[0];
}
if (master === null) {
return;
}
const href: string = master.href;
const path: string = master.remotePath !== '' ? master.remotePath : encodeURIComponent(uid) + '.ics';
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
if (master.deleted) {
// ⭐ DELETE_404_PURGE 404/410 = 服务器上本就没有这个资源。
// 必须当成"删除成功"继续往下清本地,不能 return ——
// 否则本地行永远留着,用户看到"删不掉的日程",且每次同步都重复失败。
// 典型诱因:remotePath 用 uid 猜错(服务器真实文件名不同)⇒ DELETE 打到不存在的路径。
let remoteGone: boolean = false;
try {
await DavClient.deleteRemote(url, auth);
remoteGone = true;
} catch (err) {
if (err instanceof DavHttpError) {
const st: number = (err as DavHttpError).status;
if (st === 401) {
throw err as Error;
}
if (st === 404 || st === 410) {
remoteGone = true;
LogUtil.write(`重复系列资源在服务器上已不存在(${st}),直接清理本地:uid=${uid}`);
}
}
if (!remoteGone) {
LogUtil.write(`推送删除重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
return;
}
}
await EventDb.purgeByUid(context, calKey, uid);
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);
return;
}
const overrides: LocalEvent[] = [];
for (const r of rows) {
if (r.id !== master.id && r.rrule === '' && r.recurrenceId > 0) {
overrides.push(r);
}
}
overrides.sort((a: LocalEvent, b: LocalEvent): number => a.recurrenceId - b.recurrenceId);
const ics: string = IcsUtil.buildSeries(master, overrides);
try {
const etag: string = await DavClient.putEvent(url, auth, ics);
// 清掉本系列全部行的 dirty(第一条写回 etag)
let first: boolean = true;
for (const r of rows) {
await EventDb.clearDirty(context, r.id, first ? etag : '');
first = false;
}
LogUtil.write(`推送重复系列(标题已脱敏)uid=${uid} → ${url}(${ics.length} 字节,覆盖实例=${overrides.length})`);
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
}
LogUtil.write(`推送重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
} }
} }
@@ -345,6 +733,16 @@ export class SyncEngine {
await EventDb.clearDirty(context, e.id, ''); await EventDb.clearDirty(context, e.id, '');
} }
} }
// ⭐ 顺带清理"同一资源被重复插入"的冗余行(旧版 applyRemote 判重缺陷遗留的脏数据)。
// 纯本地去重、不推服务器;放在这里是因为它是每次同步都会经过的收尾步骤。
try {
const dup: number = await EventDb.dedupeDuplicateRows(context);
if (dup > 0) {
LogUtil.write(`清理本地重复行:${dup} 条(同一资源被插了多次)`);
}
} catch (err) {
// 忽略:去重失败不影响同步
}
} }
/** /**
@@ -365,6 +763,19 @@ export class SyncEngine {
if (removed > 0) { if (removed > 0) {
LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`); LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`);
} }
// 兜底清扫:行上 href 已不属于任何已选日历本的残留行(calKey 可能因历史迁移
// 缺陷混在有效日历本名下,calKey 维度抓不到,href 才暴露真实归属)。
// 典型如服务器删除日历本后残留的"幽灵"数据——不清掉的话镜像会一直把它写进系统日历。
const validHrefs: string[] = [];
for (const a of accounts) {
for (const h of a.calendarHrefs) {
validHrefs.push(h);
}
}
const stale: number = await EventDb.pruneStaleHrefs(context, validHrefs);
if (stale > 0) {
LogUtil.write(`清理失效日历本残留行:${stale} 条(href 已不属于任何已选日历本)`);
}
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`); LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`);
@@ -5,10 +5,10 @@
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { calendarManager } from '@kit.CalendarKit'; import { calendarManager } from '@kit.CalendarKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { AccountStore, DavAccount } from './AccountStore';
import { EventDb, LocalEvent } from './EventDb'; import { EventDb, LocalEvent } from './EventDb';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
export class SystemCalendarImport { export class SystemCalendarImport {
/** /**
@@ -16,6 +16,15 @@ export class SystemCalendarImport {
* 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。 * 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。
* 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程), * 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程),
* 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。 * 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。
*
* ⚠️ 另有两层防重复(09-18 补):
* 1. **跳过本应用镜像出去的账户**(`synccalendar.mirror.*`)——它们的 type 也是 LOCAL,
* 但内容是我们从 CalDAV 写进去的,再导进备份本会让同一条日程在服务器出现两份。
* 2. 导进备份本的 uid 统一带 `syscal-` 前缀,出站镜像会跳过该前缀 → 备份本不会被镜像回系统日历。
*
* ⚠️ 目标本用**稳定标识 bookId** 解析(`SystemCalendarMirror.backupTargetRef`):
* 用户「取消勾选」目标本后序号会整体前移,老版本按 `accId_序号` 定位会**导进另一个本**。
* 现在解析不到即视为失效,并把「备份到 CalDAV」模式**自动关回仅显示**。
*/ */
static async importIfNeeded(context: common.Context): Promise<number> { static async importIfNeeded(context: common.Context): Promise<number> {
try { try {
@@ -23,30 +32,42 @@ export class SystemCalendarImport {
if (mode !== 'backup') { if (mode !== 'backup') {
return 0; return 0;
} }
const calKey: string = await AppSettings.getBackupCalKey(context); // ⭐ 目标本一律走 `backupTargetRef()`:内部用**稳定标识 bookId** 解析,
if (calKey === '') { // 并兼做「老 calKey 值 → bookId」的一次性迁移。
LogUtil.write('系统日历备份:未选择目标日历本,跳过导入'); // 解析不到时它会**自动把 sys_cal_mode 关回 display**(用户明确要求的行为):
// 目标本被取消同步/账号被删后,绝不能"随便挑一个本"把系统日历导进去。
const ref: BookRef | undefined = await SystemCalendarMirror.backupTargetRef(context);
if (ref === undefined) {
// 未选择目标本(或刚被判失效并关闭)→ 跳过。原因已由 backupTargetRef 写进日志。
LogUtil.write('系统日历备份:当前无有效目标日历本,跳过导入');
return 0; return 0;
} }
// 定位目标 CalDAV 日历本 const calKey: string = ref.calKey; // 仅用于查本地库 / 落库
const accounts: DavAccount[] = await AccountStore.loadAll(context); const targetHref: string = ref.href;
let targetHref: string = '';
for (const acc of accounts) {
for (let i = 0; i < acc.calendarHrefs.length; i++) {
if (`${acc.id}_${i}` === calKey) {
targetHref = acc.calendarHrefs[i];
}
}
}
if (targetHref === '') { if (targetHref === '') {
LogUtil.write(`系统日历备份:目标日历本 ${calKey} 不存在(账号可能已删除),跳过导入`); LogUtil.write(`系统日历备份:目标日历本 ${ref.bookId} 缺少集合 URL,跳过导入`);
return 0; return 0;
} }
const uiContext = context as common.UIAbilityContext; const uiContext = context as common.UIAbilityContext;
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext); const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars(); const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
// 诊断:把 getAllCalendars() 实际能读到的账户全列出来。
// 用于确认"是否只能读到本应用创建的 + 系统默认账户",还是能读到其他应用自建的日历账户。
const brief: string[] = [];
for (const cal of calendars) {
let acc: calendarManager.CalendarAccount;
try {
acc = cal.getAccount();
} catch (err) {
continue;
}
const tag: string = SystemCalendarMirror.isMirrorAccountName(acc.name) ? '(我们的镜像)' : '';
brief.push(`${acc.name}[type=${acc.type}]${tag}`);
}
LogUtil.write(`系统日历账户清单:共 ${calendars.length} 个 → ${brief.join(' | ')}`);
let imported: number = 0; let imported: number = 0;
let skippedCal: number = 0; let skippedCal: number = 0;
let skippedMirror: number = 0; // 本应用镜像出去的账户(不能导进备份本,否则重复)
for (const cal of calendars) { for (const cal of calendars) {
try { try {
// 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌 // 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌
@@ -55,6 +76,13 @@ export class SystemCalendarImport {
skippedCal++; skippedCal++;
continue; continue;
} }
// ⚠️ 关键:跳过**本应用自己镜像出去的账户**(type 同样 LOCAL,但内容是我们从 CalDAV 写进去的)。
// 若一并导入备份本,同一条日程会在服务器里出现两份(原始本一份、备份本一份)→ 数据非常乱。
// 这些账户的改动由 MirrorInbound 负责回写到它**原本所属**的日历本,不走备份通道。
if (SystemCalendarMirror.isMirrorAccountName(account.name)) {
skippedMirror++;
continue;
}
const events: calendarManager.Event[] = await cal.getEvents(); const events: calendarManager.Event[] = await cal.getEvents();
for (const ev of events) { for (const ev of events) {
if (ev.id === undefined) { if (ev.id === undefined) {
@@ -86,8 +114,9 @@ export class SystemCalendarImport {
// 单个日历读取失败不影响其余 // 单个日历读取失败不影响其余
} }
} }
if (imported > 0 || skippedCal > 0) { if (imported > 0 || skippedCal > 0 || skippedMirror > 0) {
LogUtil.write(`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过账户日历 ${skippedCal} 个)`); LogUtil.write(
`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过非本地账户 ${skippedCal} 个、镜像账户 ${skippedMirror} 个)`);
} }
return imported; return imported;
} catch (err) { } catch (err) {
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -36,6 +36,8 @@ export class DayTimeline {
allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前) allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前)
blocks: TimelineBlock[] = []; // 时间轴色块 blocks: TimelineBlock[] = []; // 时间轴色块
hasTimed: boolean = false; // 当天是否有有时间日程 hasTimed: boolean = false; // 当天是否有有时间日程
/** 当天截止、尚未完成的待办(画在时间轴顶部、全天区之上,带勾选框) */
todos: DisplayEvent[] = [];
} }
/** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */ /** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */
@@ -272,38 +274,43 @@ export class TimelineUtil {
return out; return out;
} }
/** 时间轴"基准可视区"的起止小时:8:00 ~ 18:00 **恒定显示**(与用户确认)。 */
static readonly BASE_START_HOUR: number = 8;
static readonly BASE_END_HOUR: number = 18;
/** /**
* 计算"需要显示的小时区间"(视窗截断): * 计算"需要显示的小时区间"(视窗截断)。规则(与用户确认):
* - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点) * - **8:00 ~ 18:00 恒定显示**(即使当天没有任何日程,也至少显示这一段,避免"只有一条日程
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见 * 就只显示那 1 小时"的奇怪观感);
* - 若某天在 **8 点之前**有日程 → 起点向前扩到最早日程所在整点;否则 8 点之前的刻度不显示;
* - 若某天在 **18 点之后**有日程 → 终点向后扩到最晚日程结束时刻向上取整;否则 18 点之后的刻度不显示。
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来以保证红线可见 —— 但**仍遵守上面的
* "无日程则不显示"约束**:只有当该侧本来就有日程(起点已 <8 / 终点已 >18)时才允许继续向外扩,
* 否则不会因为"当前时刻"而把 8 点前 / 18 点后的空档重新显示出来。
* 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。 * 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。
*/ */
static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange { static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange {
const rg = new ViewRange(); const rg = new ViewRange();
let s: number = -1; // 基准:8~18 恒显示
let e: number = -1; let s: number = TimelineUtil.BASE_START_HOUR;
let e: number = TimelineUtil.BASE_END_HOUR;
for (const b of blocks) { for (const b of blocks) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
if (s < 0) {
s = 8;
}
if (e < 0) {
e = 20;
}
if (includeNowHour) { if (includeNowHour) {
const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24); const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24);
if (nh < s) { // 只有"该侧本来就有日程"时才允许把当前小时并进来(否则会违反"8 点前/18 点后无日程则不显示")
if (nh < s && s < TimelineUtil.BASE_START_HOUR) {
s = nh; s = nh;
} }
if (nh + 1 > e) { if (nh + 1 > e && e > TimelineUtil.BASE_END_HOUR) {
e = nh + 1; e = nh + 1;
} }
} }
+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 { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI'; import { window } from '@kit.ArkUI';
import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { BackgroundSyncService } from '../common/BackgroundSyncService';
import { NetworkSyncWatcher } from '../common/NetworkSyncWatcher';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
const DOMAIN = 0x0000; const DOMAIN = 0x0000;
@@ -9,11 +11,14 @@ const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility { export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log // 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
LogUtil.init(this.context); LogUtil.init(this.context);
LogUtil.write('---- App 启动 ----'); LogUtil.write('---- App 启动 ----');
// 后台同步:注册延迟任务兜底 + 按设置恢复长时任务 // 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
// ⚠️ 不再申请"长时任务":dataTransfer 类型有 10 分钟无进度即被取消的一致性校验,
// 日历同步不符合该语义、必被取消(详见 BackgroundSyncService 顶部注释)。
BackgroundSyncService.initOnLaunch(this.context); BackgroundSyncService.initOnLaunch(this.context);
NetworkSyncWatcher.start(this.context);
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存) // 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
this.handleWidgetParam(want); this.handleWidgetParam(want);
// ⚠️ 这里**不再申请任何权限**(含通知权限)。 // ⚠️ 这里**不再申请任何权限**(含通知权限)。
@@ -37,10 +42,12 @@ export default class EntryAbility extends UIAbility {
private handleWidgetParam(want: Want): void { private handleWidgetParam(want: Want): void {
const params = want.parameters; const params = want.parameters;
if (params !== undefined) { if (params !== undefined) {
// ⭐ 白名单:日程卡片发 'add',待办卡片发 'addTodo'(新建待办)/ 'openTodo'(进待办页)。
// 之前只认 'add',待办卡片点 + 毫无反应 —— 参数被静默丢弃。
const raw = params['widgetAction']; const raw = params['widgetAction'];
if (typeof raw === 'string' && raw === 'add') { if (typeof raw === 'string' && (raw === 'add' || raw === 'addTodo' || raw === 'openTodo')) {
AppStorage.setOrCreate<string>('widgetAction', 'add'); AppStorage.setOrCreate<string>('widgetAction', raw);
LogUtil.write('卡片添加入口:已设置 widgetAction=add'); LogUtil.write(`卡片入口:已设置 widgetAction=${raw}`);
} }
// 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期) // 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期)
const od = params['openDate']; const od = params['openDate'];
@@ -189,9 +196,15 @@ export default class EntryAbility extends UIAbility {
onForeground(): void { onForeground(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
// ⭐ 回到前台:① 归还短时任务配额 ② 立刻补同步一次(用户真正要看时数据必须最新)
BackgroundSyncService.cancelTransientSync();
BackgroundSyncRunner.runOnce(this.context, 'foreground');
} }
onBackground(): void { onBackground(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
// ⭐ 退到后台:申请短时任务,兜住"退后台后那一次"同步能跑完
// (必须在退后台 5 秒内申请;单次最长 3 分钟,单日配额 10 分钟)
BackgroundSyncService.requestTransientSync('正在同步日历数据');
} }
} }
@@ -3,19 +3,47 @@
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit'; import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit'; import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { CardDataService, CardData } from '../common/CardDataService'; import { CardDataService, CardData } from '../common/CardDataService';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { TodoService } from '../common/TodoService';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
export default class EntryFormAbility extends FormExtensionAbility { export default class EntryFormAbility extends FormExtensionAbility {
/** 组装更新数据并推送到指定卡片 */ /** 当前进程 pid(取不到返回 -1):用于判断卡片回调是否与主 App 同进程 */
private static pidOf(): number {
try {
return process.pid;
} catch (err) {
return -1;
}
}
/**
* 组装更新数据并推送到指定卡片。
*
* ⭐ 一次同时构建「日程」与「待办」两份数据,合并进同一个 binding。
* 日程卡片只读 eventsJson 等字段,待办卡片只读 todoListJson 等字段,
* 彼此不会串味。这样无需维护 formId → 卡片类型的映射(那需要持久化,
* 且系统重启后容易丢失),一套逻辑服务全部 8 张卡片。
*/
private pushData(formId: string, dayOffset: number = 0): void { private pushData(formId: string, dayOffset: number = 0): void {
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => { 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 = const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(data); formBindingData.createFormBindingData(ev);
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => { formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`); LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
}); });
LogUtil.write(`卡片 ${formId} 数据已推送`); LogUtil.write(`卡片 ${formId} 数据已推送(日程+待办)`);
}).catch((err: BusinessError): void => { }).catch((err: BusinessError): void => {
LogUtil.write(`卡片数据构建失败: ${err.message}`); LogUtil.write(`卡片数据构建失败: ${err.message}`);
}); });
@@ -40,10 +68,30 @@ export default class EntryFormAbility extends FormExtensionAbility {
onUpdateForm(formId: string): void { onUpdateForm(formId: string): void {
LogUtil.init(this.context); LogUtil.init(this.context);
LogUtil.write(`系统触发卡片更新: formId=${formId}`); LogUtil.write(`系统触发卡片更新: formId=${formId}(进程 pid=${EntryFormAbility.pidOf()})`);
CardDataService.registerForm(this.context, formId); CardDataService.registerForm(this.context, formId);
// ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天) // 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
this.pushData(formId, CardDataService.getDayOffset(formId)); this.pushData(formId, CardDataService.getDayOffset(formId));
// ⭐ ② 再借这次系统刷新顺带跑一次后台同步(见 runOnceThrottledByCard:带节流、永不抛错)
// 同步成功后再推一次,让卡片显示刚同步回来的最新数据。
//
// ⚠️ 关键约束(依据官方 FormExtensionAbility 生命周期文档):
// "FormExtensionAbility 进程不能常驻后台,生命周期回调中无法处理长时间的任务,
// 生命周期调度完成后会继续存在 **10 秒**……可能需要 10 秒以上才能完成的业务逻辑,
// 建议拉起主应用进行处理。"
// ⇒ 本处同步**可能被进程回收中断**。这是**可接受**的,因为:
// · 同步是幂等的(基于 etag 增量比对 + dirty 标记),中断不会损坏任何数据;
// · 中断只是"这一次没同步成",下个 30 分钟卡片再刷新时会重试;
// · 正常网络下 1~3 个账号的**增量**同步通常数秒内完成,落在 10 秒窗口内。
// ⇒ 因此它是"**尽力而为地提高后台同步频率**",与 workScheduler 2 小时兜底、
// 回前台/网络恢复补同步共同构成多层保障,**不承诺 30 分钟必然同步成功**。
// (不采用"拉起主应用同步"是因为会每 30 分钟弹出一次界面,体验不可接受。)
BackgroundSyncRunner.runOnceThrottledByCard(this.context).then((): void => {
this.pushData(formId, CardDataService.getDayOffset(formId));
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片驱动同步失败: ${err.message}`);
});
} }
onRemoveForm(formId: string): void { onRemoveForm(formId: string): void {
@@ -53,12 +101,35 @@ export default class EntryFormAbility extends FormExtensionAbility {
CardDataService.clearDayOffset(formId); CardDataService.clearDayOffset(formId);
} }
/** 卡片上「‹ / › / 回到今天」按钮触发:message 里带 pageAction=prev|next|today */ /**
* 卡片事件:既处理日程翻页,也处理待办打勾。
*
* ⚠️ 不用 JSON.parse 解析 message —— 不同系统版本下格式可能是
* `{params:{...}}` 也可能是直接 `{...}`,格式差异会让 JSON 解析拿到 undefined。
* 统一用**子串匹配**,稳且不依赖具体结构。
*/
onFormEvent(formId: string, message: string): void { onFormEvent(formId: string, message: string): void {
LogUtil.init(this.context); 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); const cur: number = CardDataService.getDayOffset(formId);
let next: number = cur; let next: number = cur;
// 不用 JSON 解析:不同版本 message 的格式可能是 {params:{...}} 或直接 {...},直接子串匹配更稳
if (message.indexOf('prev') >= 0) { if (message.indexOf('prev') >= 0) {
next = cur - 1; next = cur - 1;
} else if (message.indexOf('next') >= 0) { } else if (message.indexOf('next') >= 0) {
@@ -70,4 +141,63 @@ export default class EntryFormAbility extends FormExtensionAbility {
LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`); LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`);
this.pushData(formId, 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}`);
});
}
} }
+54 -12
View File
@@ -1,15 +1,16 @@
// entry/src/main/ets/pages/AccountsPage.ets // entry/src/main/ets/pages/AccountsPage.ets
// DAV 账号管理(由原 CalDAVSync 首页移植) // DAV 账号管理(由原 CalDAVSync 首页移植)
import { router } from '@kit.ArkUI'; import { curves, router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore'; import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore';
import { SyncEngine } from '../common/SyncEngine'; import { SyncEngine, SyncBusyError } from '../common/SyncEngine';
import { EditNavParams } from './EditAccountPage'; import { EditNavParams } from './EditAccountPage';
import { EventDb } from '../common/EventDb'; import { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
import { AppSettings } from '../common/AppSettings'; import { AppSettings } from '../common/AppSettings';
import { ScreenKeeper } from '../common/ScreenKeeper'; import { ScreenKeeper } from '../common/ScreenKeeper';
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
@Entry @Entry
@Component @Component
@@ -90,6 +91,12 @@ struct AccountsPage {
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步完成` }); .showToast({ message: `「${acc.name}」同步完成` });
} catch (err) { } catch (err) {
// ⭐ 已有同步在进行中 → 提示"请耐心等待",不报"失败"(避免用户误解)
if (err instanceof SyncBusyError) {
this.getUIContext().getPromptAction()
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
return;
}
const e = err as BusinessError; const e = err as BusinessError;
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步失败:${e.message}` }); .showToast({ message: `「${acc.name}」同步失败:${e.message}` });
@@ -142,13 +149,24 @@ struct AccountsPage {
}); });
} }
/** 执行删除:账号配置 + 该账号全部本地日程/待办 */ /** 执行删除:账号配置 + 该账号全部本地日程/待办 + 该系统日历中该账号的全部镜像日历本 */
private async confirmDeleteAccount(acc: DavAccount): Promise<void> { private async confirmDeleteAccount(acc: DavAccount): Promise<void> {
const context = this.getUIContext().getHostContext(); const context = this.getUIContext().getHostContext();
if (context === undefined) { if (context === undefined) {
return; return;
} }
try { try {
// ⭐ 先清系统日历里属于**本账号**的镜像日历本(按 bookId 里的 accId 前缀匹配,
// 绝不按显示名 → 不同账号的同名日历本不会误删)。
// 必须在改账号配置**之前**做:一旦账号配置被删,bookId 就再也解析不出来,
// 那些幽灵账户就永远清不掉了(这正是本次要修的 bug)。
let mirrored: number = 0;
try {
mirrored = await SystemCalendarMirror.removeByAccount(context, acc.id);
} catch (err) {
// 系统日历清理失败不阻断账号删除(用户至少能删掉账号)
LogUtil.write(`删除账号 ${acc.id}:清理系统日历镜像失败 ${(err as BusinessError).message}`);
}
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id); this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
// force=true:删除最后一个账号时列表为空是合法场景,允许覆盖 // force=true:删除最后一个账号时列表为空是合法场景,允许覆盖
await AccountStore.saveAll(context, this.accounts, true); await AccountStore.saveAll(context, this.accounts, true);
@@ -156,7 +174,11 @@ struct AccountsPage {
await EventDb.deleteAccountEvents(context, acc.id); await EventDb.deleteAccountEvents(context, acc.id);
await AppSettings.cleanupAccountSettings(context, acc.id); await AppSettings.cleanupAccountSettings(context, acc.id);
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」已删除` }); .showToast({
message: mirrored > 0
? `「${acc.name}」已删除,并清除系统日历中 ${mirrored} 个镜像日历本`
: `「${acc.name}」已删除`
});
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
@@ -252,23 +274,43 @@ struct AccountsPage {
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
} }
/**
* 页面转场:淡入淡出。
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
*/
pageTransition() {
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
}
@Builder @Builder
emptyState() { emptyState() {
Column({ space: 12 }) { Column({ space: 12 }) {
// ⭐ 空状态的「+」本身可点击 → 直接进入添加账号页(此前只是个静态图标,点了没反应)
Button() {
Text('+') Text('+')
.fontSize(30) .fontSize(34)
.fontColor($r('app.color.text_hint')) .fontColor($r('app.color.brand'))
.width(72) .fontWeight(FontWeight.Medium)
.height(72) }
.textAlign(TextAlign.Center) .width(84)
.borderRadius(20) .height(84)
.borderRadius(24)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.border({ width: 1.5, color: $r('app.color.shadow_color') }) .border({ width: 1.5, color: $r('app.color.brand') })
.shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 })
.onClick(() => {
this.openAddPage();
})
Text('还没有任何 CalDAV 账号') Text('还没有任何 CalDAV 账号')
.fontSize(16) .fontSize(16)
.fontWeight(FontWeight.Medium) .fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
Text('点击右下角 + 添加 CalDAV 账号') Text('点击上方 + 或右下角 + 添加 CalDAV 账号')
.fontSize(13) .fontSize(13)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center) .textAlign(TextAlign.Center)
+47 -3
View File
@@ -1,11 +1,13 @@
// entry/src/main/ets/pages/AddAccountPage.ets // entry/src/main/ets/pages/AddAccountPage.ets
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页) // 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
import { http } from '@kit.NetworkKit'; import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI'; import { curves, router } from '@kit.ArkUI';
import { buffer } from '@kit.ArkTS'; import { buffer } from '@kit.ArkTS';
import url from '@ohos.url'; import url from '@ohos.url';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore'; import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
import { DavDiscovery, DavCapability } from '../common/DavDiscovery';
import { LogUtil } from '../common/LogUtil';
@Entry @Entry
@Component @Component
@@ -107,12 +109,30 @@ struct AddAccountPage {
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader); code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
} }
if (code === 401) { if (code === 401) {
this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码'; this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
this.statusOk = false; this.statusOk = false;
return false; return false;
} }
// ⭐ 0.0.7:405 是"这里不是 DAV 端点"的典型信号 —— 直接给出可操作指引,
// 而不是笼统地报一个状态码让用户去猜。
if (code === 405) {
const cap: DavCapability = await DavDiscovery.optionsCapability(serverUrl, authHeader);
if (!cap.isDav) {
this.statusMsg = '这个地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
+ '请填写「日历家目录」而不是 DAV 根或网页链接,例如:\n'
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
+ '(也可以直接填域名,下一步会自动尝试发现)';
this.statusOk = false;
return false;
}
}
if (code >= 200 && code < 500) { if (code >= 200 && code < 500) {
// ⭐ 即使可连,也顺手提示"地址看起来不完整",让用户提前知道下一步会自动补全
if (DavDiscovery.looksIncomplete(serverUrl)) {
this.statusMsg = '服务器连接成功(该地址看起来是服务器根路径,下一步会自动尝试定位日历家目录)';
} else {
this.statusMsg = '服务器连接成功'; this.statusMsg = '服务器连接成功';
}
this.statusOk = true; this.statusOk = true;
return true; return true;
} }
@@ -186,6 +206,17 @@ struct AddAccountPage {
if (this.isLoading) { if (this.isLoading) {
return; return;
} }
// ⭐ 0.0.7:先识别「网页分享链接」——这类地址对 PROPFIND 必定 405,
// 提前拦下并给出人话指引,避免用户白跑一趟只看到"服务器返回 405"。
const kind: string = DavDiscovery.classify(targetUrl);
const shareMsg: string = DavDiscovery.shareHint(kind);
if (shareMsg !== '') {
this.statusMsg = shareMsg;
this.statusOk = false;
LogUtil.write(`添加账号:识别为网页分享链接(${kind}),已拦截`);
return;
}
this.isLoading = true; this.isLoading = true;
this.statusMsg = '正在连接服务器…'; this.statusMsg = '正在连接服务器…';
this.statusOk = false; this.statusOk = false;
@@ -229,7 +260,7 @@ struct AddAccountPage {
.width('100%') .width('100%')
Column({ space: 16 }) { Column({ space: 16 }) {
this.formField('服务器地址', '例如:https://nas.example.com/caldav/', this.serverUrl, this.formField('服务器地址', '例如:https://nas.example.com/remote.php/dav/calendars/用户名/(也可只填域名,下一步会自动识别)', this.serverUrl,
(value: string) => { (value: string) => {
this.serverUrl = value; this.serverUrl = value;
}, false) }, false)
@@ -297,6 +328,19 @@ struct AddAccountPage {
.alignItems(HorizontalAlign.Center) .alignItems(HorizontalAlign.Center)
} }
/**
* 页面转场:淡入淡出。
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
*/
pageTransition() {
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
}
@Builder @Builder
formField(label: string, placeholder: string, value: string, formField(label: string, placeholder: string, value: string,
onChange: (value: string) => void, isPassword: boolean) { onChange: (value: string) => void, isPassword: boolean) {
+47 -5
View File
@@ -1,10 +1,11 @@
// entry/src/main/ets/pages/CalendarListPage.ets // entry/src/main/ets/pages/CalendarListPage.ets
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号 // 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
import { router } from '@kit.ArkUI'; import { curves, router } from '@kit.ArkUI';
import { buffer } from '@kit.ArkTS'; import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore'; import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient'; import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
import { DavDiscovery, DiscoverResult, DavCapability } from '../common/DavDiscovery';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
/** /**
@@ -98,17 +99,44 @@ struct CalendarListPage {
this.statusOk = false; this.statusOk = false;
return; return;
} }
const auth: string = 'Basic ' + token;
// ⭐ 0.0.7:先做地址自动发现/智能补全 —— 用户常只填域名或 DAV 根,
// 而 Nextcloud 等要求填「日历家目录」。此处自动找对端点,避免"读不出日历"。
try {
const resolved: DiscoverResult = await DavDiscovery.resolve(
this.serverUrl,
this.username,
auth,
async (u: string): Promise<boolean> => {
const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth);
return d.entries !== undefined && d.entries.length > 0;
});
if (resolved.ok && resolved.url !== '' && resolved.url !== this.serverUrl) {
LogUtil.write(`服务地址已自动修正:${this.serverUrl} → ${resolved.url}(来源 ${resolved.via})`);
this.serverUrl = resolved.url;
} else if (!resolved.ok && resolved.hint !== '') {
// 自动发现彻底失败 → 直接把"人话原因"给用户,不再只报一个状态码
this.isLoading = false;
this.statusMsg = resolved.hint;
this.statusOk = false;
return;
}
} catch (err) {
// 发现阶段异常不阻塞:继续用原地址走既有流程(保持旧行为兜底)
LogUtil.write(`地址发现阶段异常,回退原地址:${(err as BusinessError).message}`);
}
// PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑) // PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑)
try { try {
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, 'Basic ' + token); const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, auth);
this.isLoading = false; this.isLoading = false;
if (disc.statusCode === 401) { if (disc.statusCode === 401) {
this.statusMsg = '登录已失效,请返回重新连接'; this.statusMsg = '服务器拒绝凭据(401):请返回检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
this.statusOk = false; this.statusOk = false;
return; return;
} }
if (disc.statusCode !== 207 && disc.statusCode !== 200) { if (disc.statusCode !== 207 && disc.statusCode !== 200) {
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`; // ⭐ 0.0.7:把生硬的状态码换成人话 + 可操作指引
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
this.statusOk = false; this.statusOk = false;
return; return;
} }
@@ -118,7 +146,8 @@ struct CalendarListPage {
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`); LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
} }
if (list.length === 0) { if (list.length === 0) {
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)'; // ⭐ 0.0.7:区分"地址层级不对"与"确实没有日历",并给出正确填法示例
this.statusMsg = DavDiscovery.emptyHint(this.serverUrl, this.username);
this.statusOk = false; this.statusOk = false;
} else { } else {
this.calendarList = list; this.calendarList = list;
@@ -369,6 +398,19 @@ struct CalendarListPage {
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.alignItems(HorizontalAlign.Center) .alignItems(HorizontalAlign.Center)
} }
/**
* 页面转场:淡入淡出。
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
*/
pageTransition() {
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
}
} }
@Component @Component
+121 -9
View File
@@ -1,13 +1,19 @@
// entry/src/main/ets/pages/EditAccountPage.ets // entry/src/main/ets/pages/EditAccountPage.ets
// 编辑账号:查看/重选该账号下的日历本、修改账户名 // 编辑账号:查看/重选该账号下的日历本、修改账户名
// 保存后清理失效日历本的本地数据,并触发一次重新同步 // 保存后清理失效日历本的本地数据,并触发一次重新同步
import { router } from '@kit.ArkUI'; //
// ⚠️ 取消勾选某个日历本会改变**后面所有本的序号**(calKey = accId_序号)。因此保存时还要检查:
// 被取消的那个本是不是「系统日历 → CalDAV」的备份目标 —— 是的话当场把该功能关闭,
// 否则用户会以为备份还在工作,实际却可能把系统日程导进别的本(用户实测反馈)。
import { curves, router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { buffer } from '@kit.ArkTS'; import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore } from '../common/AccountStore'; import { DavAccount, AccountStore } from '../common/AccountStore';
import { DavDiscovery, DiscoverResult } from '../common/DavDiscovery';
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient'; import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
import { EventDb } from '../common/EventDb'; import { EventDb } from '../common/EventDb';
import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
/** /**
@@ -18,14 +24,27 @@ export class EditCalendarItem {
href: string; href: string;
name: string; name: string;
color: string; // 服务器定义的颜色(calendar-color),可能为空 color: string; // 服务器定义的颜色(calendar-color),可能为空
/** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */
comps: string;
selected: boolean; selected: boolean;
constructor(href: string, name: string, color: string) { constructor(href: string, name: string, color: string, comps: string = 'unknown') {
this.href = href; this.href = href;
this.name = name; this.name = name;
this.color = color; this.color = color;
this.comps = comps;
this.selected = false; this.selected = false;
} }
/** 是否待办本(纯待办或混合) */
isTodoBook(): boolean {
return this.comps === 'todo' || this.comps === 'both';
}
/** 是否日历本(纯日历、混合、或服务器未声明) */
isEventBook(): boolean {
return this.comps === 'event' || this.comps === 'both' || this.comps === 'unknown';
}
} }
/** /**
@@ -136,24 +155,47 @@ struct EditAccountPage {
return; return;
} }
// PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现) // PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现)
const auth: string = 'Basic ' + token;
// ⭐ 0.0.7:地址自动发现/智能补全(老账号可能当初填的是 DAV 根或已失效的路径)
try { try {
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, 'Basic ' + token); const resolved: DiscoverResult = await DavDiscovery.resolve(
this.acc.serverUrl,
this.acc.username,
auth,
async (u: string): Promise<boolean> => {
const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth);
return d.entries !== undefined && d.entries.length > 0;
});
if (resolved.ok && resolved.url !== '' && resolved.url !== this.acc.serverUrl) {
LogUtil.write(`编辑账号:服务地址自动修正 ${this.acc.serverUrl} → ${resolved.url}(${resolved.via})`);
this.acc.serverUrl = resolved.url;
} else if (!resolved.ok && resolved.hint !== '') {
this.isLoading = false;
this.statusMsg = resolved.hint;
this.statusOk = false;
return;
}
} catch (err) {
LogUtil.write(`编辑账号:地址发现异常,回退原地址 ${(err as BusinessError).message}`);
}
try {
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, auth);
this.isLoading = false; this.isLoading = false;
if (disc.statusCode === 401) { if (disc.statusCode === 401) {
this.statusMsg = '登录已失效,请检查账号密码'; this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
this.statusOk = false; this.statusOk = false;
return; return;
} }
if (disc.statusCode !== 207 && disc.statusCode !== 200) { if (disc.statusCode !== 207 && disc.statusCode !== 200) {
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`; this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
this.statusOk = false; this.statusOk = false;
return; return;
} }
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem => const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
new EditCalendarItem(e.href, e.displayName, e.color)); new EditCalendarItem(e.href, e.displayName, e.color, e.comps));
if (list.length === 0) { if (list.length === 0) {
LogUtil.write('编辑账号:未发现任何日历本'); LogUtil.write('编辑账号:未发现任何日历本');
this.statusMsg = '该路径下未发现日历本'; this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
this.statusOk = false; this.statusOk = false;
} else { } else {
// 已勾选的日历本按账号当前配置预选 // 已勾选的日历本按账号当前配置预选
@@ -217,8 +259,42 @@ struct EditAccountPage {
target.calendarHrefs = newHrefs; target.calendarHrefs = newHrefs;
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name); target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color); target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
target.calendarComps = selectedItems.map((c: EditCalendarItem): string => c.comps);
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`); LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey // 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
// 序号收窄后,被取消本的旧 calKey 会被前面位移下来的本"接管",
// 不先删的话旧行会与新主的行混在同一个 calKey 里(2026-09-29 实测:
// 服务器删除「读书」后重选,其 988 行混进「轻舟」的 calKey,镜像一直复活幽灵数据)。
const newHrefSet: Set<string> = new Set<string>();
for (const c of newHrefs) {
newHrefSet.add(c);
}
const removedHrefs: string[] = [];
for (const h of oldHrefs) {
if (!newHrefSet.has(h)) {
removedHrefs.push(h);
}
}
if (removedHrefs.length > 0) {
const pruned: number = await EventDb.pruneByHrefs(context, target.id, removedHrefs);
LogUtil.write(`编辑账号保存:取消勾选/已失效 ${removedHrefs.length} 个日历本,清理本地日程 ${pruned} 行`);
}
// ⭐ 被取消勾选的日历本,其系统日历镜像账户也要一并删掉(否则取消同步后系统日历仍有幽灵账户)。
// 此时账号配置即将被改写、listBooks 已解析不到它,故用 href 直接算稳定 bookId(与镜像侧同算法)。
// ⚠️ 用稳定 bookId 删 —— 不同账号下有同名日历本时,bookId 里带 accId 前缀,绝不会删错。
for (let oi: number = 0; oi < oldHrefs.length; oi++) {
const oh: string = oldHrefs[oi];
if (newHrefSet.has(oh)) {
continue;
}
const bookId: string = SystemCalendarMirror.bookIdOf(target.id, oh, `${target.id}_${oi}`);
try {
await SystemCalendarMirror.removeById(context, bookId);
} catch (err) {
// 镜像账户删除失败不阻断保存(下次镜像前的孤儿清理还会兜底)
}
}
const remapPairs: Array<[string, string]> = []; const remapPairs: Array<[string, string]> = [];
for (let oi: number = 0; oi < oldHrefs.length; oi++) { for (let oi: number = 0; oi < oldHrefs.length; oi++) {
const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]); const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]);
@@ -243,8 +319,15 @@ struct EditAccountPage {
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`); selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
await EventDb.pruneAccountEvents(context, target.id, validKeys); await EventDb.pruneAccountEvents(context, target.id, validKeys);
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id); AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
this.getUIContext().getPromptAction() // ⭐ 若刚被取消勾选的本正是「系统日历 → CalDAV」的备份目标,**当场**判它失效并关闭备份功能。
.showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` }); // 不这么做的话,要等下一次同步才会发现目标没了;这期间开关还显示"备份到 CalDAV",
// 用户会以为备份仍在正常工作,而实际已经导不进去(或更糟:导进了别的本)。
const backupCheck: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context);
this.getUIContext().getPromptAction().showToast({
message: backupCheck.closed
? '原系统日历备份目标已被取消同步,备份功能已自动关闭'
: `已保存,同步 ${selectedItems.length} 个日历本`
});
router.back(); router.back();
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
@@ -439,6 +522,19 @@ struct EditAccountPage {
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.alignItems(HorizontalAlign.Center) .alignItems(HorizontalAlign.Center)
} }
/**
* 页面转场:淡入淡出。
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
*/
pageTransition() {
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
}
} }
@Component @Component
@@ -448,6 +544,22 @@ struct EditCalendarRow {
build() { build() {
Row({ space: 12 }) { Row({ space: 12 }) {
// ⭐ SVG_ICON 类型图标:放在颜色圆点**前面**,
// 让用户先看清"这本是日历还是待办",再看它是什么颜色。
//
// 用 SVG 而不是字符:字符图标(▦ / ✓)依赖系统字体字形,
// 笔画粗细与视觉重量不统一,跨设备还可能缺字形变成方框。
// 这两个 SVG 共用同一套笔触(stroke 2、round 端点、24×24),
// 渲染成 16vp 时重量一致。
//
// 混合本(both):显示待办图标(它确实能存待办),
// 但用户看名字/颜色仍能分辨,不必塞两个图标挤在一行。
Image(this.item.comps === 'todo' || this.item.comps === 'both'
? $r('app.media.ic_todo_book')
: $r('app.media.ic_event_book'))
.width(16)
.height(16)
.objectFit(ImageFit.Contain)
if (this.item.color !== '') { if (this.item.color !== '') {
Circle() Circle()
.width(12) .width(12)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
// entry/src/main/ets/pages/widget/TodoWidget2x2.ets
// 2x2 待办服务卡片:突出未完成待办总数 + 今日到期提示。
// 本卡片**不展示待办列表**,因此没有打勾交互;点击整卡进入 App 的待办页。
let storageTodo2x2 = new LocalStorage();
@Entry(storageTodo2x2)
@Component
struct TodoWidget2x2Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
/** 右上角添加按钮:拉起 App 直接进入新建待办页 */
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
/** 打开 App 并进入待办 Tab */
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
build() {
Column({ space: 6 }) {
// 顶部:日期 + 添加按钮
Row({ space: 6 }) {
Column({ space: 2 }) {
Text(this.dateMd)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
Text(`${this.weekday === '' ? '' : this.weekday + ' '}${this.lunarText}`)
.fontSize(11)
.fontColor('#8A8A8A')
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
// 未完成待办大数字
Row({ space: 8 }) {
Text(`${this.todoCount}`)
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor('#6A4C93')
Column({ space: 2 }) {
Text('待办未完成')
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
// 今日到期优先提示,没有则给操作引导
Text(this.todoTodayCount > 0 ? `今日到期 ${this.todoTodayCount} 项`
: (this.todoCount === 0 ? '点击添加' : '今日无到期'))
.fontSize(10)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => this.openApp())
}
}
@@ -0,0 +1,232 @@
// entry/src/main/ets/pages/widget/TodoWidget2x4.ets
// 2x4 待办服务卡片:左侧 = 未完成总数 + 今日到期(同 2x2);右侧 = 最近 2 条待办。
// 右侧带**圆形勾选框**,点击即完成并推送回服务器。
let storageTodo2x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem2x4 {
id: number = 0;
title: string = '';
dueText: string = '';
/** 副行:截止时间 · 日历本 */
subText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo2x4)
@Component
struct TodoWidget2x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
/** 右侧列表数据(可滚动;数据侧已按截止时间排序并限制在 20 条内) */
private parseTodos(): TodoItem2x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem2x4[];
} catch (err) {
return [];
}
}
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
/** 打开 App 并进入待办 Tab */
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
/**
* 点击圆形勾选框 → 让 FormExtensionAbility 去完成这条待办。
*
* ⭐ 用 message 而不是 call:message 会进入 onFormEvent,能在
* FormExtension 的 10 秒窗口内完成"改库 + PUT 推送 + 刷新卡片",
* 不需要拉起主 App(拉起会闪界面,体验差)。
* ⭐ 带上 localId:卡片只知道自己渲染的是哪一条,改库要靠主键。
*/
private checkTodo(id: number): void {
postCardAction(this, {
action: 'message',
params: { todoCheck: `${id}` }
});
}
/** 右侧单条待办:勾选框 + 标题 + 截止时间 */
/**
* 单条待办:与 4×4 / 6×4 同款结构(圆圈用日历本色 + 标题 + 副行),
* 尺寸再收一档 —— 2×4 只有 2 格高,圈 16 / 标题 12 / 副行 9。
*/
@Builder
todoRow(item: TodoItem2x4) {
Row({ space: 6 }) {
// 勾选圈:颜色用日历本色,点击即完成
Row() {
Text('')
.width(16)
.height(16)
.borderRadius(8)
.border({ width: 2, color: item.color })
}
.padding({ right: 2 })
.onClick(() => {
this.checkTodo(item.id);
})
Column({ space: 1 }) {
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.subText)
.fontSize(9)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 4, right: 8, top: 5, bottom: 5 })
.borderRadius(9)
.backgroundColor('#F5F7FA')
}
/** 右侧空态 */
@Builder
emptyHint() {
Column({ space: 4 }) {
Text(this.todoCount === 0 ? '暂无待办' : '今日无到期')
.fontSize(12)
.fontColor('#8A8A8A')
.maxLines(2)
.textAlign(TextAlign.Center)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
build() {
Row({ space: 10 }) {
// ===== 左半:未完成总数 =====
Column({ space: 6 }) {
Row({ space: 6 }) {
Column({ space: 2 }) {
Text(this.dateMd)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${this.weekday === '' ? '' : this.weekday + ' '}${this.lunarText}`)
.fontSize(10)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
Row({ space: 6 }) {
Text(`${this.todoCount}`)
.fontSize(38)
.fontWeight(FontWeight.Bold)
.fontColor('#6A4C93')
Column({ space: 2 }) {
Text('待办未完成')
.fontSize(11)
.fontColor('#1A1A1A')
.maxLines(1)
Text(this.todoTodayCount > 0 ? `今日到期 ${this.todoTodayCount} 项`
: (this.todoCount === 0 ? '点击添加' : '今日无到期'))
.fontSize(9)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.height('100%')
Divider()
.vertical(true)
.height('92%')
.strokeWidth(0.5)
.color('#E5E5E5')
// ===== 右半:最近 2 条待办(可打勾)=====
Column() {
if (this.parseTodos().length === 0) {
this.emptyHint()
} else {
// ⭐ List 可滚动:2x4 原来只渲染固定 2 条(Column + slice),
// 待办是清单性质、条数常十几条,只看 2 条价值太低。
// List 自带虚拟化与滚动惯性,条目多也不占内存。
List({ space: 3 }) {
ForEach(this.parseTodos(), (item: TodoItem2x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem2x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => this.openApp())
}
}
@@ -0,0 +1,216 @@
// entry/src/main/ets/pages/widget/TodoWidget4x4.ets
// 4x4 待办服务卡片:顶部标题栏(未完成数 / 今日到期)+ 可滚动待办列表。
// 每条待办左侧为圆形勾选框,点击即完成并推送回服务器。
let storageTodo4x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem4x4 {
id: number = 0;
title: string = '';
dueText: string = '';
/** 副行:截止时间 · 日历本 */
subText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo4x4)
@Component
struct TodoWidget4x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
private parseTodos(): TodoItem4x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem4x4[];
} catch (err) {
return [];
}
}
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
/** 点击勾选框 → 交给 FormExtension 完成(带 localId) */
private checkTodo(id: number): void {
postCardAction(this, {
action: 'message',
params: { todoCheck: `${id}` }
});
}
/** 圆形勾选框(空心圆,点击后由数据刷新从列表中消失) */
@Builder
checkbox(id: number) {
// ⚠️ 不用 Circle().stroke() —— 那是 SDK 26 的 API,本工程 compatibleSdkVersion 为
// 6.1.1(24),真机上 stroke 不生效会导致圆圈不可见。
// 改用 border + 50% 圆角,视觉一致且从 API 7 起就支持。
Column()
.width(20)
.height(20)
.borderRadius(10)
.border({ width: 1.5, color: '#B0B0B0' })
.backgroundColor(Color.Transparent)
.onClick(() => this.checkTodo(id))
}
/**
* 单条待办:与 App 内 Index.todoRow 同款结构 ——
* 圆圈(颜色跟随日历本)+ 标题 + 副行「截止时间 · 日历本」
*
* ⚠️ 未完成=空心环、已完成=实心+✓,这套语义与 App 内一致;
* 卡片上只显示未完成,所以这里主要是空心环。
* ⚠️ 4×4 与 6×4 的 todoRow 必须**完全一致**(用户要求),改一处要同步另一处。
*/
@Builder
todoRow(item: TodoItem4x4) {
Row({ space: 8 }) {
// 勾选圈:颜色用日历本色(与 App 内一致),点击即完成
Row() {
Text('')
.width(18)
.height(18)
.borderRadius(9)
.border({ width: 2, color: item.color })
}
.padding({ right: 2 })
.onClick(() => {
this.checkTodo(item.id);
})
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.subText)
.fontSize(10)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 6, right: 8, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F5F7FA')
}
/** 顶部标题栏 */
@Builder
headerBar() {
Row({ space: 8 }) {
Column({ space: 2 }) {
Row({ space: 6 }) {
Text('待办')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(`${this.todoCount}`)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#6A4C93')
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
Text(this.todoTodayCount > 0
? `${this.dateMd} ${this.weekday} · 今日到期 ${this.todoTodayCount} 项`
: `${this.dateMd} ${this.weekday} · 今日无到期`)
.fontSize(10)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
}
/** 空态 */
@Builder
emptyState() {
Column({ space: 8 }) {
Text('✓')
.fontSize(30)
.fontColor('#C7C7CC')
Text(this.todoCount === 0 ? '所有待办都完成了' : '暂无待办')
.fontSize(13)
.fontColor('#8A8A8A')
Text('点右上角 + 新建')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
build() {
Column({ space: 8 }) {
this.headerBar()
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
if (this.parseTodos().length === 0) {
this.emptyState()
} else {
// List 虚拟化:待办多时滑动流畅
List({ space: 4 }) {
ForEach(this.parseTodos(), (item: TodoItem4x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem4x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
}
@@ -0,0 +1,209 @@
// entry/src/main/ets/pages/widget/TodoWidget6x4.ets
// 6x4 待办服务卡片:4x4 的横向扩展。左侧 = 统计概览,右侧 = 可滚动的待办列表(更多条目)。
// 每条待办左侧圆形勾选框,点击即完成并推送回服务器。
let storageTodo6x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem6x4 {
id: number = 0;
title: string = '';
dueText: string = '';
/** 副行:截止时间 · 日历本 */
subText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo6x4)
@Component
struct TodoWidget6x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
private parseTodos(): TodoItem6x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem6x4[];
} catch (err) {
return [];
}
}
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
private checkTodo(id: number): void {
postCardAction(this, {
action: 'message',
params: { todoCheck: `${id}` }
});
}
@Builder
checkbox(id: number) {
// ⚠️ 不用 Circle().stroke() —— 那是 SDK 26 的 API,本工程 compatibleSdkVersion 为
// 6.1.1(24),真机上 stroke 不生效会导致圆圈不可见。
// 改用 border + 50% 圆角,视觉一致且从 API 7 起就支持。
Column()
.width(20)
.height(20)
.borderRadius(10)
.border({ width: 1.5, color: '#B0B0B0' })
.backgroundColor(Color.Transparent)
.width(20)
.height(20)
.onClick(() => this.checkTodo(id))
}
/**
* 单条待办:与 App 内 Index.todoRow 同款结构 ——
* 圆圈(颜色跟随日历本)+ 标题 + 副行「截止时间 · 日历本」
*
* ⚠️ 未完成=空心环、已完成=实心+✓,这套语义与 App 内一致;
* 卡片上只显示未完成,所以这里主要是空心环。
* ⚠️ 4×4 与 6×4 的 todoRow 必须**完全一致**(用户要求),改一处要同步另一处。
*/
@Builder
todoRow(item: TodoItem6x4) {
Row({ space: 8 }) {
// 勾选圈:颜色用日历本色(与 App 内一致),点击即完成
Row() {
Text('')
.width(18)
.height(18)
.borderRadius(9)
.border({ width: 2, color: item.color })
}
.padding({ right: 2 })
.onClick(() => {
this.checkTodo(item.id);
})
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.subText)
.fontSize(10)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 6, right: 8, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F5F7FA')
}
@Builder
headerBar() {
Row({ space: 8 }) {
Row({ space: 6 }) {
Text('待办')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(`${this.todoCount}`)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#6A4C93')
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
Text(this.todoTodayCount > 0
? `${this.dateMd} ${this.weekday} · 今日到期 ${this.todoTodayCount} 项`
: `${this.dateMd} ${this.weekday} · 今日无到期`)
.fontSize(10)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
.layoutWeight(1)
this.addButton()
}
.width('100%')
}
@Builder
emptyState() {
Column({ space: 8 }) {
Text('✓')
.fontSize(30)
.fontColor('#C7C7CC')
Text(this.todoCount === 0 ? '所有待办都完成了' : '暂无待办')
.fontSize(13)
.fontColor('#8A8A8A')
Text('点右上角 + 新建')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
build() {
Column({ space: 8 }) {
this.headerBar()
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
if (this.parseTodos().length === 0) {
this.emptyState()
} else {
List({ space: 4 }) {
ForEach(this.parseTodos(), (item: TodoItem6x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem6x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
}
@@ -11,8 +11,12 @@ struct Widget2x2Card {
@LocalStorageProp('dateMd') dateMd: string = ''; @LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = ''; @LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todayCount') todayCount: number = 0; @LocalStorageProp('todayCount') todayCount: number = 0;
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
@LocalStorageProp('selectedDate') selectedDate: number = 0;
/** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */ /** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder @Builder
addButton() { addButton() {
Button() { Button() {
@@ -35,12 +39,12 @@ struct Widget2x2Card {
}) })
} }
/** 卡片"打开 App":点击日期区或今日日程数区域触发;添加按钮是 header 行的兄弟节点,其点击不会冒泡到这里 */ /** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
private openApp(): void { private openApp(): void {
postCardAction(this, { postCardAction(this, {
action: 'router', action: 'router',
abilityName: 'EntryAbility', abilityName: 'EntryAbility',
params: {} params: { openDate: `${this.selectedDate}` }
}); });
} }
@@ -61,7 +65,6 @@ struct Widget2x2Card {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.addButton() this.addButton()
} }
@@ -92,12 +95,13 @@ struct Widget2x2Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.onClick(() => this.openApp())
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
.padding(14) .padding(14)
.backgroundColor('#FFFFFF') .backgroundColor('#FFFFFF')
.borderRadius(16) .borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日(原来只有日期区/条数区可点)
.onClick(() => this.openApp())
} }
} }
+18 -14
View File
@@ -23,12 +23,13 @@ struct Widget4x2Card {
@LocalStorageProp('lunarText') lunarText: string = ''; @LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todayCount') todayCount: number = 0; @LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('ongoingJson') ongoingJson: string = '[]'; @LocalStorageProp('ongoingJson') ongoingJson: string = '[]';
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
@LocalStorageProp('selectedDate') selectedDate: number = 0;
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */ /** 右侧列表数据(可滚动;数据侧已按时间升序并限制在 15 条内) */
private parseOngoing(): CardItem2x4[] { private parseOngoing(): CardItem2x4[] {
try { try {
const all: CardItem2x4[] = JSON.parse(this.ongoingJson) as CardItem2x4[]; return JSON.parse(this.ongoingJson) as CardItem2x4[];
return all.slice(0, 2);
} catch (err) { } catch (err) {
return []; return [];
} }
@@ -57,12 +58,12 @@ struct Widget4x2Card {
}) })
} }
/** 卡片"打开 App":点击左侧日期区、今日条数区、右侧日程区触发;添加按钮是 header 行兄弟节点,不冒泡 */ /** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
private openApp(): void { private openApp(): void {
postCardAction(this, { postCardAction(this, {
action: 'router', action: 'router',
abilityName: 'EntryAbility', abilityName: 'EntryAbility',
params: {} params: { openDate: `${this.selectedDate}` }
}); });
} }
@@ -100,7 +101,7 @@ struct Widget4x2Card {
} }
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.width('100%') .width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }) .padding({ left: 6, right: 8, top: 4, bottom: 4 })
.borderRadius(8) .borderRadius(8)
.backgroundColor('#F5F7FA') .backgroundColor('#F5F7FA')
} }
@@ -118,7 +119,6 @@ struct Widget4x2Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} }
build() { build() {
@@ -141,7 +141,6 @@ struct Widget4x2Card {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.addButton() this.addButton()
} }
@@ -173,7 +172,6 @@ struct Widget4x2Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.onClick(() => this.openApp())
} }
.layoutWeight(1) .layoutWeight(1)
.height('100%') .height('100%')
@@ -184,20 +182,24 @@ struct Widget4x2Card {
.strokeWidth(0.5) .strokeWidth(0.5)
.color('#E5E5E5') .color('#E5E5E5')
// ===== 右半:当前时间之后最近的两条日程(不滚动)===== // ===== 右半:未来日程列表(可滚动,最多 15 条)=====
// ⭐ 原来固定 Column + 只取 2 条,用户想看后天的安排就看不到。
// 改为 List:自带虚拟化与滚动惯性,条目多也不占内存。
Column() { Column() {
if (this.parseOngoing().length === 0) { if (this.parseOngoing().length === 0) {
this.emptyHint() this.emptyHint()
} else { } else {
Column({ space: 6 }) { List({ space: 4 }) {
ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => { ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => {
ListItem() {
this.ongoingRow(item) this.ongoingRow(item)
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`) }
}, (item: CardItem2x4, idx: number) => `o${idx}_${item.time}_${item.title}`)
} }
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .scrollBar(BarState.Off)
.onClick(() => this.openApp()) .edgeEffect(EdgeEffect.Spring)
} }
} }
.layoutWeight(1) .layoutWeight(1)
@@ -208,5 +210,7 @@ struct Widget4x2Card {
.padding(12) .padding(12)
.backgroundColor('#FFFFFF') .backgroundColor('#FFFFFF')
.borderRadius(16) .borderRadius(16)
// ⭐ 整卡可点:左右空白处也能进 App 并定位到当前显示日
.onClick(() => this.openApp())
} }
} }
+17 -23
View File
@@ -110,41 +110,33 @@ struct Widget4x4Card {
return r >= 0 && Math.floor(r * 24) === h; return r >= 0 && Math.floor(r * 24) === h;
} }
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 /** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ * (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
private w4Range(): W4Range { private w4Range(): W4Range {
const rg = new W4Range(); const rg = new W4Range();
const bs: TBlock[] = this.parseBlocks(); const bs: TBlock[] = this.parseBlocks();
let s: number = -1; let s: number = 8;
let e: number = -1; let e: number = 18;
for (const b of bs) { for (const b of bs) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
const r: number = this.w4NowR(); const r: number = this.w4NowR();
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 // 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1; const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1;
if (s < 0) { if (nh >= 0 && nh < s && s < 8) {
s = nh >= 0 ? nh : 8;
}
if (e < 0) {
e = nh >= 0 ? nh + 1 : 20;
}
if (nh >= 0) {
if (nh < s) {
s = nh; s = nh;
} }
if (nh + 1 > e) { if (nh >= 0 && nh + 1 > e && e > 18) {
e = nh + 1; e = nh + 1;
} }
}
if (s < 0) { if (s < 0) {
s = 0; s = 0;
} }
@@ -395,7 +387,9 @@ struct Widget4x4Card {
return h < 10 ? `0${h}` : `${h}`; return h < 10 ? `0${h}` : `${h}`;
} }
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡到其它点击区) */ /** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder @Builder
addButton() { addButton() {
Button() { Button() {
@@ -450,6 +444,7 @@ struct Widget4x4Card {
.borderRadius(11) .borderRadius(11)
.padding(0) .padding(0)
.backgroundColor('#F2F3F5') .backgroundColor('#F2F3F5')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
.onClick(() => this.page(act)) .onClick(() => this.page(act))
} }
@@ -470,6 +465,7 @@ struct Widget4x4Card {
.borderRadius(11) .borderRadius(11)
.padding(0) .padding(0)
.backgroundColor('#007DFF') .backgroundColor('#007DFF')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
.onClick(() => this.page('today')) .onClick(() => this.page('today'))
} }
} }
@@ -562,7 +558,6 @@ struct Widget4x4Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} else { } else {
List({ space: 4 }) { List({ space: 4 }) {
ForEach(this.parseList(), (item: LItem4) => { ForEach(this.parseList(), (item: LItem4) => {
@@ -576,7 +571,6 @@ struct Widget4x4Card {
.layoutWeight(1) .layoutWeight(1)
.scrollBar(BarState.Auto) .scrollBar(BarState.Auto)
.cachedCount(8) .cachedCount(8)
.onClick(() => this.openApp())
} }
} }
@@ -597,7 +591,6 @@ struct Widget4x4Card {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
this.pageBtn('›', 'next') this.pageBtn('›', 'next')
this.sideBtn() this.sideBtn()
} }
@@ -638,7 +631,6 @@ struct Widget4x4Card {
.padding({ left: 5, right: 5 }) .padding({ left: 5, right: 5 })
.borderRadius(5) .borderRadius(5)
.backgroundColor(a.color) .backgroundColor(a.color)
.onClick(() => this.openApp())
}, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`) }, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`)
if (this.w4AllDayRest() > 0) { if (this.w4AllDayRest() > 0) {
Text(`还有 ${this.w4AllDayRest()} 个全天日程`) Text(`还有 ${this.w4AllDayRest()} 个全天日程`)
@@ -698,6 +690,9 @@ struct Widget4x4Card {
.padding(12) .padding(12)
.backgroundColor('#FFFFFF') .backgroundColor('#FFFFFF')
.borderRadius(16) .borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
.onClick(() => this.openApp())
} }
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */ /** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@@ -823,7 +818,6 @@ struct Widget4x4Card {
.width('100%') .width('100%')
.height(this.w4BlockVp(b)) .height(this.w4BlockVp(b))
.constraintSize({ minHeight: 14 }) .constraintSize({ minHeight: 14 })
.onClick(() => this.openApp())
}, (b: TBlock) => `b_${r.key}_${b.eventKey}`) }, (b: TBlock) => `b_${r.key}_${b.eventKey}`)
} }
.layoutWeight(1) .layoutWeight(1)
+17 -23
View File
@@ -109,41 +109,33 @@ struct Widget6x4Card {
return r >= 0 && Math.floor(r * 24) === h; return r >= 0 && Math.floor(r * 24) === h;
} }
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 /** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ * (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
private w6Range(): W6Range { private w6Range(): W6Range {
const rg = new W6Range(); const rg = new W6Range();
const bs: TBlock6[] = this.parseBlocks(); const bs: TBlock6[] = this.parseBlocks();
let s: number = -1; let s: number = 8;
let e: number = -1; let e: number = 18;
for (const b of bs) { for (const b of bs) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
const r: number = this.w6NowR(); const r: number = this.w6NowR();
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 // 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1; const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1;
if (s < 0) { if (nh >= 0 && nh < s && s < 8) {
s = nh >= 0 ? nh : 8;
}
if (e < 0) {
e = nh >= 0 ? nh + 1 : 20;
}
if (nh >= 0) {
if (nh < s) {
s = nh; s = nh;
} }
if (nh + 1 > e) { if (nh >= 0 && nh + 1 > e && e > 18) {
e = nh + 1; e = nh + 1;
} }
}
if (s < 0) { if (s < 0) {
s = 0; s = 0;
} }
@@ -392,7 +384,9 @@ struct Widget6x4Card {
return h < 10 ? `0${h}:00` : `${h}:00`; return h < 10 ? `0${h}:00` : `${h}:00`;
} }
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡) */ /** 右上角添加按钮:拉起 App 直接进入新建日程页。
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
* 故点 + 不会连带触发根容器的"打开首页"。 */
@Builder @Builder
addButton() { addButton() {
Button() { Button() {
@@ -447,6 +441,7 @@ struct Widget6x4Card {
.borderRadius(11) .borderRadius(11)
.padding(0) .padding(0)
.backgroundColor('#F2F3F5') .backgroundColor('#F2F3F5')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
.onClick(() => this.page(act)) .onClick(() => this.page(act))
} }
@@ -465,6 +460,7 @@ struct Widget6x4Card {
.borderRadius(11) .borderRadius(11)
.padding(0) .padding(0)
.backgroundColor('#007DFF') .backgroundColor('#007DFF')
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
.onClick(() => this.page('today')) .onClick(() => this.page('today'))
} }
} }
@@ -557,7 +553,6 @@ struct Widget6x4Card {
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} else { } else {
List({ space: 4 }) { List({ space: 4 }) {
ForEach(this.parseList(), (item: LItem6) => { ForEach(this.parseList(), (item: LItem6) => {
@@ -571,7 +566,6 @@ struct Widget6x4Card {
.layoutWeight(1) .layoutWeight(1)
.scrollBar(BarState.Auto) .scrollBar(BarState.Auto)
.cachedCount(8) .cachedCount(8)
.onClick(() => this.openApp())
} }
} }
@@ -592,7 +586,6 @@ struct Widget6x4Card {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
Text(`${this.dayCount} 条`) Text(`${this.dayCount} 条`)
.fontSize(10) .fontSize(10)
.fontColor('#B0B0B0') .fontColor('#B0B0B0')
@@ -637,7 +630,6 @@ struct Widget6x4Card {
.padding({ left: 6, right: 6 }) .padding({ left: 6, right: 6 })
.borderRadius(6) .borderRadius(6)
.backgroundColor(a.color) .backgroundColor(a.color)
.onClick(() => this.openApp())
}, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`) }, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`)
if (this.w6AllDayRest() > 0) { if (this.w6AllDayRest() > 0) {
Text(`还有 ${this.w6AllDayRest()} 个全天日程`) Text(`还有 ${this.w6AllDayRest()} 个全天日程`)
@@ -697,6 +689,9 @@ struct Widget6x4Card {
.padding(12) .padding(12)
.backgroundColor('#FFFFFF') .backgroundColor('#FFFFFF')
.borderRadius(16) .borderRadius(16)
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
.onClick(() => this.openApp())
} }
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */ /** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@@ -822,7 +817,6 @@ struct Widget6x4Card {
.width('100%') .width('100%')
.height(this.w6BlockVp(b)) .height(this.w6BlockVp(b))
.constraintSize({ minHeight: 16 }) .constraintSize({ minHeight: 16 })
.onClick(() => this.openApp())
}, (b: TBlock6) => `b_${r.key}_${b.eventKey}`) }, (b: TBlock6) => `b_${r.key}_${b.eventKey}`)
} }
.layoutWeight(1) .layoutWeight(1)
+11 -50
View File
@@ -1,14 +1,13 @@
// entry/src/main/ets/syncwork/SyncWorkAbility.ets // entry/src/main/ets/syncwork/SyncWorkAbility.ets
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步 // 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步。
// 注意:必须幂等——同步本身基于 ETag 全量比对,天然幂等;执行完必须 stopWork,否则系统按超时处理 //
// ⚠️ 约束(勿改):
// - 单次回调**上限 2 分钟**,超时进程被系统终止 → 逻辑必须轻量(本类只调 Runner,不做事后处理)。
// - 周期由 BackgroundSyncService 设为 2 小时(系统活跃分组下限),此处不再重复设置。
// - 必须幂等:同步基于 etag 全量比对 + dirty 标记,天然幂等。
// - 必须主动 stopWork,否则系统按超时处理。
import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit'; import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
import { SyncEngine } from '../common/SyncEngine';
import { CardDataService } from '../common/CardDataService';
import { ReminderService } from '../common/ReminderService';
import { SystemCalendarImport } from '../common/SystemCalendarImport';
import { AppSettings } from '../common/AppSettings';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
@@ -24,47 +23,9 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
private async doSync(workInfo: workScheduler.WorkInfo): Promise<void> { private async doSync(workInfo: workScheduler.WorkInfo): Promise<void> {
try { try {
const context = this.context; // 完整的后台同步流程(导入/对账/逐账号同步/镜像/卡片/提醒)统一由 Runner 实现,
const accounts: DavAccount[] = await AccountStore.loadAll(context); // 与"网络恢复补同步"、"回到前台补同步"共用同一套逻辑,避免三处实现漂移。
// 备份模式:先导入系统本地日历(幂等),再随同步推送 await BackgroundSyncRunner.runOnce(this.context, 'deferred');
try {
await SystemCalendarImport.importIfNeeded(context);
} catch (err) {
// 导入失败不影响正常同步
}
let ok: number = 0;
let attempted: number = 0;
for (const acc of accounts) {
if (acc.type !== TYPE_CALDAV) {
continue;
}
attempted++;
try {
// 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 5 分钟),不再套外层超时
await SyncEngine.syncAccount(context, acc);
ok++;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`延迟任务同步 id=${acc.id} 失败: ${e.message}`);
}
}
// 全部账号同步成功才结束"一次性全量重拉"状态
if (attempted > 0 && ok === attempted) {
await AppSettings.markFullRefetchDone(context);
}
await SyncEngine.settleLocalEvents(context);
await SyncEngine.pruneOrphanRows(context, accounts);
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失,
// 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。
await AccountStore.saveAll(context, accounts);
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
await CardDataService.pushToAllForms(context);
await ReminderService.refreshReminders(context);
LogUtil.write(`延迟任务同步完成:${ok} 个账号`);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`延迟任务同步失败: ${e.message}`);
} finally { } finally {
// 必须主动结束,否则系统按 120 秒超时处理 // 必须主动结束,否则系统按 120 秒超时处理
workScheduler.stopWork(workInfo); workScheduler.stopWork(workInfo);
+2 -18
View File
@@ -12,9 +12,6 @@
{ {
"name": "ohos.permission.INTERNET" "name": "ohos.permission.INTERNET"
}, },
{
"name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
},
{ {
"name": "ohos.permission.PUBLISH_AGENT_REMINDER" "name": "ohos.permission.PUBLISH_AGENT_REMINDER"
}, },
@@ -39,18 +36,8 @@
} }
}, },
{ {
"name": "ohos.permission.LOCATION", "name": "ohos.permission.WRITE_CALENDAR",
"reason": "$string:perm_location", "reason": "$string:perm_write_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:perm_location_approx",
"usedScene": { "usedScene": {
"abilities": [ "abilities": [
"EntryAbility" "EntryAbility"
@@ -73,9 +60,6 @@
"startWindowBackground": "$color:start_window_background", "startWindowBackground": "$color:start_window_background",
"exported": true, "exported": true,
"orientation": "auto_rotation_restricted", "orientation": "auto_rotation_restricted",
"backgroundModes": [
"dataTransfer"
],
"skills": [ "skills": [
{ {
"entities": [ "entities": [
@@ -19,6 +19,8 @@
{ "name": "grid_line", "value": "#14000000" }, { "name": "grid_line", "value": "#14000000" },
{ "name": "today_bg", "value": "#14007DFF" }, { "name": "today_bg", "value": "#14007DFF" },
{ "name": "selected_bg", "value": "#007DFF" }, { "name": "selected_bg", "value": "#007DFF" },
{ "name": "chip_off_bg", "value": "#0D000000" } { "name": "chip_off_bg", "value": "#0D000000" },
{ "name": "event_mark", "value": "#D93025" },
{ "name": "soft_red", "value": "#F2938A" }
] ]
} }
@@ -18,20 +18,12 @@
}, },
{ {
"name": "perm_write_calendar", "name": "perm_write_calendar",
"value": "写入系统日历,用于保存您创建的日程" "value": "写入系统日历,用于把您选择的 CalDAV 日历本镜像到系统日历,让小艺、桌面日历卡片和手表也能看到您的日程"
}, },
{ {
"name": "perm_read_whole_calendar", "name": "perm_read_whole_calendar",
"value": "读取所有日历账户的日程,用于在日历中统一展示" "value": "读取所有日历账户的日程,用于在日历中统一展示"
}, },
{
"name": "perm_location",
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
},
{
"name": "perm_location_approx",
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
},
{ {
"name": "card_ability_desc", "name": "card_ability_desc",
"value": "同步日历服务卡片" "value": "同步日历服务卡片"
@@ -59,6 +51,26 @@
{ {
"name": "sync_work_desc", "name": "sync_work_desc",
"value": "日历后台兜底同步任务" "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", "scheduledUpdateTime": "07:30",
"updateDuration": 1, "updateDuration": 1,
"defaultDimension": "2*2", "defaultDimension": "2*2",
"supportDimensions": ["2*2"] "supportDimensions": [
"2*2"
]
}, },
{ {
"name": "WidgetCard2x4", "name": "WidgetCard2x4",
@@ -34,7 +36,9 @@
"scheduledUpdateTime": "07:30", "scheduledUpdateTime": "07:30",
"updateDuration": 1, "updateDuration": 1,
"defaultDimension": "2*4", "defaultDimension": "2*4",
"supportDimensions": ["2*4"] "supportDimensions": [
"2*4"
]
}, },
{ {
"name": "WidgetCard4x4", "name": "WidgetCard4x4",
@@ -52,7 +56,9 @@
"scheduledUpdateTime": "07:30", "scheduledUpdateTime": "07:30",
"updateDuration": 1, "updateDuration": 1,
"defaultDimension": "4*4", "defaultDimension": "4*4",
"supportDimensions": ["4*4"] "supportDimensions": [
"4*4"
]
}, },
{ {
"name": "WidgetCard6x4", "name": "WidgetCard6x4",
@@ -70,7 +76,89 @@
"scheduledUpdateTime": "07:30", "scheduledUpdateTime": "07:30",
"updateDuration": 1, "updateDuration": 1,
"defaultDimension": "6*4", "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/CalendarListPage",
"pages/EditAccountPage", "pages/EditAccountPage",
"pages/EventEditPage", "pages/EventEditPage",
"pages/TodoEditPage",
"pages/SettingsPage" "pages/SettingsPage"
] ]
} }
@@ -19,6 +19,8 @@
{ "name": "grid_line", "value": "#1FFFFFFF" }, { "name": "grid_line", "value": "#1FFFFFFF" },
{ "name": "today_bg", "value": "#330A84FF" }, { "name": "today_bg", "value": "#330A84FF" },
{ "name": "selected_bg", "value": "#0A84FF" }, { "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);