28 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
26 changed files with 4995 additions and 219 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": {
"bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq",
"versionCode": 10200,
"versionName": "0.2.0",
"versionCode": 10201,
"versionName": "0.2.1",
"buildVersion": "1",
"icon": "$media:layered_image",
"label": "$string:app_name"
+133 -4
View File
@@ -2,12 +2,41 @@
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
- 应用市场送审版本:**0.1.0** | 仓库当前:**0.1.0**(versionCode 10000)
- 应用市场送审版本:**0.2.0** | 仓库当前:**0.2.0**(versionCode 10200)
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://feedlite.yangyq.net/?sw=synccalendar>
- 开源协议:Apache License 2.0
---
## 📝 最近更新
### v0.2.0(2026-11)
**待办(VTODO)从只读升级为完整双向同步**
- 新增 `TodoService`:新建 / 编辑 / 打勾完成 / 删除待办,全部推送回服务器
- 打过勾会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT
- 修改前先 GET 原始 `.ics`,原地改写而不是整份重建 —— 保留服务器上本应用不认识的属性
- 日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
**同步核心修复(3 项高危)**
- **删除 404**:`remotePath` 改为保存服务器真实资源文件名。此前一直用
`encodeURIComponent(uid) + '.ics'` 去猜,遇到「文件名 ≠ UID」的服务器
(如用 `preg_replace` 改名的家书)会 DELETE 到不存在的路径 → 404 → 服务器上删不掉。
修复覆盖三条取值链路:REPORT 补拉、etag 未变的复用路径、以及 unchanged 分支的存量回填。
- **防复活**:`applyRemote` 增加 `dirtyKeyed` / `dirtyDeletedUids` 白名单。
此前只把 dirty 行排除出索引,远端数据查不到就走"新增"分支又插一行,
导致同一事件出现两条、或"删了又回来"。
- **404/410 物理清库**:删除请求返回 404/410 时视为服务器上已不存在,
继续清理本地行而不是直接 return —— 否则用户会看到"怎么删都删不掉"的日程。
**反馈渠道迁移**
- 从自建 PHP 论坛迁至多软件共用的 **FeedLite**,按软件分栏管理
### v0.1.0(首个送审版本)
- CalDAV 全链路同步、月/周/列表视图、重复日程展开、系统日历镜像、桌面卡片、后台上层同步
---
## ✨ 功能特性
### CalDAV 同步
@@ -15,7 +44,10 @@
- 完整协议链路:**PROPFIND** 发现日历本 → **REPORT** 全量拉取 → **ETag** 增量比对 → **PUT / DELETE** 双向推送
- 一个账号可挂载多个日历本,按需勾选同步范围;支持在线编辑账号、重选日历本、删除账号
- 解析 **VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID**,重复日程的**单次覆盖实例**也能正确还原
- 支持 VTODO 待办同步(只读展示,不参与推送)
- 完整 **VTODO 待办**支持:新建 / 编辑 / 打勾完成 / 删除,均可双向推送回服务器
- 待办与日程分列:日历本按类型区分「日历」与「待办」,待办在专属 Tab 中管理
- 打勾完成会改写 `STATUS:COMPLETED` + `PERCENT-COMPLETE:100` 并 PUT 回服务器
- 修改时先 GET 原始 `.ics` 再原地改写,保留服务器上本应用不认识的属性
- 「重建日程数据」可强制整表重拉,用于修复提醒丢失等本地数据问题
### 日历视图与交互
@@ -82,7 +114,27 @@
### 设置项一览
系统日历处理方式(仅显示 / 备份到 CalDAV)· 镜像到系统日历 · 选择要镜像的日历本 · 回写开关 · 默认视图 · 日程显示方式 · 自动同步间隔 · 后台自动同步 · 通知权限 · 混合显示系统日历 · 日历本只读 / 静音 · 重建日程数据 · 重置账号库 · 软件特性 · 使用帮助 · 隐私政策与用户协议
**系统日历**
- 镜像到系统日历(含「选择要镜像的日历本」)
- 把系统日历的改动回写
- 系统日历中的显示名称(三种命名格式可选)
- 混合显示系统日历(关闭后只显示 DAV 账号的日程)
- 清除系统日历数据(卸载前清理镜像数据,防止残留孤儿账户)
**通用**
- 默认视图(月 / 周 / 列表)
- 日程显示方式(竖向时间轴 / 列表)
- 自动同步间隔(1 / 5 / 15 / 30 分钟)
- 后台自动同步
- 通知权限
**数据**
- 重建日程数据(强制整表重拉)
- 重置账号库
- 日历本只读 / 静音(逐本设置)
**关于**
- 软件特性 · 使用帮助 · 问题反馈 · 隐私政策与用户协议
---
@@ -108,7 +160,84 @@
- **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
- **农历**:`LunarUtil`,1901–2099 压缩表推算
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER / LOCATION(仅地点导航时)
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER
- ⚠️ **LOCATION 已于 0.1.0 移除**:`geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,地理编码不需要定位权限;应用也从不调用 `getCurrentLocation`。
隐私政策/隐私标签里**不得**再声称读取位置。
## 🔐 权限清单与用途
| 权限 | 类型 | 用途 | 何时申请 |
|---|---|---|---|
| `ohos.permission.INTERNET` | 系统授权 | 访问你填写的 CalDAV 服务器 | 安装即授予 |
| `ohos.permission.READ_CALENDAR` | user_grant | 读取本应用写入系统日历的日程 | 打开「混合显示」时 |
| `ohos.permission.READ_WHOLE_CALENDAR` | **受限(需 ACL)** | 读取系统日历中**非本应用创建**的日程,实现混合展示 | 同上 |
| `ohos.permission.WRITE_CALENDAR` | user_grant | 把 CalDAV 日历本镜像到系统日历 | 开启「镜像到系统日历」时 |
| `ohos.permission.PUBLISH_AGENT_REMINDER` | 系统授权 | 发送日程提醒通知 | 保存带提醒的日程时 |
> 所有 user_grant 权限都**只在用户主动操作对应功能时**才弹窗,启动 / 首启 / 同意隐私政策时一律不申请。
> 拒绝后功能默认关闭,可随时在系统设置或本应用设置页重新开启。
---
## 🔑 自行编译并上架:权限要怎么处理
> 如果你 fork 本项目、修改后想上架华为应用市场,**仅修改 `module.json5` 是不够的**。
> 下面这些权限需要额外动作,否则审核会被驳回、或安装后权限静默失效。
### 一、必须自己去申请的(本项目无法代劳)
以下权限绑定在**华为开发者账号**上,与本仓库代码无关。你必须用自己的账号重新走一遍:
| 权限 | 需要做什么 | 审核周期 | 被拒风险 |
|---|---|---|---|
| `READ_WHOLE_CALENDAR` | 在 **AGC → 我的应用 → 权限管理** 提交 **ACL 申请**,说明为什么必须读取非本应用创建的日程 | 数个工作日 ~ 2 周 | 中。用途说明不充分会被驳回 |
| `PUBLISH_AGENT_REMINDER` | 属受限开放权限,需在 AGC 申请 | 数个工作日 | 低 |
**本项目已获批的 ACL 只对 `bundleName = synccalendar.yangyq.net` 生效。**
一旦你改了 `bundleName`(几乎必然要改,否则会与本项目冲突),ACL 立即失效,必须用新包名重新申请。
### 二、ACL 是怎么落地的
ACL **不写在 `module.json5` 里**,而是由 AGC 在审核通过后签发进 **签名 Profile(`.p7b`)**。
想确认手上这份 Profile 有没有带 ACL,可以这样查:
```bash
# 解出 profile 明文(p7b 本质是 PKCS#7 包裹的 JSON)
openssl smime -verify -inform DER -in your.release.p7b -noverify -out profile.json
# 查看 allowed-acls
python3 -c "import json;d=json.load(open('profile.json'));print(d.get('acls'))"
```
正常应输出类似:
```json
{"allowed-acls": ["ohos.permission.READ_WHOLE_CALENDAR"]}
```
**若 `acls` 为空或缺失,说明该 Profile 未获批 ACL** —— 此时即使 `module.json5` 声明了权限,
装到普通用户手机上也会被系统拒绝,功能静默失效。
### 三、不想申请 ACL 怎么办
如果你不想走 ACL 流程(例如只是自用、或嫌审核慢),可以把 `READ_WHOLE_CALENDAR` 从
`module.json5` 中删掉,代价是:
- 「混合显示系统日历」只能看到**本应用自己写入**的日历(`READ_CALENDAR` 足够)
- 看不到华为账号自带日历、其他 App 写入的日历
功能仍然可用,只是视图里少了其他来源的日程。这是一种被社区实践过的合规绕行方案。
### 四、其他与审核相关的注意点
- **应用图标**:AGC 要求分层图标的后景图(`background.png`)为 **100% 不透明、无圆角**(规范 7.1.1.4.3)。
本项目 `AppScope/resources/base/media/background.png` 已满足;若你要换图,务必重新校验 alpha 通道。
另外 `app_icon.png` 目前含透明像素,某些场景(如应用内展示)不受影响,但如要提交商店素材建议先转为不透明。
- **Demo / 免账号体验**:审核员通常没有你的 CalDAV 账号,无法测试核心功能,这是常见的驳回原因。
建议提供一个「一键载入演示数据」入口,或在上架备注中提供测试账号。
- **隐私政策一致性**:隐私政策里声明的权限、数据流向必须与 `module.json5` 完全一致。
本项目已移除 LOCATION,隐私政策中**不得**再出现"读取位置"之类表述。
### 目录结构
@@ -77,6 +77,14 @@ export class CalSource {
source: string = 'dav'; // 'dav' | 'system' | 'local'
visible: boolean = true;
writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑
/**
* 本支持哪些组件(来自服务器 COMP_SET 声明):
* 'event' 纯日历本(新建日程可选,新建待办不可选)
* 'todo' 纯待办本(反过来)
* 'both' 两者都可
* 'unknown' 服务器未声明 → 两边都按可用处理(旧数据兼容)
*/
comps: string = 'unknown';
}
/** 与 UI 无关的调色板 */
@@ -13,6 +13,7 @@ import { IcsUtil } from './IcsUtil';
/** 界面展示用事件 */
export class DisplayEvent {
id: number = 0; // 本地库 id 或系统日历事件 id
uid: string = ''; // 服务器 UID(待办父子关系用它匹配 parentUid)
isSystem: boolean = false;
title: string = '';
description: string = '';
@@ -30,6 +31,16 @@ export class DisplayEvent {
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; // 所属日历本是否可写(只读时点击显示详情而非编辑)
}
@@ -117,6 +128,9 @@ export class CalendarDataService {
const detected: boolean = acc.calendarWritable.length > i
? acc.calendarWritable[i] !== '0' : true;
s.writable = detected && !manualKeys.includes(s.calKey);
// 透传本支持的类型(新建待办/日程时据此过滤,避免把待办写进纯日历本)
s.comps = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
? acc.calendarComps[i] : 'unknown';
sources.push(s);
}
}
@@ -359,6 +373,7 @@ export class CalendarDataService {
}
const d = new DisplayEvent();
d.id = e.id;
d.uid = e.uid;
d.isSystem = false;
d.title = e.title;
d.description = e.description;
@@ -370,6 +385,11 @@ export class CalendarDataService {
d.calName = nameOf(e.calKey);
d.color = colorOf(e.calKey);
d.completed = e.completed;
d.priority = e.priority;
d.categories = e.categories;
d.reminders = e.reminders;
d.parentUid = e.parentUid;
d.attachments = e.attachments;
d.recurRule = e.rrule;
d.dtstart = e.dtstart;
d.recurring = e.rrule !== '';
+172 -6
View File
@@ -36,6 +36,24 @@ export class CardAllDay {
isAllDay: boolean = true;
}
/** 待办卡片单条(4x4 / 6x4 列表用,带 localId 以支持卡片内直接打勾) */
export class TodoCardItem {
/** 本地库主键 —— 卡片"打勾完成"时靠它定位要改哪一行 */
id: number = 0;
title: string = '';
/** 截止时间文字:'今天 18:00' / '明天' / '无日期' */
dueText: string = '';
/**
* 副行文字:截止时间 · 日历本(与 App 内 todoSubtitle 同款呈现)。
* 在数据侧拼好,卡片侧直接显示,避免各卡片各拼一遍导致不一致。
*/
subText: string = '';
color: string = '#007DFF';
calName: string = '';
/** 是否已完成(已完成的不出现在待办卡片列表里,此字段留作扩展) */
completed: boolean = false;
}
/** 卡片整体数据 */
export class CardData {
eventsJson: string = '[]';
@@ -62,6 +80,15 @@ export class CardData {
isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线)
selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天)
dayCount: number = 0; // 当前显示日的日程条数(含全天)
// ===== 待办卡片专用字段(isTodoCard=true 时才有效)=====
/** 待办卡片列表:TodoCardItem[] JSON(按截止时间升序,未完成在前) */
todoListJson: string = '[]';
/** 未完成待办总数(2x2 / 2x4 卡片大数字) */
todoCount: number = 0;
/** 今日到期待办数(卡片副标题用) */
todoTodayCount: number = 0;
/** 是否待办卡片(卡片侧据此切换渲染分支) */
isTodoCard: boolean = false;
}
export class CardDataService {
@@ -127,25 +154,57 @@ export class CardDataService {
}
/** App 内同步完成后调用:刷新所有已添加的卡片 */
static async pushToAllForms(context: common.Context): Promise<void> {
/**
* 把待办数据合并进日程数据对象。
*
* ⭐ 抽出来的原因: EntryFormAbility.pushData 与 CardDataService.pushToAllForms
* 是两条独立的推送路径,之前只有前者做了合并,后者漏了 ⇒
* 待办卡片永远显示 0 条(todoCount 停在默认值)。
* 两处共用同一段逻辑,避免再出现"改一处忘一处"。
*/
private static mergeTodoInto(ev: CardData, td: CardData): void {
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
}
/**
* @param excludeTodoId 乐观更新用:临时把该待办从**所有卡片**的列表与计数中排除
* (只影响卡片显示,不动数据库)。0 表示不排除。
*/
static async pushToAllForms(context: common.Context, excludeTodoId: number = 0): Promise<void> {
// 先恢复持久化的 formId(App 重启后内存注册表为空)
await CardDataService.loadPersistedFormIds(context);
if (CardDataService.formIds.length === 0) {
return;
}
// ⭐ 待办数据与 dayOffset 无关(待办不翻页),只构建一次
let todoData: CardData | null = null;
try {
todoData = await CardDataService.buildTodoCardData(context, excludeTodoId);
} catch (err) {
console.error(`待办卡片数据构建失败: ${(err as BusinessError).message}`);
}
// 各卡片可能翻到了不同日期 → 按偏移分组构建,避免把用户翻走的日期拽回今天
const cache: Map<number, formBindingData.FormBindingData> =
new Map<number, formBindingData.FormBindingData>();
const fallbackData: CardData = await CardDataService.buildCardData(context, 0);
if (todoData !== null) {
CardDataService.mergeTodoInto(fallbackData, todoData);
}
const fallback: formBindingData.FormBindingData =
formBindingData.createFormBindingData(await CardDataService.buildCardData(context, 0));
formBindingData.createFormBindingData(fallbackData);
cache.set(0, fallback);
const stale: string[] = [];
for (const formId of CardDataService.formIds) {
const off: number = CardDataService.getDayOffset(formId);
let binding: formBindingData.FormBindingData | undefined = cache.get(off);
if (binding === undefined) {
binding = formBindingData.createFormBindingData(
await CardDataService.buildCardData(context, off));
const d: CardData = await CardDataService.buildCardData(context, off);
if (todoData !== null) {
CardDataService.mergeTodoInto(d, todoData);
}
binding = formBindingData.createFormBindingData(d);
cache.set(off, binding);
}
try {
@@ -305,14 +364,16 @@ export class CardDataService {
// 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中)
data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length;
// ===== 2x4 卡片右侧:**当前时间之后最近的 2 条**日程 =====
// ===== 2x4 卡片右侧:**当前时间之后最近的若干条**日程(卡片可滚动,最多 15 条)=====
// 规则:只取"还没开始"的有时间日程(startTime >= now),
// **排除全天 / 跨天**,按开始时间升序取前 2 条。
// 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。
const upcomingNext: DisplayEvent[] = upcoming
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 2);
// ⭐ 上限 15 条:卡片可滚动,不必再为了"放得下"而截断到 2 条。
// 15 是经验值 —— 再多则用户更该进 App 看,而不是在卡片里翻。
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 15);
const ongoingItems: CardItem[] = [];
for (const e of ongoingPick) {
const dayLong: boolean = isDayLong(e);
@@ -381,4 +442,109 @@ export class CardDataService {
}
return data;
}
/**
* 卡片列表最多输出多少条待办。
* ⭐ 统一为 15:所有待办卡片都已改为可滚动(List + 虚拟化),
* 不必再为了"一屏放得下"而按卡片尺寸给不同上限。
*/
private static readonly MAX_TODO_ITEMS: number = 15;
/**
* 构建**待办卡片**数据。
*
* 与 buildCardData 的区别:不查日程,只查 VTODO;输出 todoListJson + 统计数字。
* 复用同一个 CardData 类,靠 isTodoCard 标记,卡片侧据此走不同渲染分支
*(这样 FormExtension 的推送逻辑不用区分两种卡片,一套 binding 推所有卡片即可)。
*/
/** @param excludeId 乐观更新用:临时把该 localId 从列表与计数中排除(不动数据库) */
static async buildTodoCardData(context: common.Context, excludeId: number = 0): Promise<CardData> {
const data = new CardData();
data.isTodoCard = true;
try {
LogUtil.init(context);
const now = new Date();
const weekCn: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const fullWeekCn: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
data.dateText = `今天 ${now.getMonth() + 1}月${now.getDate()}日 ${weekCn[now.getDay()]}`;
data.lunarText = LunarUtil.lunarDayText(now.getTime());
data.dateMd = `${now.getMonth() + 1}月${now.getDate()}日`;
data.weekday = fullWeekCn[now.getDay()];
data.dayOffset = 0;
data.isToday = true;
data.selectedDate = CardDataService.startOfDay(now.getTime());
const sources = await CalendarDataService.loadSources(context);
const todos: DisplayEvent[] = await CalendarDataService.loadTodos(context, sources);
// 只保留未完成;excludeId>0 时额外剔除该条(乐观更新,仅影响卡片显示)
const undone: DisplayEvent[] = todos.filter((e: DisplayEvent): boolean =>
!e.completed && (excludeId <= 0 || e.id !== excludeId));
const todayStart: number = CardDataService.startOfDay(now.getTime());
const todayEnd: number = todayStart + 86400000;
// 排序:有截止时间在前按升序;无截止时间在后(新建的在前)
const withDue: DisplayEvent[] = undone
.filter((e: DisplayEvent): boolean => e.endTime > 0)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.endTime - b.endTime);
const noDue: DisplayEvent[] = undone
.filter((e: DisplayEvent): boolean => e.endTime <= 0)
.sort((a: DisplayEvent, b: DisplayEvent): number => b.id - a.id);
const orderedList: DisplayEvent[] = withDue.concat(noDue);
const p2 = (n: number): string => n < 10 ? '0' + n : String(n);
const items: TodoCardItem[] = [];
for (const e of orderedList) {
if (items.length >= CardDataService.MAX_TODO_ITEMS) {
break;
}
const it = new TodoCardItem();
it.id = e.id;
it.title = e.title === '' ? '(无标题)' : e.title;
it.color = e.color;
it.calName = e.calName;
it.completed = e.completed;
// 截止时间文字:今天 / 明天 / 昨天 / 逾期 / 具体日期 / 无日期
if (e.endTime <= 0) {
it.dueText = '无日期';
} else {
const due = new Date(e.endTime);
const dueDay: number = CardDataService.startOfDay(e.endTime);
const diffDays: number = Math.round((dueDay - todayStart) / 86400000);
const hm: string = `${p2(due.getHours())}:${p2(due.getMinutes())}`;
// 是否为"当天 0 点整" —— 只有日期的待办,不显示 00:00
const dateOnly: boolean = due.getHours() === 0 && due.getMinutes() === 0;
const timePart: string = dateOnly ? '' : ' ' + hm;
if (diffDays === 0) {
it.dueText = '今天' + timePart;
} else if (diffDays === 1) {
it.dueText = '明天' + timePart;
} else if (diffDays === -1) {
it.dueText = '昨天' + timePart;
} else if (diffDays < -1) {
it.dueText = `逾期 ${Math.abs(diffDays)} 天`;
} else if (diffDays > 1 && diffDays <= 7) {
it.dueText = `${due.getMonth() + 1}月${due.getDate()}日${timePart}`;
} else {
it.dueText = `${due.getMonth() + 1}/${due.getDate()}${timePart}`;
}
}
// 副行:截止时间 · 日历本(本名为空时只显示截止时间)
it.subText = it.calName !== '' ? `${it.dueText} · ${it.calName}` : it.dueText;
items.push(it);
}
data.todoListJson = JSON.stringify(items);
data.todoCount = undone.length;
data.todoTodayCount = withDue.filter((e: DisplayEvent): boolean =>
e.endTime >= todayStart && e.endTime < todayEnd).length;
// 今日到期条数也塞进 todayCount,让 2x2/2x4 复用同一套大数字排版
data.todayCount = data.todoCount;
LogUtil.write(`待办卡片数据刷新:未完成 ${undone.length} 条,今日到期 ${data.todoTodayCount} 条,列表输出 ${items.length} 条`);
} catch (err) {
LogUtil.write(`待办卡片数据刷新失败: ${JSON.stringify(err)}`);
}
return data;
}
}
+61
View File
@@ -465,6 +465,52 @@ export class DavClient {
return DavClient.reportComponents(href, auth, 'VTODO', '');
}
/** PROPFIND Depth:1 列出某集合下所有资源的文件名(诊断用,不做组件过滤) */
static async listNames(href: string, auth: string): Promise<string[]> {
const body: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(href, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: body,
connectTimeout: 10000,
readTimeout: 30000
});
const xml: string = resp.result as string;
LogUtil.write(`PROPFIND诊断 状态=${resp.responseCode} 响应体=${xml.length}字符`);
const out: string[] = [];
const re = /<[\w-]*:?href>([^<]+)<\/[\w-]*:?href>/gi;
let m: RegExpExecArray | null = re.exec(xml);
while (m !== null) {
const h: string = m[1];
if (h.indexOf('.ics') >= 0) {
out.push(decodeURIComponent(h.substring(h.lastIndexOf('/') + 1)));
}
m = re.exec(xml);
}
return out;
} finally {
httpRequest.destroy();
}
}
/** 不带 comp-filter 的全量 calendar-query(诊断用) */
static async reportAll(href: string, auth: string): Promise<RemoteItem[]> {
const body: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' +
'<d:prop><d:getetag/><c:calendar-data/></d:prop>' +
'<c:filter><c:comp-filter name="VCALENDAR"/></c:filter>' +
'</c:calendar-query>';
return DavClient.reportRaw(href, auth, body, 'ALL');
}
/** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */
private static async reportComponents(href: string, auth: string,
compName: string, timeRange: string): Promise<RemoteItem[]> {
@@ -475,6 +521,12 @@ export class DavClient {
`<c:comp-filter name="${compName}">` +
timeRange +
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
return DavClient.reportRaw(href, auth, body, compName);
}
/** 通用 REPORT 执行器:传完整 body,返回解析后的资源列表 */
static async reportRaw(href: string, auth: string, body: string,
compName: string): Promise<RemoteItem[]> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(href, {
@@ -501,6 +553,8 @@ export class DavClient {
}
const xml: string = resp.result as string;
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
LogUtil.write(`REPORT-诊断(${compName}) 前200字符: ${xml.substring(0, 200)}`);
LogUtil.write(`REPORT-诊断(${compName}) 含VTODO=${xml.indexOf('BEGIN:VTODO') >= 0} 含VEVENT=${xml.indexOf('BEGIN:VEVENT') >= 0} 响应数=${(xml.match(/<[\w-]*:?response[\s>]/gi) || []).length}`);
const originMatch = /https?:\/\/[^/]+/i.exec(href);
const origin: string = originMatch !== null ? originMatch[0] : '';
const items: RemoteItem[] = [];
@@ -525,6 +579,13 @@ export class DavClient {
item.ics = block.substring(start, end + 'END:VCALENDAR'.length);
items.push(item);
}
LogUtil.write(`诊断 blocks=${blocks.length} items=${items.length} xml长=${xml.length}`);
for (let bi = 0; bi < blocks.length; bi++) {
const bb: string = blocks[bi];
if (!/<[\w-]*:?response[\s>]/i.test(bb)) { continue; }
const h: string = DavClient.extractTag(bb, "href");
LogUtil.write(`诊断 block${bi} len=${bb.length} href=[${h.substring(0,50)}] VCAL=${bb.indexOf("BEGIN:VCALENDAR")>=0} VTODO=${bb.indexOf("BEGIN:VTODO")>=0}`);
}
return items;
} finally {
httpRequest.destroy();
+184 -8
View File
@@ -33,8 +33,21 @@ export class LocalEvent {
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
recurrenceId: number = 0;
/**
* 优先级,用 **iCal 原生值**:1=最高 … 9=最低,0=未设置。
* 直接存原生值,写回时零转换,也不会与服务器上其他客户端写下的值语义不一致。
*/
priority: number = 0;
/** 分类标签,逗号分隔(对应 iCal CATEGORIES,多值用逗号连接) */
categories: string = '';
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
/** 父任务 UID(空 = 顶层任务)。对应 VTODO 的 RELATED-TO:RELTYPE=PARENT。
* 用 UID 而非本地 id:父子关系是服务器资源属性,跨设备同步时 UID 才稳定。 */
parentUid: string = '';
/** 附件 JSON 数组字符串:[{name,mime,size,base64}],空 = 无附件。
* base64 直接存库,同步时拼 ATTACH 行零转换。 */
attachments: string = '';
}
/** 远端事件条目(REPORT 解析结果) */
@@ -44,6 +57,10 @@ export class RemoteEvent {
title: string = '';
description: string = '';
location: string = '';
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
priority: number = 0;
/** 分类标签,逗号分隔(对应 CATEGORIES) */
categories: string = '';
startTime: number = 0;
endTime: number = 0;
isAllDay: boolean = false;
@@ -56,6 +73,19 @@ export class RemoteEvent {
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
reminders: number[] = []; // 全部提醒提前分钟数(多选)
/** 父任务 UID(空 = 顶层任务)。 */
parentUid: string = '';
/** 附件 JSON 数组字符串(含 base64),空 = 无附件。 */
attachments: string = '';
/**
* ⭐ REMOTE_PATH 服务器上的真实资源文件名(REPORT 返回的 href 末段,已含 URL 编码)。
*
* 为什么不能用 uid 拼:服务器不保证「资源文件名 = ICS 里的 UID」。
* 家书 CalDav.php 用 `preg_replace('/[^A-Za-z0-9._-]/', '_', $uid)` 生成文件名,
* UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics`。
* 猜错 ⇒ DELETE/PUT 打到不存在的路径 ⇒ 404,服务器上删不掉、或写出重复资源。
*/
remotePath: string = '';
}
export class EventDb {
@@ -129,6 +159,26 @@ export class EventDb {
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN parent_uid TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN attachments TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql('ALTER TABLE events ADD COLUMN priority INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN categories TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
} catch (err) {
@@ -198,6 +248,9 @@ export class EventDb {
e.title = rs.getString(rs.getColumnIndex('title'));
e.description = rs.getString(rs.getColumnIndex('description'));
e.location = rs.getString(rs.getColumnIndex('location'));
e.priority = rs.getLong(rs.getColumnIndex('priority'));
const cats: string = rs.getString(rs.getColumnIndex('categories'));
e.categories = cats === undefined || cats === null ? '' : cats;
e.startTime = rs.getLong(rs.getColumnIndex('start_time'));
e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
@@ -213,6 +266,10 @@ export class EventDb {
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
const pUid: string = rs.getString(rs.getColumnIndex('parent_uid'));
e.parentUid = (pUid === undefined || pUid === null) ? '' : pUid;
const att: string = rs.getString(rs.getColumnIndex('attachments'));
e.attachments = (att === undefined || att === null) ? '' : att;
const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
if (remStr !== '') {
e.reminders = remStr.split(',').map((s: string): number => Number(s))
@@ -245,7 +302,11 @@ export class EventDb {
'exdate': e.exdate,
'recurrence_id': e.recurrenceId,
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
'reminders': e.reminders.map((n: number): string => String(n)).join(',')
'reminders': e.reminders.map((n: number): string => String(n)).join(','),
'priority': e.priority,
'categories': e.categories,
'parent_uid': e.parentUid,
'attachments': e.attachments
};
return bucket;
}
@@ -472,6 +533,72 @@ export class EventDb {
}
/** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */
/**
* 查"未来窗口内需要提醒的未完成待办"(#38)。
*
* 与 queryRemindable(日程)的区别:
* - `kind='todo'` 而不是 'event'
* - **必须 `completed=0`** —— 已完成的待办不该再响
* - 待办的提醒基准同样是 start_time(loadTodos 把 DUE 存进 startTime)
*/
/**
* 单独更新待办的子任务/附件属性(updateTodoFields 只管标题/备注/截止)。
* @param touchParent true=写 parent_uid(空串=清空),false=不动
* @param touchAttach true=写 attachments(空串=清空),false=不动
*/
static async setTodoHierarchy(context: common.Context, id: number, parentUid: string,
attachments: string, touchParent: boolean, touchAttach: boolean): Promise<void> {
if (!touchParent && !touchAttach) {
return;
}
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {};
if (touchParent) {
(bucket as Record<string, string>)['parent_uid'] = parentUid;
}
if (touchAttach) {
(bucket as Record<string, string>)['attachments'] = attachments;
}
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/** 单独更新待办的提醒列表(updateTodoFields 只管标题/备注/截止三字段) */
static async setTodoReminders(context: common.Context, id: number,
reminders: number[]): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'reminder': reminders.length > 0 ? reminders[0] : 0,
'reminders': reminders.map((n: number): string => String(n)).join(',')
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
static async queryRemindableTodos(context: common.Context, from: number,
to: number): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('deleted', 0).and().equalTo('kind', 'todo')
.and().equalTo('completed', 0)
.and().greaterThan('reminder', 0)
.and().greaterThanOrEqualTo('start_time', from)
.and().lessThanOrEqualTo('start_time', to)
.orderByAsc('start_time').limitAs(50);
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
list.push(EventDb.fromRow(rs));
}
} finally {
rs.close();
}
return list;
}
static async queryRemindable(context: common.Context, from: number, to: number): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
@@ -524,14 +651,29 @@ export class EventDb {
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
// 语义与 `existing.find(x => !x.dirty && x.uid===r.uid && x.startTime===r.startTime
// && x.recurrenceId===r.recurrenceId)` 完全一致:dirty 行不入索引、同键取**最先**出现的那行。
// 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;
}
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
if (!keyed.has(k)) {
keyed.set(k, e);
}
@@ -548,6 +690,14 @@ export class EventDb {
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) {
// 新增
@@ -555,7 +705,8 @@ export class EventDb {
e.uid = r.uid;
e.calKey = calKey;
e.href = href;
e.remotePath = encodeURIComponent(r.uid) + '.ics';
// ⭐ REMOTE_PATH 优先用服务器真实文件名;为空时才退回按 uid 猜(本地新建等场景)
e.remotePath = r.remotePath !== '' ? r.remotePath : (encodeURIComponent(r.uid) + '.ics');
e.title = r.title;
e.description = r.description;
e.location = r.location;
@@ -573,17 +724,27 @@ export class EventDb {
e.recurrenceId = r.recurrenceId;
e.reminder = r.reminder;
e.reminders = r.reminders;
e.priority = r.priority;
e.categories = r.categories;
e.parentUid = r.parentUid;
e.attachments = r.attachments;
await store.insert('events', EventDb.toBucket(e));
added++;
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|| found.reminder !== r.reminder || found.recurring !== r.recurring
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
|| found.priority !== r.priority || found.categories !== r.categories
|| found.parentUid !== r.parentUid || found.attachments !== r.attachments
|| found.href !== href) {
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
found.href = href;
// 服务器上资源被改名时同步更新,否则后续 DELETE/PUT 会打到旧路径
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
found.remotePath = r.remotePath;
}
found.title = r.title;
found.description = r.description;
found.location = r.location;
@@ -601,19 +762,34 @@ export class EventDb {
found.recurrenceId = r.recurrenceId;
found.reminder = r.reminder;
found.reminders = r.reminders;
found.priority = r.priority;
found.categories = r.categories;
found.parentUid = r.parentUid;
found.attachments = r.attachments;
const up = new relationalStore.RdbPredicates('events');
up.equalTo('id', found.id);
await store.update(EventDb.toBucket(found), up);
updated++;
} else {
unchanged++;
// etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中,
// 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释)
// ⭐ UNCHANGED_REMOTE_PATH etag 未变是**常态路径**(REPORT 只给 etag,
// 未变就直接复用本地行、不 GET)。实测 742 行里 99% 走这里。
// 若只在"更新分支"回填 remotePath,对存量数据等于无效 ——
// 历史行里存的是旧的 uid 猜测值,而 DELETE/PUT 正是用它拼 URL,
// 拼错就 404(服务器上删不掉)。
// 所以这里必须一并校正 href 与 remotePath。
const hb: relationalStore.ValuesBucket = {};
if (found.href !== href) {
found.href = href;
hb['href'] = href;
}
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
found.remotePath = r.remotePath;
hb['remote_path'] = r.remotePath;
}
if (Object.keys(hb).length > 0) {
const hp = new relationalStore.RdbPredicates('events');
hp.equalTo('id', found.id);
const hb: relationalStore.ValuesBucket = { 'href': href };
await store.update(hb, hp);
}
}
+286 -1
View File
@@ -2,11 +2,22 @@
// iCalendar 解析与生成
import { LocalEvent, RemoteEvent } from './EventDb';
/** 解析 ATTACH 行得到的单个附件元数据(Index.ets 弹层也会用到,故 export) */
export class AttachMeta {
name: string = '';
mime: string = '';
base64: string = '';
}
/** 解析出的远端日程(内部中间结构) */
class ParsedEvent {
uid: string = '';
title: string = '';
description: string = '';
/** 优先级,iCal 原生值:1=最高 … 9=最低,0=未设置 */
priority: number = 0;
/** 分类标签,逗号分隔(对应 CATEGORIES) */
categories: string = '';
location: string = '';
startTime: number = 0;
endTime: number = 0;
@@ -341,7 +352,15 @@ export class IcsUtil {
* ② 再在块内替换/删除目标字段,缺失的在 END:VTODO 前补齐
*/
static rewriteTodoFields(ics: string, uid: string, title: string,
desc: string, dueMs: number): string {
desc: string, dueMs: number, priority: number = -1, categories: string = '',
location: string = '', reminders: number[] = [], parentUid: string = '',
attachments: string = '', touchParent: boolean = false,
touchAttach: boolean = false): string {
// priority === -1 表示"本次不改优先级";0 表示"清空优先级"。
// 用 -1 而不是 0 做哨兵,因为 0 本身是合法语义(无优先级)。
const touchPriority: boolean = priority >= 0;
const touchCategories: boolean = categories !== '';
const touchLocation: boolean = location !== '';
const crlf: string = '\r\n';
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
@@ -383,6 +402,10 @@ export class IcsUtil {
let sawSummary: boolean = false;
let sawDesc: boolean = false;
let sawDue: boolean = false;
let inAlarm: boolean = false;
let sawPriority: boolean = false;
let sawCategories: boolean = false;
let sawLocation: boolean = false;
for (let i = 0; i < lines.length; i++) {
const line: string = lines[i];
const u: string = upperOf(line);
@@ -412,6 +435,50 @@ export class IcsUtil {
}
continue;
}
// 提醒:整段丢弃旧 VALARM,末尾按新列表重建(比逐行改 TRIGGER 简单且不会漏 ACTION/DESCRIPTION)
if (reminders.length > 0 && (u.startsWith('BEGIN:VALARM') || inAlarm)) {
if (u.startsWith('BEGIN:VALARM')) { inAlarm = true; }
if (u.startsWith('END:VALARM')) { inAlarm = false; }
continue;
}
// 父任务:touched 时才改写,否则原样保留(不误删别人写的 RELATED-TO)
if (u.startsWith('RELATED-TO')) {
if (touchParent) {
if (parentUid !== '') {
out.push(IcsUtil.parentLine(parentUid));
}
// parentUid 空 → 删除该行
continue;
}
out.push(line);
continue;
}
// 附件:touched 时整段重建(删所有旧 ATTACH,末尾统一写新的)
if (u.startsWith('ATTACH')) {
if (touchAttach) {
continue; // 丢弃旧 ATTACH,末尾统一写
}
out.push(line);
continue;
}
if (u.startsWith('PRIORITY') && touchPriority) {
sawPriority = true;
if (priority >= 1 && priority <= 9) {
out.push('PRIORITY:' + priority);
}
// priority===0 → 清空,不 push(等于删除该行)
continue;
}
if (u.startsWith('CATEGORIES') && touchCategories) {
sawCategories = true;
out.push('CATEGORIES:' + IcsUtil.escape(categories));
continue;
}
if (u.startsWith('LOCATION') && touchLocation) {
sawLocation = true;
out.push('LOCATION:' + IcsUtil.escape(location));
continue;
}
out.push(line);
}
@@ -426,6 +493,36 @@ export class IcsUtil {
if (!sawDue && dueMs > 0) {
extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
}
if (!sawPriority && touchPriority && priority >= 1 && priority <= 9) {
extra.push('PRIORITY:' + priority);
}
if (!sawCategories && touchCategories) {
extra.push('CATEGORIES:' + IcsUtil.escape(categories));
}
if (!sawLocation && touchLocation) {
extra.push('LOCATION:' + IcsUtil.escape(location));
}
if (touchParent && parentUid !== '') {
extra.push(IcsUtil.parentLine(parentUid));
}
if (touchAttach) {
const metas: AttachMeta[] = IcsUtil.jsonToAttachMeta(attachments);
for (const m of metas) {
extra.push(IcsUtil.attachLine(m));
}
}
// 提醒整体重建(上面已把旧 VALARM 整段跳过)
if (reminders.length > 0) {
const alarms: string = IcsUtil.valarms(reminders);
if (alarms !== '') {
// 去掉末尾的 CRLF(extra 每项会由 join(crlf) 拼接,带尾部换行会多一个空行)
let a: string = alarms;
while (a.length >= 2 && a.charAt(a.length - 2) === '\r' && a.charAt(a.length - 1) === '\n') {
a = a.substring(0, a.length - 2);
}
extra.push(a);
}
}
if (extra.length === 0) {
const plain: string = out.join(crlf);
return plain.endsWith(crlf) ? plain : plain + crlf;
@@ -732,15 +829,21 @@ export class IcsUtil {
const lines: string[] = unfolded.split(/\r?\n/);
const parsed: RemoteEvent[] = [];
let current: RemoteEvent | null = null;
let curAttaches: AttachMeta[] = [];
for (const line of lines) {
const upper: string = line.toUpperCase();
if (upper.startsWith('BEGIN:VTODO')) {
current = new RemoteEvent();
current.isTodo = true;
curAttaches = [];
continue;
}
if (upper.startsWith('END:VTODO')) {
if (current !== null && current.uid !== '') {
// 附件:把收集到的 AttachMeta 序列化成 JSON 存进 RemoteEvent
if (curAttaches.length > 0) {
current.attachments = IcsUtil.attachMetaToJson(curAttaches);
}
parsed.push(current);
}
current = null;
@@ -764,6 +867,14 @@ export class IcsUtil {
current.description = IcsUtil.unescape(value);
} else if (propName === 'LOCATION') {
current.location = IcsUtil.unescape(value);
} else if (propName === 'PRIORITY') {
// iCal 原生值:1=最高 … 9=最低,0/缺失=未设置。直接存,不二次映射。
const pv: number = parseInt(value, 10);
current.priority = isNaN(pv) ? 0 : pv;
} else if (propName === 'CATEGORIES') {
// 多标签用逗号分隔;iCal 允许多个 CATEGORIES 行,这里取最后一行即可
// (本应用写回时只写一行,读别人的多行时最后一行足够用)
current.categories = IcsUtil.unescape(value);
} else if (propName === 'RRULE') {
current.recurring = true;
// ⭐ 保存规则原文:原来只置了 recurring 标志,规则本身被丢掉,
@@ -790,6 +901,20 @@ export class IcsUtil {
if (value.toUpperCase() === 'COMPLETED') {
current.completed = true;
}
} else if (propName === 'RELATED-TO') {
// 父任务 UID。RELTYPE 参数默认 PARENT(本应用的语义);
// 其他客户端可能不带参数,值就是父 UID,直接取。
current.parentUid = value;
} else if (propName === 'ATTACH') {
// 只收集内联 base64;URI 形式(ATTACH:http://...)暂不落库(服务器不支持托管,
// 且外部链接意义不大)。别人客户端写入的 URI 附件这里跳过、不报错。
if (propPart.toUpperCase().includes('ENCODING=BASE64')) {
const meta = new AttachMeta();
meta.base64 = value;
meta.mime = IcsUtil.paramValue(propPart, 'FMTTYPE');
meta.name = IcsUtil.paramValue(propPart, 'X-FILENAME');
curAttaches.push(meta);
}
}
}
return parsed;
@@ -815,6 +940,161 @@ export class IcsUtil {
'END:VCALENDAR\r\n';
}
/**
* 从零构造一份 VTODO 资源(新建待办用)。
*
* @param uid 待办全局唯一标识
* @param title 标题
* @param desc 备注(可为空,空则不写 DESCRIPTION)
* @param dueMs 截止时间毫秒(0 = 无截止时间,不写 DUE)
* @param dueIsDateOnly 截止时间是否"只有日期无时刻"(true 写 `DUE;VALUE=DATE`)
*/
static buildTodo(uid: string, title: string, desc: string, dueMs: number,
dueIsDateOnly: boolean, priority: number = 0, categories: string = '',
location: string = '', reminders: number[] = [], parentUid: string = '',
attachments: string = ''): string {
const now: string = IcsUtil.fmtUtcNow();
let out: string = 'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
'CALSCALE:GREGORIAN\r\n' +
'BEGIN:VTODO\r\n' +
`UID:${uid}\r\n` +
`DTSTAMP:${now}\r\n` +
`SUMMARY:${IcsUtil.escape(title)}\r\n`;
if (desc !== '') {
out += `DESCRIPTION:${IcsUtil.escape(desc)}\r\n`;
}
if (dueMs > 0) {
if (dueIsDateOnly) {
out += `DUE;VALUE=DATE:${IcsUtil.fmtIcsTime(dueMs, true)}\r\n`;
} else {
out += `DUE:${IcsUtil.fmtIcsTime(dueMs, false)}\r\n`;
}
}
// 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED
out += 'STATUS:NEEDS-ACTION\r\n';
out += IcsUtil.todoExtras(priority, categories, location);
if (parentUid !== '') {
out += IcsUtil.parentLine(parentUid) + '\r\n';
}
// 附件:内联 base64,可多条
const metas: AttachMeta[] = IcsUtil.jsonToAttachMeta(attachments);
for (const m of metas) {
out += IcsUtil.attachLine(m) + '\r\n';
}
// VALARM:VTODO 的 TRIGGER 相对 DUE(RFC 5545 §3.8.6.3),
// 所以复用现有 valarm() 生成的 -PT5M 就是"到期前 5 分钟"
out += IcsUtil.valarmsPublic(reminders);
out += 'END:VTODO\r\n';
out += 'END:VCALENDAR\r\n';
return out;
}
/**
* 生成 VTODO 的扩展属性行(PRIORITY / CATEGORIES / LOCATION)。
*
* 抽成方法的原因:新建(buildTodo)与改写(rewriteTodoFields)都要写这三行,
* 逻辑必须一致,否则新建的和改过的会生成不同结构。
*
* @param priority iCal 原生值 1(最高)…9(最低);0 或非法值 = 不写
* @param categories 逗号分隔的标签;空 = 不写
* @param location 地点文本;空 = 不写
*/
/** 从属性串里取参数值(如 propPart = "ATTACH;FMTTYPE=text/plain;X-FILENAME=报告.pdf")。 */
private static paramValue(propPart: string, paramName: string): string {
const parts: string[] = propPart.split(';');
for (let i = 1; i < parts.length; i++) {
const seg: string = parts[i];
const eq: number = seg.indexOf('=');
if (eq > 0 && seg.substring(0, eq).toUpperCase() === paramName.toUpperCase()) {
return seg.substring(eq + 1).replace(/^"|"$/g, '');
}
}
return '';
}
/** AttachMeta[] → JSON 字符串(存库用)。 */
static attachMetaToJson(list: AttachMeta[]): string {
if (list.length === 0) {
return '';
}
const arr: string[] = [];
for (const a of list) {
arr.push(JSON.stringify({ name: a.name, mime: a.mime, base64: a.base64 }));
}
return '[' + arr.join(',') + ']';
}
/** JSON 字符串 → AttachMeta[](解析库值用)。 */
static jsonToAttachMeta(json: string): AttachMeta[] {
if (json === '') {
return [];
}
try {
const arr = JSON.parse(json) as Record<string, string>[];
const out: AttachMeta[] = [];
for (const o of arr) {
const m = new AttachMeta();
m.name = o.name ?? '';
m.mime = o.mime ?? 'application/octet-stream';
m.base64 = o.base64 ?? '';
if (m.base64 !== '') {
out.push(m);
}
}
return out;
} catch (err) {
return [];
}
}
/** 生成一条内联 base64 的 ATTACH 行(已折行)。 */
private static attachLine(meta: AttachMeta): string {
let head: string = 'ATTACH';
if (meta.mime !== '') {
head += `;FMTTYPE=${meta.mime}`;
}
head += ';ENCODING=BASE64;VALUE=BINARY';
if (meta.name !== '') {
// 逗号/分号属性值里要转义;文件名通常是中文或普通字符,稳妥起见转义
head += `;X-FILENAME=${meta.name}`;
}
return IcsUtil.foldLine(head + ':' + meta.base64);
}
/** 生成 RELATED-TO 行(父任务)。 */
private static parentLine(parentUid: string): string {
return `RELATED-TO;RELTYPE=PARENT:${parentUid}`;
}
/**
* RFC 5545 折行:本工程**有意不折**,直通返回。
*
* 为什么不做折行:
* 1. 现有 SUMMARY/DESCRIPTION/LOCATION/CATEGORIES 一贯整行输出,未做折行;
* 2. 目标服务器(群晖)实测 3792 字符整行 ATTACH 也能 PUT→201、GET 原样保留;
* 3. 折行是 RFC 5545 的 SHOULD 而非 MUST;一致性优先;
* 4. 折行若按 75 字符硬切,会把 UTF-8 中文名(如「报告.pdf」)拦腰切断成乱码。
*/
static foldLine(line: string): string {
return line;
}
static todoExtras(priority: number, categories: string, location: string): string {
let out: string = '';
if (priority >= 1 && priority <= 9) {
out += `PRIORITY:${priority}\r\n`;
}
if (categories !== '') {
out += `CATEGORIES:${IcsUtil.escape(categories)}\r\n`;
}
if (location !== '') {
out += `LOCATION:${IcsUtil.escape(location)}\r\n`;
}
return out;
}
/**
* ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。
*
@@ -962,6 +1242,11 @@ export class IcsUtil {
}
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
/** 供待办侧复用的公开包装(VTODO 的 VALARM 与 VEVENT 格式相同) */
static valarmsPublic(minutes: number[]): string {
return IcsUtil.valarms(minutes);
}
private static valarms(minutes: number[]): string {
let out: string = '';
for (const m of minutes) {
+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;
}
}
+15 -4
View File
@@ -161,8 +161,13 @@ export class ReminderService {
LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`);
}
const now: number = Date.now();
const rows: LocalEvent[] =
// ⭐ #38 日程 + 待办一起排提醒。两类分别查(过滤条件不同:
// 待办要求 completed=0,日程无此概念),合并后统一处理。
const eventRows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
const todoRows: LocalEvent[] =
await EventDb.queryRemindableTodos(context, now, now + ReminderService.WINDOW);
const rows: LocalEvent[] = eventRows.concat(todoRows);
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
@@ -193,6 +198,8 @@ export class ReminderService {
}
const d = new Date(remindAt);
const calName: string = await ReminderService.calName(context, e.calKey);
// #38 文案按类型区分:待办是"到期",日程是"开始"
const isTodo: boolean = e.kind === 'todo';
const request: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
@@ -203,10 +210,14 @@ export class ReminderService {
minute: d.getMinutes(),
second: 0
},
title: '日程提醒',
content: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
title: isTodo ? '待办提醒' : '日程提醒',
content: isTodo
? `「${calName}」中的待办「${e.title}」将在 ${rem} 分钟后到期`
: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`,
// ⚠️ 通知 id 必须唯一:日程与待办的 id 来自同一张表(自增主键),
// 天然不冲突,所以这里不用额外加偏移,否则会与 rem 的乘加混淆。
notificationId: (e.id * 10 + rem) % 100000,
expiredContent: '该日程已开始',
expiredContent: isTodo ? '该待办已到期' : '该日程已开始',
ringDuration: 5,
snoozeTimes: 0,
timeInterval: 0
+70 -7
View File
@@ -161,6 +161,16 @@ export class SyncEngine {
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
const t1: number = Date.now();
// ⭐ 纯待办本(COMP_SET 只有 VTODO)不跑 event 通道。
// 实测群晖:对纯待办本做 event 的 REPORT 会返回文件列表,
// 里面其实是 VTODO 文件,被 parseEvents 解析成 0 条日程却仍落库
// → 本地库出现 kind='event' 记录,而所在本是待办本。
// 后果:这条"假日程"在待办列表看不到(列表只查 kind='todo'),
// 但点它编辑会走日程通道,报"需要联网 + 日历本需可写"。
const thisComps: string = i < acc.calendarComps.length ? acc.calendarComps[i] : '';
if (thisComps === 'todo') {
LogUtil.write(`日历本[${i}]「${calName}」是纯待办本(COMP_SET=todo),跳过 event 通道`);
} else {
let items: RemoteItem[] = [];
try {
items = await DavClient.reportEtags(href, auth);
@@ -244,8 +254,9 @@ export class SyncEngine {
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
&& group[0].etag === it.etag) {
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
const rp: string = it.href.substring(it.href.lastIndexOf('/') + 1);
for (const row of group) {
remote.push(SyncEngine.rowToRemote(row, it.etag));
remote.push(SyncEngine.rowToRemote(row, it.etag, rp));
}
continue;
}
@@ -264,6 +275,12 @@ export class SyncEngine {
return [];
}
const resUid: string = SyncEngine.resUidFromHref(it.href);
// ⭐ REMOTE_PATH 服务器真实资源文件名(href 末段,已含 URL 编码)。
// 绝不能用 `encodeURIComponent(uid) + '.ics'` 去猜:
// 家书 CalDav.php 用 preg_replace('/[^A-Za-z0-9._-]/','_',uid) 生成文件名,
// UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics`。
// 猜错 ⇒ DELETE/PUT 打到不存在的路径 ⇒ 404,服务器上删不掉、或写出重复资源。
const fileName: string = it.href.substring(it.href.lastIndexOf('/') + 1);
const parsed: RemoteEvent[] = IcsUtil.parse(raw);
const out: RemoteEvent[] = [];
for (const r of parsed) {
@@ -273,6 +290,7 @@ export class SyncEngine {
if (r.uid === '') {
r.uid = resUid;
}
r.remotePath = fileName;
r.etag = it.etag;
out.push(r);
}
@@ -299,6 +317,8 @@ export class SyncEngine {
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
changed += remote.length;
} // end else(非纯待办本才跑 event 通道)
// 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步)
try {
const todoItems = await DavClient.reportTodos(href, auth);
@@ -506,10 +526,18 @@ export class SyncEngine {
}
/** 本地行 → 远端事件(etag 未变的资源复用本地完整数据,含 reminder) */
private static rowToRemote(e: LocalEvent, etag: string): RemoteEvent {
/**
* ⭐ REMOTE_PATH 第三个参数是当前 REPORT 返回的真实资源文件名(href 末段)。
*
* 为什么必须从外面传进来:本地行的 remotePath 可能是历史数据用 uid 猜出来的,
* 也可能是空的(本地新建后还没同步成功)。而 REPORT 的 href 反映的是
* **此刻服务器上的真实文件名**,比本地任何值都可靠。
*/
private static rowToRemote(e: LocalEvent, etag: string, remotePath: string): RemoteEvent {
const r = new RemoteEvent();
r.uid = e.uid;
r.etag = etag;
r.remotePath = remotePath !== '' ? remotePath : e.remotePath;
r.title = e.title;
r.description = e.description;
r.location = e.location;
@@ -564,7 +592,27 @@ export class SyncEngine {
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
try {
if (e.deleted) {
await DavClient.deleteRemote(url, auth);
// ⭐ DELETE_404_PURGE 单条路径:deleteRemote 内部已把 404 视作成功,
// 这里再显式兜一层 410(Gone),并保证无论哪种"远端已无"都清本地。
let gone: boolean = false;
try {
await DavClient.deleteRemote(url, auth);
gone = true;
} catch (delErr) {
if (delErr instanceof DavHttpError) {
const st: number = (delErr as DavHttpError).status;
if (st === 401) {
throw delErr as Error;
}
if (st === 404 || st === 410) {
gone = true;
LogUtil.write(`单条资源在服务器上已不存在(${st}),直接清理本地:uid=${e.uid}`);
}
}
if (!gone) {
throw delErr as Error;
}
}
await EventDb.purge(context, e.id);
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
} else {
@@ -616,14 +664,29 @@ export class SyncEngine {
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
if (master.deleted) {
// ⭐ DELETE_404_PURGE 404/410 = 服务器上本就没有这个资源。
// 必须当成"删除成功"继续往下清本地,不能 return ——
// 否则本地行永远留着,用户看到"删不掉的日程",且每次同步都重复失败。
// 典型诱因:remotePath 用 uid 猜错(服务器真实文件名不同)⇒ DELETE 打到不存在的路径。
let remoteGone: boolean = false;
try {
await DavClient.deleteRemote(url, auth);
remoteGone = true;
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
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;
}
LogUtil.write(`推送删除重复系列失败:uid=${uid} → ${(err as Error).message}`);
return;
}
await EventDb.purgeByUid(context, calKey, uid);
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);
@@ -36,6 +36,8 @@ export class DayTimeline {
allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前)
blocks: TimelineBlock[] = []; // 时间轴色块
hasTimed: boolean = false; // 当天是否有有时间日程
/** 当天截止、尚未完成的待办(画在时间轴顶部、全天区之上,带勾选框) */
todos: DisplayEvent[] = [];
}
/** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */
+126 -2
View File
@@ -44,7 +44,10 @@ export class TodoService {
* @param dueMs 0 = 清除截止时间(删除 DUE 行)
*/
static async updateTodo(context: common.Context, localId: number, title: string,
desc: string, dueMs: number): Promise<boolean> {
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}`);
@@ -74,10 +77,19 @@ export class TodoService {
LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`);
return false;
}
const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs);
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) {
@@ -90,6 +102,92 @@ export class TodoService {
}
}
/**
* 新建待办:生成 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);
@@ -257,6 +355,32 @@ export class TodoService {
}
}
/**
* 级联完成:把 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) {
@@ -42,10 +42,12 @@ export default class EntryAbility extends UIAbility {
private handleWidgetParam(want: Want): void {
const params = want.parameters;
if (params !== undefined) {
// ⭐ 白名单:日程卡片发 'add',待办卡片发 'addTodo'(新建待办)/ 'openTodo'(进待办页)。
// 之前只认 'add',待办卡片点 + 毫无反应 —— 参数被静默丢弃。
const raw = params['widgetAction'];
if (typeof raw === 'string' && raw === 'add') {
AppStorage.setOrCreate<string>('widgetAction', 'add');
LogUtil.write('卡片添加入口:已设置 widgetAction=add');
if (typeof raw === 'string' && (raw === 'add' || raw === 'addTodo' || raw === 'openTodo')) {
AppStorage.setOrCreate<string>('widgetAction', raw);
LogUtil.write(`卡片入口:已设置 widgetAction=${raw}`);
}
// 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期)
const od = params['openDate'];
@@ -6,6 +6,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { CardDataService, CardData } from '../common/CardDataService';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { TodoService } from '../common/TodoService';
import { LogUtil } from '../common/LogUtil';
export default class EntryFormAbility extends FormExtensionAbility {
@@ -18,15 +19,31 @@ export default class EntryFormAbility extends FormExtensionAbility {
}
}
/** 组装更新数据并推送到指定卡片 */
/**
* 组装更新数据并推送到指定卡片。
*
* ⭐ 一次同时构建「日程」与「待办」两份数据,合并进同一个 binding。
* 日程卡片只读 eventsJson 等字段,待办卡片只读 todoListJson 等字段,
* 彼此不会串味。这样无需维护 formId → 卡片类型的映射(那需要持久化,
* 且系统重启后容易丢失),一套逻辑服务全部 8 张卡片。
*/
private pushData(formId: string, dayOffset: number = 0): void {
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
Promise.all([
CardDataService.buildCardData(this.context, dayOffset),
CardDataService.buildTodoCardData(this.context),
]).then((results: CardData[]): void => {
const ev: CardData = results[0];
const td: CardData = results[1];
// 待办字段并入日程数据对象(两者都是 CardData,字段不冲突)
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(data);
formBindingData.createFormBindingData(ev);
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
});
LogUtil.write(`卡片 ${formId} 数据已推送`);
LogUtil.write(`卡片 ${formId} 数据已推送(日程+待办)`);
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片数据构建失败: ${err.message}`);
});
@@ -84,12 +101,35 @@ export default class EntryFormAbility extends FormExtensionAbility {
CardDataService.clearDayOffset(formId);
}
/** 卡片上「‹ / › / 回到今天」按钮触发:message 里带 pageAction=prev|next|today */
/**
* 卡片事件:既处理日程翻页,也处理待办打勾。
*
* ⚠️ 不用 JSON.parse 解析 message —— 不同系统版本下格式可能是
* `{params:{...}}` 也可能是直接 `{...}`,格式差异会让 JSON 解析拿到 undefined。
* 统一用**子串匹配**,稳且不依赖具体结构。
*/
onFormEvent(formId: string, message: string): void {
LogUtil.init(this.context);
// ===== 待办打勾:message 里带 todoCheck=<localId> =====
const checkIdx: number = message.indexOf('todoCheck');
if (checkIdx >= 0) {
const m = /todoCheck\D{0,8}(\d+)/.exec(message);
const localId: number = m !== null ? parseInt(m[1], 10) : 0;
LogUtil.write(`卡片打勾待办: formId=${formId} localId=${localId} msg=${message}`);
if (localId > 0) {
// onFormEvent 是同步回调,这里异步执行但必须兜住 reject,
// 否则未捕获的 Promise 会让进程异常(FormExtension 生命周期只有 10 秒)
this.completeTodoFromCard(formId, localId).catch((err: BusinessError): void => {
LogUtil.write('卡片打勾未捕获异常: ' + err.message);
});
}
return;
}
// ===== 日程翻页 =====
const cur: number = CardDataService.getDayOffset(formId);
let next: number = cur;
// 不用 JSON 解析:不同版本 message 的格式可能是 {params:{...}} 或直接 {...},直接子串匹配更稳
if (message.indexOf('prev') >= 0) {
next = cur - 1;
} else if (message.indexOf('next') >= 0) {
@@ -101,4 +141,63 @@ export default class EntryFormAbility extends FormExtensionAbility {
LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`);
this.pushData(formId, next);
}
/**
* 在卡片上完成一条待办。
*
* 时序设计(关键):
* ① **先推一次数据让该条从卡片消失** —— 用户点勾必须立刻有视觉反馈。
* 但此时库里还没改成 completed,所以这里不等它完成,
* 而是先推送"排除该 localId"的临时数据(见 pushDataExcludingTodo)。
* ② 再真正调 TodoService.setCompleted(内部 GET 原 ics → 改写 → PUT)。
* ③ 成功 → 推真实数据(该条已 completed,本来就不在列表里,效果一致);
* 失败 → 也推真实数据(该条会重新出现,用户能看出没成功,可重试或进 App 处理)。
*
* 为什么不 await 完再推:
* FormExtensionAbility 生命周期回调只有 10 秒窗口,
* GET+PUT 两步网络在慢网下可能 10 秒以上,卡片会先"卡住不动"再消失,体验很差。
*/
private async completeTodoFromCard(formId: string, localId: number): Promise<void> {
// ⭐ 关键:推给**所有**卡片,不只 formId 这一张。
// 原来只推 formId ⇒ 用户在 A 卡片打勾后,B 卡片仍显示那条待办,
// 必须在 B 上再点一次才刷新(用户实测反馈)。
// 每张卡片是独立实例、各持一份数据,必须逐个 updateForm。
// ① 乐观移除:所有卡片立即不含该条(先给视觉反馈,不等网络)
await CardDataService.pushToAllForms(this.context, localId);
// ② 真正落库 + 推送服务器(GET 原 ics → 改写 → PUT)
let ok: boolean = false;
try {
ok = await TodoService.setCompleted(this.context, localId, true);
LogUtil.write('卡片打勾结果: localId=' + localId + ' ' + (ok ? '成功' : '失败'));
} catch (err) {
const e = err as BusinessError;
LogUtil.write('卡片打勾异常: localId=' + localId + ' ' + e.message);
}
// ③ 无论成败都再推一次真实数据:
// 成功 → 该条已 completed,本就不在未完成列表里,各卡片保持一致
// 失败 → 该条重新出现,用户能看出没成功,可重试
await CardDataService.pushToAllForms(this.context);
}
/** 推送"排除某条待办"的卡片数据(乐观更新用,只改卡片视图,不动数据库) */
private pushDataExcludingTodo(formId: string, excludeId: number): void {
Promise.all([
CardDataService.buildCardData(this.context, CardDataService.getDayOffset(formId)),
CardDataService.buildTodoCardData(this.context, excludeId),
]).then((results: CardData[]): void => {
const ev: CardData = results[0];
const td: CardData = results[1];
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(ev);
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
LogUtil.write(`卡片 ${formId} 乐观更新失败: ${err.message}`);
});
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片乐观更新数据构建失败: ${err.message}`);
});
}
}
+195 -22
View File
@@ -2,7 +2,7 @@
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { notificationManager } from '@kit.NotificationKit';
import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore';
import { EventDb, LocalEvent } from '../common/EventDb';
@@ -14,6 +14,7 @@ import { LogUtil } from '../common/LogUtil';
import { IcsUtil } from '../common/IcsUtil';
import { RruleUtil } from '../common/RruleUtil';
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
import { PlaceParser, ParsedPlace } from '../common/PlaceParser';
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
class SeriesBounds {
@@ -443,6 +444,123 @@ struct EventEditPage {
.padding({ top: 4, bottom: 4 })
}
/**
* 从剪贴板粘贴并解析地点。
*
* ⚠️ 必须由 PasteButton 的 onClick 触发,权限仅在回调内有效。
* 在回调外调用 pasteboard.getData() 会因缺少权限而失败。
*/
private async pastePlace(): Promise<void> {
this.cancelSuggestTimer();
let text: string = '';
try {
const sys = pasteboard.getSystemPasteboard();
const has: boolean = await sys.hasData();
if (!has) {
this.toast('剪贴板是空的');
return;
}
const data: pasteboard.PasteData = await sys.getData();
text = data.getPrimaryText();
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`粘贴读取失败: ${e.code} ${e.message}`);
this.toast('无法读取剪贴板');
return;
}
if (text === undefined || text === null || text.trim() === '') {
this.toast('剪贴板里没有文字');
return;
}
await this.resolvePastedText(text.trim());
}
/**
* 把粘贴到的文本解析成地点。
*
* 三级降级(从准到松):
* ① 高德链接 → 直接有坐标,无需检索
* ② 微信文本 → 拆出名称,用**名称**检索(实测命中率最高)
* ③ 名称搜不到 → 用完整地址再搜一次
* ④ 都失败 → 把原文填进地点栏,提示用户手动确认
*
* 第 ④ 步兜底很重要:宁可让用户自己看一眼,
* 也不能因为解析失败就清空输入框或卡住保存。
*/
private async resolvePastedText(text: string): Promise<void> {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
let parsed: ParsedPlace | null = null;
try {
parsed = await PlaceParser.parse(text, ctx);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`粘贴解析异常: ${e.message}`);
}
// 解析不出东西 → 原文填入,让用户自行判断
if (parsed === null) {
this.lastAppliedText = text;
this.location = text;
this.toast('已填入,请确认地点是否正确');
return;
}
// 有坐标(高德链接):直接用,坐标已包含在解析结果里
if (parsed.hasCoord) {
const locText: string = PlaceParser.mergeForLocationText(parsed.name, parsed.address);
this.lastAppliedText = locText !== '' ? locText : text;
this.location = this.lastAppliedText;
this.placePicked = true;
this.toast('已从链接解析地点');
return;
}
// 无坐标(微信文本):名称优先检索,失败再用地址
const tryKeywords: string[] = [];
if (parsed.name !== '') {
tryKeywords.push(parsed.name);
}
if (parsed.address !== '' && parsed.address !== parsed.name) {
tryKeywords.push(parsed.address);
}
if (tryKeywords.length === 0) {
tryKeywords.push(text);
}
for (let i = 0; i < tryKeywords.length; i++) {
const kw: string = tryKeywords[i];
try {
let items: PlaceItem[] = await PlaceSuggest.query(kw, ctx);
if (items.length === 0) {
items = await PlaceSuggest.search(kw, ctx);
}
if (items.length > 0) {
// 命中:直接应用第一条(与用户点选候选等价)
const best: PlaceItem = items[0];
this.placePicked = true;
this.lastAppliedText = best.locationText;
this.location = best.locationText;
this.toast('已匹配到地点');
return;
}
} catch (err) {
LogUtil.write(`粘贴检索失败(${kw}): ${(err as BusinessError).message}`);
}
}
// 全部检索失败:填原文,不阻塞
const fallback: string = parsed.name !== '' ? parsed.name : parsed.address;
this.lastAppliedText = fallback !== '' ? fallback : text;
this.location = this.lastAppliedText;
this.placePicked = true;
this.toast('已填入,请确认地点是否正确');
}
/** 轻量提示(复用页面已有的 toast 机制;没有就静默) */
private toast(msg: string): void {
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 1800 });
}
/** 选中一条候选:写进地点输入框并收起列表 */
private applyPlace(it: PlaceItem): void {
// ⭐ 顺序很关键:先收起浮层,再写文本。
@@ -1273,7 +1391,8 @@ struct EventEditPage {
}
build() {
Column({ space: 14 }) {
Stack({ alignContent: Alignment.Center }) {
Column({ space: 14 }) {
// 顶部(与内容区左右对齐,避免贴边误触)
Row({ space: 6 }) {
Text('取消')
@@ -1624,6 +1743,33 @@ struct EventEditPage {
}
})
.id('placeAnchor')
// ⭐ 粘贴地址:系统 PasteButton 安全控件
// 用它而不是自绘按钮 + pasteboard.getData(),原因见 pastePlace() 注释:
// READ_PASTEBOARD 是受限权限,普通应用申请会被拒绝。
// PasteButton 无需声明权限、无需弹窗,权限仅在 onClick 内有效。
// ⭐ PasteButton:安全控件,点击时临时获得剪贴板读权限
// (无需申请 READ_PASTEBOARD —— 那是受限权限,普通应用申请会被拒)
//
// ⚠️ icon/text/buttonType **只能走构造参数**,写成链式属性会编译报错。
// 文字内容由系统枚举决定(PasteDescription 只有 PASTE 一个值,即「粘贴」)。
// 外观与右边的「地图」按钮保持一致:同字号、同色、同底色、同圆角、同高度。
PasteButton({
text: PasteDescription.PASTE,
// ⚠️ 必须用 Normal,不能用 Capsule:
// 按 SDK 文档,Capsule / Circle 下 borderRadius **不生效**,
// 圆角由按钮类型自动决定(= 两端全圆),与「地图」的圆角矩形不一致。
// 只有 Normal / ROUNDED_RECTANGLE 才认 borderRadius。
buttonType: ButtonType.Normal
})
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.backgroundColor($r('app.color.card_bg'))
.height(44)
.padding({ left: 12, right: 12 })
.borderRadius(12)
.onClick(() => {
this.pastePlace();
})
// 在地图上选点(系统地图界面,可选可搜可周边)
Text('地图')
.fontSize(13)
@@ -1687,27 +1833,41 @@ struct EventEditPage {
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
}
.width('100%')
.height('100%')
.padding({ top: 12 })
.backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// ===== 居中弹层 =====
if (this.sheetShow) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => {
this.sheetShow = false;
})
this.centerSheet()
}
}
.width('100%')
.height('100%')
.padding({ top: 12 })
.backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// ⚠️ 整个页面**只允许一个 bindSheet**:挂两个时后者会覆盖前者,
// 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。
// 这里用 sheetMode 在同一个弹层里切换内容。
.bindSheet($$this.sheetShow, this.bottomSheet(), {
height: this.sheetMode === 'scope' ? 320 : (this.sheetMode === 'repeatEnd' ? 340 : 380),
showClose: false,
dragBar: true,
backgroundColor: $r('app.color.card_bg')
})
}
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"/"重复范围选择"/"重复截止日期" */
/**
* 居中弹层(自绘覆盖层,与待办页一致)。
*
* 原来用 bindSheet 从底部弹出,用户反馈"点完之后选择框在下面出来,操作不顺畅"。
* 改成屏幕居中,视线不用上下跳。
*
* 为什么不用 bindSheet:SDK 的 SheetOptions 没有 position/alignment,只能底部弹。
*/
@Builder
bottomSheet() {
centerSheet() {
if (this.sheetMode === 'scope') {
this.scopeSheet()
} else if (this.sheetMode === 'repeatEnd') {
@@ -1757,8 +1917,10 @@ struct EventEditPage {
this.sheetShow = false;
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
}
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
@@ -1794,8 +1956,10 @@ struct EventEditPage {
this.sheetShow = false;
})
}
.width('100%')
.padding({ left: 20, right: 20 })
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
}
@Builder
@@ -2004,8 +2168,10 @@ struct EventEditPage {
this.sheetShow = false;
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
.width('86%')
.padding(18)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
}
}
@@ -2029,6 +2195,13 @@ class CalendarDataBridge {
if (!detected || manualKeys.includes(`${acc.id}_${i}`)) {
continue;
}
// ⭐ 只列**能放日程**的本:纯待办本(COMP_SET 只有 VTODO)写不进 VEVENT。
// 'both' / 'unknown' 保留,理由同 Index.openTodoCreator。
const comp: string = i < acc.calendarComps.length && acc.calendarComps[i] !== ''
? acc.calendarComps[i] : 'unknown';
if (comp === 'todo') {
continue;
}
const s = new CalSourceWithHref();
s.calKey = `${acc.id}_${i}`;
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
File diff suppressed because it is too large Load Diff
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)
}
}
+13 -9
View File
@@ -26,11 +26,10 @@ struct Widget4x2Card {
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
@LocalStorageProp('selectedDate') selectedDate: number = 0;
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */
/** 右侧列表数据(可滚动;数据侧已按时间升序并限制在 15 条内) */
private parseOngoing(): CardItem2x4[] {
try {
const all: CardItem2x4[] = JSON.parse(this.ongoingJson) as CardItem2x4[];
return all.slice(0, 2);
return JSON.parse(this.ongoingJson) as CardItem2x4[];
} catch (err) {
return [];
}
@@ -102,7 +101,7 @@ struct Widget4x2Card {
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.padding({ left: 6, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
@@ -183,19 +182,24 @@ struct Widget4x2Card {
.strokeWidth(0.5)
.color('#E5E5E5')
// ===== 右半:当前时间之后最近的两条日程(不滚动)=====
// ===== 右半:未来日程列表(可滚动,最多 15 条)=====
// ⭐ 原来固定 Column + 只取 2 条,用户想看后天的安排就看不到。
// 改为 List:自带虚拟化与滚动惯性,条目多也不占内存。
Column() {
if (this.parseOngoing().length === 0) {
this.emptyHint()
} else {
Column({ space: 6 }) {
List({ space: 4 }) {
ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => {
this.ongoingRow(item)
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`)
ListItem() {
this.ongoingRow(item)
}
}, (item: CardItem2x4, idx: number) => `o${idx}_${item.time}_${item.title}`)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.layoutWeight(1)
@@ -51,6 +51,26 @@
{
"name": "sync_work_desc",
"value": "日历后台兜底同步任务"
},
{
"name": "todo_card_2x2_name",
"value": "待办 2×2"
},
{
"name": "todo_card_2x4_name",
"value": "待办 2×4"
},
{
"name": "todo_card_4x4_name",
"value": "待办 4×4"
},
{
"name": "todo_card_6x4_name",
"value": "待办 6×4"
},
{
"name": "todo_card_desc",
"value": "待办服务卡片:查看未完成待办,可直接点击完成"
}
]
}
}
@@ -16,7 +16,9 @@
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["2*2"]
"supportDimensions": [
"2*2"
]
},
{
"name": "WidgetCard2x4",
@@ -34,7 +36,9 @@
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*4",
"supportDimensions": ["2*4"]
"supportDimensions": [
"2*4"
]
},
{
"name": "WidgetCard4x4",
@@ -52,7 +56,9 @@
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "4*4",
"supportDimensions": ["4*4"]
"supportDimensions": [
"4*4"
]
},
{
"name": "WidgetCard6x4",
@@ -70,7 +76,89 @@
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "6*4",
"supportDimensions": ["6*4"]
"supportDimensions": [
"6*4"
]
},
{
"name": "TodoCard2x2",
"displayName": "$string:todo_card_2x2_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget2x2.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": [
"2*2"
]
},
{
"name": "TodoCard2x4",
"displayName": "$string:todo_card_2x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget2x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*4",
"supportDimensions": [
"2*4"
]
},
{
"name": "TodoCard4x4",
"displayName": "$string:todo_card_4x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget4x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "4*4",
"supportDimensions": [
"4*4"
]
},
{
"name": "TodoCard6x4",
"displayName": "$string:todo_card_6x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget6x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "6*4",
"supportDimensions": [
"6*4"
]
}
]
}
@@ -6,6 +6,7 @@
"pages/CalendarListPage",
"pages/EditAccountPage",
"pages/EventEditPage",
"pages/TodoEditPage",
"pages/SettingsPage"
]
}