34 Commits
Author SHA1 Message Date
laoyang 37062cc75b feat(0.2.0): 待办同步、事件标记、反馈系统迁移、版本升 0.2.0 #5 #6
## 版本
versionCode 10200 / versionName 0.2.0(原 10000 / 0.1.0)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 根因(两层)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验证:assembleHap BUILD SUCCESSFUL;已 install -r 真机。
⚠️ 功能待 AGC 开通「地图服务」+ 重签 Profile 后才生效(否则抛 1002600004);
   未开通时界面显示「地图服务不可用,可直接手动输入地点」,不影响其他功能。
2026-10-21 07:59:21 +08:00
laoyang 29898d5dff 修复:改了日历本颜色,系统日历镜像颜色不跟变
根因:SyncEngine.refreshCalendarColors 每轮同步都 PROPFIND 服务器把新
calendar-color 写进**内存**里的账号对象,但从不 saveAll(唯一落盘点是
removeDeadBooks 的"服务器删本"分支)。而镜像 listBooks 是重新
AccountStore.loadAll 从磁盘读 → 拿到的永远是旧颜色 → ensureCalendar
setConfig(旧色) → 系统日历颜色永远不变。
用户在「账号设置」页看到的是**当场 PROPFIND** 的新颜色,所以表现为
"设置页颜色对、系统日历颜色错"。

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

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

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-20 22:31:29 +08:00
26 changed files with 3792 additions and 203 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": {
"bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq",
"versionCode": 800,
"versionName": "0.0.8",
"versionCode": 10200,
"versionName": "0.2.0",
"buildVersion": "1",
"icon": "$media:layered_image",
"label": "$string:app_name"
+16 -5
View File
@@ -2,8 +2,8 @@
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
- 应用市场送审版本:**0.0.6** | 仓库当前:**0.0.6**(versionCode 600)
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
- 应用市场送审版本:**0.1.0** | 仓库当前:**0.1.0**(versionCode 10000)
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://feedlite.yangyq.net/?sw=synccalendar>
- 开源协议:Apache License 2.0
---
@@ -32,10 +32,19 @@
### 日程编辑与提醒
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
- **地点搜索补全**:在「地点」栏输入关键词即可列出匹配地点(华为 Map Kit / Site Kit),点一下填入完整地址;也可点「地图」进入系统地图界面搜索、拖动图钉选点。**不申请任何定位权限**,手动输入同样可用
- **一键导航**:日程详情中点击「导航 ›」,用系统地理编码把地点地址换算为坐标后唤起高德地图;未安装则回退网页版。**不申请定位权限**(`getAddressesFromLocationName` 无需该权限),不会读取设备实时位置
- **必填项校验强提醒**:未填标题等必填项时,以 Toast + 输入框标红 + 自动滚回定位三管齐下提示,不会出现"提示在屏幕外看不到"的情况
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
### CalDAV 地址自动发现
- 只填服务器域名也能用:按标准 **`/.well-known/caldav`** 自动发现端点,并在常见路径(Nextcloud、群晖等)中依次尝试
- 发现时只认 `resourcetype` 含 `calendar` 的集合,避免把 DAV 根或分享链接误当成日历
- 失败时给出**人话错误提示**(如"正确形式通常为 `https://域名/remote.php/dav/calendars/用户名/`"),而不是抛原始状态码
### 系统日历镜像(可选增强,默认关闭)
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
@@ -81,10 +90,12 @@
我们遵循"**数据自持、最小必要**"原则:
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机)
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机,且以 **AES-256-GCM** 加密存储,密钥由系统 HUKS/TEE 保管、不可导出)
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒、点击地点导航)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
- **不申请定位权限**:本应用从不读取设备实时位置。「一键导航」所需的坐标由系统地理编码服务按**你填写的地址文本**换算,故 `module.json5` 中不含 `LOCATION` / `APPROXIMATELY_LOCATION`
- 地点关键词检索由 HarmonyOS 系统能力**华为地图服务**(Map Kit / Site Kit)提供,仅用于检索,不用于广告、画像或统计分析
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
---
@@ -326,7 +337,7 @@ hvigorw assembleApp --mode project -p product=default -p buildMode=release
| 软件特性 | <https://synccalendar.yangyq.net/sf.html> |
| 隐私政策 | <https://synccalendar.yangyq.net/upa.html> |
| 用户服务协议 | <https://synccalendar.yangyq.net/usa.html> |
| 问题反馈 | <https://synccalendar.yangyq.net/feedback/> |
| 问题反馈 | <https://feedlite.yangyq.net/?sw=synccalendar> |
网站备案:**鲁ICP备17014997号-6A**
@@ -57,6 +57,14 @@ export class DavAccount {
calendarColors: string[] = [];
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
calendarWritable: string[] = [];
/**
* ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应:
* 'event' 纯日历本(只有 VEVENT)
* 'todo' 纯待办本(只有 VTODO)
* 'both' 混合本
* 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容)
*/
calendarComps: string[] = [];
itemCount: number = 0;
lastSyncTime: string = '';
}
@@ -474,6 +482,9 @@ export class AccountStore {
if (parts.length >= 12) {
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
}
if (parts.length >= 13) {
acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';');
}
return acc;
}
@@ -565,6 +576,7 @@ export class AccountStore {
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : [];
a.calendarComps = o['calendarComps'] !== undefined ? o['calendarComps'] as string[] : [];
a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
if (a.id === '') {
@@ -699,4 +711,45 @@ export class AccountStore {
return false;
}
}
/**
* ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。
*
* 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`,
* 于是调用方无法从返回值区分两种情况:
* ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**);
* ② 密文存在但解不开,或读取过程抛错(临时故障)。
*
* 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理,
* 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。
*
* 判定口径(三者同时成立才算"确认空白"):
* - 偏好里**没有**账号密文(`data_b64` 为空);
* - 旧版明文存储也没有账号;
* - 读取过程没有抛异常。
*
* @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过
*/
static async isEmptyVaultConfirmed(context: common.Context): Promise<boolean> {
try {
const store: preferences.Preferences = await Vault.prefs(context);
const data: string = await store.get(VAULT_DATA, '') as string;
if (data !== '') {
// 有密文:只有能解出"0 个账号"才算确认空白;解不开一律视为拿不准
const plain: string | null = await Vault.decrypt(context, data);
if (plain === null) {
return false;
}
const arr: Array<Record<string, Object>> = JSON.parse(plain) as Array<Record<string, Object>>;
return arr.length === 0;
}
// 无密文:再看旧版明文存储是否还有残留账号
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
return legacy === null || legacy.length === 0;
} catch (err) {
const e = err as BusinessError;
console.error(`[AccountStore] isEmptyVaultConfirmed 判定失败(按"拿不准"处理): ${e.message}`);
return false;
}
}
}
+16 -2
View File
@@ -45,8 +45,13 @@ export class AppSettings {
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / LOCATION / APPROXIMATELY_LOCATION(均前台),
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)与"添加/修改/删除日历活动"(WRITE_CALENDAR)。
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(均前台)。
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)。
// ⚠️ 0.1.0 起已**移除 LOCATION / APPROXIMATELY_LOCATION**:实测本 SDK(API 26)的
// `geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,
// 地理编码不需要定位权限;且应用从不调用 getCurrentLocation,本就不读实时位置。
// ⇒ 隐私政策/隐私标签里都**不得**再声称"读取设备精确位置或大致位置"。
// ⇒ 若将来真要用实时定位,必须同时改 module.json5、本注释、upa.html 与 AGC 隐私标签。
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
/** 隐私政策网址 */
static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html';
@@ -56,11 +61,20 @@ export class AppSettings {
static readonly FEATURES_URL: string = 'https://synccalendar.yangyq.net/sf.html';
/** 使用帮助网址 */
static readonly HELP_URL: string = 'https://synccalendar.yangyq.net/uh.html';
/**
* ⭐ 问题反馈网址。
*
* 换到 FeedLite(多站点共用一套反馈平台),`?sw=` 指定本项目站点标识。
* 旧地址 synccalendar.yangyq.net/feedback/ 是自建 PHP 论坛,
* 里面还有 3 条历史反馈(含一条完整 PR 贡献),服务暂不删除。
*/
static readonly FEEDBACK_URL: string = 'https://feedlite.yangyq.net/?sw=synccalendar';
/** 文档标题(内置浏览器标题栏用) */
static readonly PRIVACY_TITLE: string = '隐私政策';
static readonly AGREEMENT_TITLE: string = '用户服务协议';
static readonly FEATURES_TITLE: string = '软件特性';
static readonly HELP_TITLE: string = '使用帮助';
static readonly FEEDBACK_TITLE: string = '问题反馈';
/**
* 是否已同意《隐私政策》与《用户协议》(首启同意弹窗)。
@@ -25,6 +25,11 @@ export class DisplayEvent {
color: string = '#007DFF';
completed: boolean = false; // 仅待办使用
recurring: boolean = false; // 是否为重复日程展开出的发生
/** ⭐ 待办的 RRULE 原文(空 = 非循环)。与 recurring 不同:
* recurring 只说"重不重复",这里带的是"多久重复一次"。 */
recurRule: string = '';
/** 待办的 DTSTART(重复基准,毫秒;0 = 无) */
dtstart: number = 0;
writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑)
}
@@ -341,6 +346,11 @@ export class CalendarDataService {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.color : '#9AA0A6';
};
// ⭐ 待办按日历本分组显示需要"本名";原来只带 calKey,分组标题无法显示
const nameOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.name : '';
};
try {
const rows: LocalEvent[] = await EventDb.queryTodos(context);
for (const e of rows) {
@@ -357,8 +367,12 @@ export class CalendarDataService {
d.endTime = e.endTime;
d.isAllDay = e.isAllDay;
d.calKey = e.calKey;
d.calName = nameOf(e.calKey);
d.color = colorOf(e.calKey);
d.completed = e.completed;
d.recurRule = e.rrule;
d.dtstart = e.dtstart;
d.recurring = e.rrule !== '';
result.push(d);
}
} catch (err) {
+76
View File
@@ -18,6 +18,15 @@ export class DavColorEntry {
color: string = ''; // 规范化后的 #RRGGBB,可能为空
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
/**
* ⭐ COMP_SET 该集合支持的组件类型(来自 supported-calendar-component-set)。
* 'event' = 纯日历本(只有 VEVENT)
* 'todo' = 纯待办本(只有 VTODO)
* 'both' = 混合本(两者都有)
* 'unknown' = 服务器未声明该属性(老实现常见)→ 按日历对待,保持兼容
*/
comps: string = 'unknown';
}
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
@@ -25,6 +34,8 @@ export class DavCalendarEntry {
href: string = ''; // 完整 URL(已补全域名)
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
color: string = ''; // 规范化后的 #RRGGBB,可能为空
/** ⭐ COMP_SET 'event' 纯日历 / 'todo' 纯待办 / 'both' 混合 / 'unknown' 服务器未声明 */
comps: string = 'unknown';
}
/**
@@ -61,6 +72,8 @@ export class DavClient {
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'<d:current-user-privilege-set/>' +
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
@@ -104,6 +117,7 @@ export class DavClient {
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
}
entry.color = color;
entry.comps = DavClient.parseComps(block);
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
if (privSet !== '') {
@@ -123,6 +137,33 @@ export class DavClient {
}
}
/**
* ⭐ COMP_SET 解析 supported-calendar-component-set,归一成四类:
* event / todo / both / unknown
*
* 为什么 unknown 要单独存在:很多服务器(含自研实现)压根不返回这个属性。
* 这时不能武断判成"不是待办本",否则用户原本能用的日历会被当成待办、
* 或者反过来把纯日历标成待办 —— 保守按"日历"对待,不破坏既有行为。
*/
static parseComps(block: string): string {
const seg: string = DavClient.extractTag(block, 'supported-calendar-component-set');
if (seg === '') {
return 'unknown';
}
const event: boolean = /name\s*=\s*["']VEVENT["']/i.test(seg);
const todo: boolean = /name\s*=\s*["']VTODO["']/i.test(seg);
if (event && todo) {
return 'both';
}
if (todo) {
return 'todo';
}
if (event) {
return 'event';
}
return 'unknown';
}
/**
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
*
@@ -136,6 +177,7 @@ export class DavClient {
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
const out = new DavCalendarDiscovery();
@@ -197,6 +239,9 @@ export class DavClient {
entry.href = href.startsWith('http') ? href : origin + href;
entry.displayName = name;
entry.color = color;
entry.comps = DavClient.parseComps(block);
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
out.entries.push(entry);
}
return out;
@@ -250,6 +295,37 @@ export class DavClient {
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
}
/**
* GET 拉取单个资源原文,**失败抛错**(与 getRaw 相反)。
*
* 为什么需要严格版:待办写回要"先 GET 原文、改字段、再 PUT"。
* 若 GET 失败却拿到一个 `"HTTP 404"` 之类的字符串并当 ICS 内容 PUT 回去,
* 会把服务器上那条待办**覆盖成一句错误信息**(数据破坏)。
* 原 getRaw 保留原行为不动(诊断用,已有调用方)。
*/
static async getRawStrict(url: string, auth: string): Promise<string> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: http.RequestMethod.GET,
header: {
'Authorization': auth,
'Content-Type': 'text/calendar; charset=utf-8',
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 10000,
readTimeout: 20000,
expectDataType: http.HttpDataType.STRING
});
if (resp.responseCode < 200 || resp.responseCode >= 300) {
throw new DavHttpError(resp.responseCode, `拉取待办原文失败,服务器返回 ${resp.responseCode}`);
}
return resp.result as string;
} finally {
httpRequest.destroy();
}
}
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
static async getRaw(url: string, auth: string): Promise<string> {
const httpRequest = http.createHttp();
+95
View File
@@ -24,6 +24,10 @@ export class LocalEvent {
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
/** ⭐ 待办的 DTSTART(重复规则的基准日,毫秒;0 = 该条没有 DTSTART)。
* 与 startTime 必须分开:startTime 对 VTODO 是**截止时间 DUE**,
* 而 DTSTART 是 RRULE 的起点,两者语义不同、不能互相覆盖。 */
dtstart: number = 0;
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
@@ -47,6 +51,7 @@ export class RemoteEvent {
isTodo: boolean = false; // VTODO 待办
completed: boolean = false; // VTODO STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE
dtstart: number = 0; // ⭐ VTODO 的 DTSTART(RRULE 基准),与 startTime(DUE) 分开
exdate: string = ''; // 原始 EXDATE(分号分隔)
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
@@ -124,6 +129,11 @@ export class EventDb {
} catch (err) {
// 列已存在
}
try {
await store.executeSql('ALTER TABLE events ADD COLUMN dtstart INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
} catch (err) {
@@ -181,6 +191,7 @@ export class EventDb {
const e = new LocalEvent();
e.id = rs.getLong(rs.getColumnIndex('id'));
e.uid = rs.getString(rs.getColumnIndex('uid'));
e.dtstart = rs.getLong(rs.getColumnIndex('dtstart'));
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
e.href = rs.getString(rs.getColumnIndex('href'));
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
@@ -336,6 +347,90 @@ export class EventDb {
}
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
/** 保存循环规则与基准(设置/取消循环后调用) */
static async updateTodoRecurrence(context: common.Context, id: number,
rrule: string, dtstart: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'rrule': rrule,
'dtstart': dtstart,
'recurring': rrule !== '' ? 1 : 0,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
/**
* 循环待办"完成一轮"后更新本地:推后的基准/截止 + 清除完成态。
*
* ⚠️ 必须同时把 completed 置 0、dirty 清掉:
* 否则界面会显示成"已完成",而服务器上其实是"下一轮未完成",两边不一致。
*/
static async updateTodoAfterRound(context: common.Context, id: number,
dtstart: number, dueMs: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'dtstart': dtstart,
'start_time': dueMs,
'end_time': dueMs,
'completed': 0,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
/** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */
static async updateTodoFields(context: common.Context, id: number, title: string,
desc: string, dueMs: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'title': title,
'description': desc,
'start_time': dueMs,
'etag': etag,
'dirty': 0
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
static async setTodoCompleted(context: common.Context, id: number, completed: boolean,
etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const values: relationalStore.ValuesBucket = {
'completed': completed ? 1 : 0,
'dirty': 0,
'etag': etag
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
static async queryById(context: common.Context, id: number): Promise<LocalEvent | null> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
const rs = await store.query(predicates);
try {
if (rs.goToNextRow()) {
return EventDb.fromRow(rs);
}
return null;
} finally {
rs.close();
}
}
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
+568 -1
View File
@@ -170,6 +170,562 @@ export class IcsUtil {
return 0;
}
/**
* ⭐ 在**服务器原文**上改写某个 VTODO 的完成状态,返回新的整份 ICS。
*
* 为什么不从本地字段重建:VTODO 上还有 SUMMARY / DESCRIPTION / DUE / PRIORITY /
* CATEGORIES / X-* 等大量字段,本地库只保存了其中少数几个。
* 若按本地字段重新拼一份 VTODO 再 PUT,等于**把没保存的字段全部删掉**(丢用户数据)。
* 因此这里只做"在原文上精确改那几行",其余内容原样保留。
*
* 改动范围(按 RFC 5545,三者要一起维护,否则各客户端显示不一致):
* - `STATUS:COMPLETED` ⇄ `STATUS:NEEDS-ACTION`
* - `COMPLETED:<UTC时间>`(完成时写入,取消完成时删除)
* - `PERCENT-COMPLETE:100` ⇄ `PERCENT-COMPLETE:0`
*
* @param ics 服务器上的原始 .ics 全文
* @param uid 目标待办的 UID(一个资源里理论上只有一个 VTODO,但仍按 uid 定位更稳)
* @param done true=标记完成;false=取消完成
* @returns 改写后的完整 ICS(调用方直接 PUT)
*/
static rewriteTodoCompletion(ics: string, uid: string, done: boolean): string {
const crlf: string = '\r\n';
// 折行还原(RFC 5545 允许续行,续行以空格/Tab 开头)
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, ''));
const upperOf = (s: string): string => s.toUpperCase();
const nowUtc: string = IcsUtil.fmtUtcNow();
// ---------- 第 1 遍:找出"含目标 uid 的 VTODO"的 [begin, end] 行号区间 ----------
// 必须两遍:RFC 5545 不规定字段顺序,UID 可能出现在 STATUS 之后。
// 单遍实现会漏改(实测风险:STATUS 在前时 matched 仍为 false → 用户点了没反应)。
let targetBegin: number = -1;
let targetEnd: number = -1;
let targetOrdinal: number = -1; // 目标 VTODO 是第几个块(0 基),供插入段按结构定位
let curBegin: number = -1;
let curHasUid: boolean = false;
let blockIdx: number = -1;
for (let i = 0; i < lines.length; i++) {
const u: string = upperOf(lines[i]);
if (u.startsWith('BEGIN:VTODO')) {
blockIdx++;
curBegin = i;
curHasUid = false;
continue;
}
if (u.startsWith('UID:') && curBegin >= 0) {
if (lines[i].substring(4).trim() === uid) {
curHasUid = true;
}
continue;
}
if (u.startsWith('END:VTODO')) {
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
targetBegin = curBegin;
targetEnd = i;
targetOrdinal = blockIdx;
}
curBegin = -1;
curHasUid = false;
}
}
if (targetBegin < 0) {
// 找不到目标 uid:原样返回(调用方会 PUT 回去,内容未变,等于无操作)
return ics;
}
// ---------- 第 2 遍:在区间内改写状态字段 ----------
const out: string[] = [];
let sawStatus: boolean = false;
let sawPercent: boolean = false;
for (let i = 0; i < lines.length; i++) {
const line: string = lines[i];
const u: string = upperOf(line);
const inTarget: boolean = i > targetBegin && i < targetEnd;
if (!inTarget) {
out.push(line);
continue;
}
if (u.startsWith('STATUS:')) {
sawStatus = true;
out.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
continue;
}
if (u.startsWith('COMPLETED')) {
continue; // 丢弃旧的,完成时下面统一补
}
if (u.startsWith('PERCENT-COMPLETE')) {
sawPercent = true;
out.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
continue;
}
out.push(line);
}
// 缺字段则在目标 VTODO 的 END 之前补齐(有的客户端缺 STATUS 就不显示完成态)
const extra: string[] = [];
if (!sawStatus) {
extra.push(done ? 'STATUS:COMPLETED' : 'STATUS:NEEDS-ACTION');
}
if (done) {
extra.push('COMPLETED:' + nowUtc);
}
if (!sawPercent) {
extra.push(done ? 'PERCENT-COMPLETE:100' : 'PERCENT-COMPLETE:0');
}
if (extra.length === 0) {
const plain: string = out.join(crlf);
return plain.endsWith(crlf) ? plain : plain + crlf;
}
// ⭐ 插入点必须按"结构"定位,不能按行号:
// 改动可能删掉了旧的 COMPLETED 行,out 与第一遍的 lines 行号已不对齐,
// 用行号定位会把字段写到 END:VTODO 之外(实测会落到 END:VCALENDAR 之后,
// 结构非法,部分服务端直接 400)。
// 这里重新数 VTODO 块,找到"第 targetOrdinal 个"块的 END 行插进去。
const result: string[] = [];
let vtodoIdx: number = -1; // 当前处于第几个 VTODO 块内(-1=不在块内)
let inserted: boolean = false;
for (let i = 0; i < out.length; i++) {
const u: string = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) {
vtodoIdx++;
result.push(out[i]);
continue;
}
if (u.startsWith('END:VTODO')) {
// 走到目标块的 END 之前先补字段
if (!inserted && vtodoIdx === targetOrdinal) {
for (const ex of extra) {
result.push(ex);
}
inserted = true;
}
// ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 0 重新数,
// 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。
result.push(out[i]);
continue;
}
result.push(out[i]);
}
if (!inserted) {
// 兜底(理论上不会发生):退回到"整份文本末尾 END:VCALENDAR 之前"插入
const tail0: string = result.join(crlf);
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
if (idxVc > 0) {
const head: string = tail0.substring(0, idxVc);
const tail: string = tail0.substring(idxVc);
const merged: string = head + extra.join(crlf) + crlf + tail;
return merged.endsWith(crlf) ? merged : merged + crlf;
}
for (const ex of extra) {
result.push(ex);
}
}
const text2: string = result.join(crlf);
return text2.endsWith(crlf) ? text2 : text2 + crlf;
}
/**
* 在**服务器原文**上改写 VTODO 的内容字段,返回新的整份 ICS。
*
* 只改三项,其余(PRIORITY / CATEGORIES / X-* / STATUS / COMPLETED …)原样保留:
* - SUMMARY 标题
* - DESCRIPTION 备注
* - DUE 截止时间(dueMs<=0 → 删除该行)
*
* 与 rewriteTodoCompletion 相同的两遍处理:
* ① 先定位含目标 uid 的 VTODO 块(RFC 5545 不规定字段顺序,不能边扫边改)
* ② 再在块内替换/删除目标字段,缺失的在 END:VTODO 前补齐
*/
static rewriteTodoFields(ics: string, uid: string, title: string,
desc: string, dueMs: number): string {
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$/, ''));
const upperOf = (s: string): string => s.toUpperCase();
// ---- 第 1 遍:定位目标 VTODO 块 ----
let targetBegin: number = -1;
let targetEnd: number = -1;
let curBegin: number = -1;
let curHasUid: boolean = false;
for (let i = 0; i < lines.length; i++) {
const u: string = upperOf(lines[i]);
if (u.startsWith('BEGIN:VTODO')) {
curBegin = i;
curHasUid = false;
continue;
}
if (u.startsWith('UID:') && curBegin >= 0) {
if (lines[i].substring(4).trim() === uid) {
curHasUid = true;
}
continue;
}
if (u.startsWith('END:VTODO')) {
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
targetBegin = curBegin;
targetEnd = i;
}
curBegin = -1;
curHasUid = false;
}
}
if (targetBegin < 0) {
return ics; // 找不到目标,原样返回
}
// ---- 第 2 遍:块内改写 ----
const out: string[] = [];
let sawSummary: boolean = false;
let sawDesc: boolean = false;
let sawDue: boolean = false;
for (let i = 0; i < lines.length; i++) {
const line: string = lines[i];
const u: string = upperOf(line);
const inTarget: boolean = i > targetBegin && i < targetEnd;
if (!inTarget) {
out.push(line);
continue;
}
if (u.startsWith('SUMMARY')) {
sawSummary = true;
if (title !== '') {
out.push('SUMMARY:' + IcsUtil.escape(title));
}
continue;
}
if (u.startsWith('DESCRIPTION')) {
sawDesc = true;
if (desc !== '') {
out.push('DESCRIPTION:' + IcsUtil.escape(desc));
}
continue;
}
if (u.startsWith('DUE')) {
sawDue = true;
if (dueMs > 0) {
out.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
}
continue;
}
out.push(line);
}
// 缺失字段补齐
const extra: string[] = [];
if (!sawSummary && title !== '') {
extra.push('SUMMARY:' + IcsUtil.escape(title));
}
if (!sawDesc && desc !== '') {
extra.push('DESCRIPTION:' + IcsUtil.escape(desc));
}
if (!sawDue && dueMs > 0) {
extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
}
if (extra.length === 0) {
const plain: string = out.join(crlf);
return plain.endsWith(crlf) ? plain : plain + crlf;
}
// 按**结构**定位插入点(不能按行号:上面可能删掉了 DUE 行,行号已不对齐)
const result: string[] = [];
let vtodoIdx: number = -1;
let targetOrdinal: number = -1;
// 重新数出目标的块序号
{
let n: number = -1;
let inBlk: boolean = false;
let has: boolean = false;
for (let i = 0; i < out.length; i++) {
const u: string = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) {
n++;
inBlk = true;
has = false;
continue;
}
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) {
has = true;
continue;
}
if (u.startsWith('END:VTODO') && inBlk) {
if (has && targetOrdinal < 0) {
targetOrdinal = n;
}
inBlk = false;
}
}
}
let inserted: boolean = false;
for (let i = 0; i < out.length; i++) {
const u: string = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) {
vtodoIdx++;
result.push(out[i]);
continue;
}
if (u.startsWith('END:VTODO')) {
if (!inserted && vtodoIdx === targetOrdinal) {
for (const ex of extra) {
result.push(ex);
}
inserted = true;
}
// ⚠️ 不要重置 vtodoIdx:它是全局块序号,重置会让下一块从 0 重新数,
// 多 VTODO 文件里永远匹配不到 targetOrdinal(静默失败)。
result.push(out[i]);
continue;
}
result.push(out[i]);
}
if (!inserted) {
const tail0: string = result.join(crlf);
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
if (idxVc > 0) {
const merged: string = tail0.substring(0, idxVc) + extra.join(crlf) + crlf
+ tail0.substring(idxVc);
return merged.endsWith(crlf) ? merged : merged + crlf;
}
for (const ex of extra) {
result.push(ex);
}
}
const text2: string = result.join(crlf);
return text2.endsWith(crlf) ? text2 : text2 + crlf;
}
/**
* 按 FREQ 把时间往后推一个周期(用于"完成一个循环任务 → 生成下一次")。
*
* ⚠️ 月份/年份推进要处理"月末溢出":
* 1 月 31 日 + 1 个月 在 JS 里会得到 3 月 3 日(2 月没有 31 日)。
* RFC 5545 规定这类"不存在的日期"应当**跳过**而非顺延,
* 但对家务类待办来说"顺延到 3 号"比"跳过整个月"更符合直觉,
* 这里采用**钳制到当月最后一天**(1/31 → 2/28),并注释说明取舍。
*
* @param freq DAILY / WEEKLY / MONTHLY / YEARLY
* @param fromMs 基准时间
* @returns 下一周期的时间(同一天内时分秒保持不变)
*/
/** 从 RRULE 原文取出 FREQ 值(DAILY / WEEKLY / MONTHLY / YEARLY);取不到返回 DAILY */
static freqOfRrule(rrule: string): string {
const parts: string[] = rrule.split(';');
for (const part of parts) {
const idx: number = part.indexOf('=');
if (idx <= 0) {
continue;
}
const key: string = part.substring(0, idx).trim().toUpperCase();
if (key === 'FREQ') {
return part.substring(idx + 1).trim().toUpperCase();
}
}
return 'DAILY';
}
static incForFreq(freq: string, fromMs: number): number {
const d: Date = new Date(fromMs);
const f: string = freq.toUpperCase();
if (f === 'DAILY') {
return fromMs + 86400000;
}
if (f === 'WEEKLY') {
return fromMs + 7 * 86400000;
}
if (f === 'MONTHLY') {
const y: number = d.getFullYear();
const m: number = d.getMonth() + 1;
const day: number = d.getDate();
const lastDayOfNext: number = new Date(y, m + 1, 0).getDate();
return new Date(y, m, Math.min(day, lastDayOfNext),
d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
}
if (f === 'YEARLY') {
const y: number = d.getFullYear() + 1;
const m: number = d.getMonth();
const day: number = d.getDate();
// 2/29 → 次年 2/28(闰年才有 29)
const lastDay: number = new Date(y, m + 1, 0).getDate();
return new Date(y, m, Math.min(day, lastDay),
d.getHours(), d.getMinutes(), d.getSeconds()).getTime();
}
return fromMs + 86400000;
}
/**
* 在**服务器原文**上写入循环规则(RRULE + DTSTART + DUE)。
*
* 只在这三处动,其余字段(SUMMARY/DESCRIPTION/PRIORITY/X-* …)原样保留。
*
* @param rrule 规则原文,如 `FREQ=WEEKLY`;空串 = 取消循环(删除 RRULE/DTSTART,保留 DUE)
* @param dtstart 重复基准(毫秒);<=0 时用 dueMs 兜底
* @param dueMs 本轮的截止时间(毫秒);<=0 表示无截止
*/
static rewriteTodoRecurrence(ics: string, uid: string, rrule: string,
dtstart: number, dueMs: number): string {
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$/, ''));
const upperOf = (s: string): string => s.toUpperCase();
const hasRule: boolean = rrule.trim() !== '';
// ---- 第 1 遍:定位目标 VTODO 块(RFC 5545 不规定字段顺序,必须两遍)----
let targetBegin: number = -1;
let targetEnd: number = -1;
let curBegin: number = -1;
let curHasUid: boolean = false;
for (let i = 0; i < lines.length; i++) {
const u: string = upperOf(lines[i]);
if (u.startsWith('BEGIN:VTODO')) {
curBegin = i;
curHasUid = false;
continue;
}
if (u.startsWith('UID:') && curBegin >= 0) {
if (lines[i].substring(4).trim() === uid) {
curHasUid = true;
}
continue;
}
if (u.startsWith('END:VTODO')) {
if (curBegin >= 0 && curHasUid && targetBegin < 0) {
targetBegin = curBegin;
targetEnd = i;
}
curBegin = -1;
curHasUid = false;
}
}
if (targetBegin < 0) {
return ics;
}
// ---- 第 2 遍:块内替换/删除 RRULE、DTSTART、DUE ----
const out: string[] = [];
let sawRule: boolean = false;
let sawStart: boolean = false;
let sawDue: boolean = false;
for (let i = 0; i < lines.length; i++) {
const line: string = lines[i];
const u: string = upperOf(line);
const inTarget: boolean = i > targetBegin && i < targetEnd;
if (!inTarget) {
out.push(line);
continue;
}
if (u.startsWith('RRULE')) {
sawRule = true;
if (hasRule) {
out.push('RRULE:' + rrule.trim());
}
continue; // 取消循环时整行丢掉
}
if (u.startsWith('DTSTART')) {
sawStart = true;
if (hasRule && dtstart > 0) {
out.push('DTSTART:' + IcsUtil.fmtIcsTime(dtstart, false));
}
continue;
}
if (u.startsWith('DUE')) {
sawDue = true;
if (dueMs > 0) {
out.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
}
continue;
}
out.push(line);
}
// 缺字段补齐
const extra: string[] = [];
if (hasRule) {
if (!sawRule) {
extra.push('RRULE:' + rrule.trim());
}
if (!sawStart && dtstart > 0) {
extra.push('DTSTART:' + IcsUtil.fmtIcsTime(dtstart, false));
}
if (!sawDue && dueMs > 0) {
extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false));
}
}
if (extra.length === 0) {
const plain: string = out.join(crlf);
return plain.endsWith(crlf) ? plain : plain + crlf;
}
// 按**结构**定位插入点(改动会删行,行号已不对齐)
let targetOrdinal: number = -1;
{
let n: number = -1;
let inBlk: boolean = false;
let has: boolean = false;
for (let i = 0; i < out.length; i++) {
const u: string = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) {
n++;
inBlk = true;
has = false;
continue;
}
if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) {
has = true;
continue;
}
if (u.startsWith('END:VTODO') && inBlk) {
if (has && targetOrdinal < 0) {
targetOrdinal = n;
}
inBlk = false;
}
}
}
// ⭐ RRULE_IDX_FIX vtodoIdx 是**全局块序号**,只在 BEGIN:VTODO 自增。
// 之前在这里的 END:VTODO 分支把它重置成 -1,导致每块都从 0 重新数,
// 多 VTODO 文件里永远匹配不到 targetOrdinal(第 2 块应为 1)⇒ RRULE 加不上。
const result: string[] = [];
let vtodoIdx: number = -1;
let inserted: boolean = false;
for (let i = 0; i < out.length; i++) {
const u: string = upperOf(out[i]);
if (u.startsWith('BEGIN:VTODO')) {
vtodoIdx++;
result.push(out[i]);
continue;
}
if (u.startsWith('END:VTODO')) {
if (!inserted && vtodoIdx === targetOrdinal) {
for (const ex of extra) {
result.push(ex);
}
inserted = true;
}
result.push(out[i]);
continue;
}
result.push(out[i]);
}
if (!inserted) {
const tail0: string = result.join(crlf);
const idxVc: number = tail0.lastIndexOf('END:VCALENDAR');
if (idxVc > 0) {
const merged: string = tail0.substring(0, idxVc) + extra.join(crlf) + crlf
+ tail0.substring(idxVc);
return merged.endsWith(crlf) ? merged : merged + crlf;
}
for (const ex of extra) {
result.push(ex);
}
}
const text2: string = result.join(crlf);
return text2.endsWith(crlf) ? text2 : text2 + crlf;
}
/** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
static parseTodos(ics: string): RemoteEvent[] {
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
@@ -210,7 +766,18 @@ export class IcsUtil {
current.location = IcsUtil.unescape(value);
} else if (propName === 'RRULE') {
current.recurring = true;
} else if (propName === 'DUE' || propName === 'DTSTART') {
// ⭐ 保存规则原文:原来只置了 recurring 标志,规则本身被丢掉,
// 导致「知道它重复,但不知道多久重复一次」。
current.rrule = value.trim();
} else if (propName === 'DTSTART') {
// ⭐ DTSTART 单独存:它是 RRULE 的重复基准,**不是**截止时间。
// 原来与 DUE 共用 startTime,谁后出现谁覆盖 ⇒ 循环任务基准错乱。
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
const t = IcsUtil.parseTime(value, isDateOnly);
if (t !== null) {
current.dtstart = t.time;
}
} else if (propName === 'DUE') {
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
const t = IcsUtil.parseTime(value, isDateOnly);
if (t !== null) {
+253
View File
@@ -0,0 +1,253 @@
// entry/src/main/ets/common/PlaceSuggest.ets
// 地点补全/选点:基于鸿蒙系统内置的 Map Kit(Site Kit + sceneMap)
//
// 为什么用华为 Map Kit 而不是高德:
// 1) 系统内置、无需第三方 SDK 依赖,也不需要我们自己维护一个 API Key;
// 2) Site Kit 的 queryAutoComplete / searchByText / reverseGeocode **不需要任何权限**
// (SDK 声明里没有 @permission),符合本项目「权限只在用户主动点该功能时申请」的硬约束;
// 3) HarmonyOS 5.0 及以上 Map Kit 的地图加载/POI 搜索/地理编码当前免费(官方论坛口径)。
//
// ⚠️ 前置条件(必须由用户在 AGC 完成,代码无法替代):
// AGC 项目 → 开放能力管理 → 打开「地图服务」,并**重新申请 Profile**(旧的 Profile 不含地图能力)。
// 未开通时调用会抛 1002600004(The Map permission is not enabled)。
// 因此本类所有入口都必须**优雅降级**:失败只返回空列表,绝不影响日程本身的保存。
import { site, sceneMap, mapCommon } from '@kit.MapKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { LogUtil } from './LogUtil';
/** 一条地点候选(界面直接消费) */
export class PlaceItem {
/** Site Kit 的地点 ID,可用于 searchById 取详情 */
siteId: string = '';
/** 地点名称,如「星巴克(中关村店)」 */
name: string = '';
/** 完整格式化地址,如「北京市海淀区中关村大街1号」 */
address: string = '';
/** 纬度(无法取得时为 0) */
lat: number = 0;
/** 经度(无法取得时为 0) */
lng: number = 0;
/** 是否有有效坐标 */
hasCoord: boolean = false;
/** 界面上第一行显示:有名称用名称,否则用地址 */
get title(): string {
return this.name !== '' ? this.name : this.address;
}
/** 界面上第二行显示:名称+地址重复时不显示 */
get subtitle(): string {
if (this.address === '') {
return '';
}
if (this.name === '' || this.address === this.name) {
return '';
}
return this.address;
}
/** 写入日程 location 字段的文本:优先完整地址,退化为名称 */
get locationText(): string {
return this.address !== '' ? this.address : this.name;
}
}
export class PlaceSuggest {
/** 地图能力是否可用(false 时界面应显示提示而不是反复重试) */
private static unavailable: boolean = false;
/** 最近一次失败原因(给界面显示,便于用户/开发者判断是"没开通"还是"网络问题") */
private static lastError: string = '';
/** 当前在途请求序号:用于丢弃过期结果(边打字边搜时后发先至) */
private static seq: number = 0;
static isUnavailable(): boolean {
return PlaceSuggest.unavailable;
}
static getLastError(): string {
return PlaceSuggest.lastError;
}
/**
* 关键字补全:输入几个字返回候选地点。
* 失败/未开通一律返回空数组(不抛异常,不影响其他功能)。
* @param keyword 用户已输入的关键字
* @param context 可选,传了就用带 context 的重载(定位更准)
*/
static async query(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
const q: string = keyword.trim();
if (q === '') {
return [];
}
if (PlaceSuggest.unavailable) {
return [];
}
// 序号机制:只接受最后一次请求的结果
PlaceSuggest.seq++;
const mySeq: number = PlaceSuggest.seq;
try {
const params: site.QueryAutoCompleteParams = {
query: q,
language: 'zh-CN',
isCityLimit: false
};
const res: site.QueryAutoCompleteResult = context !== undefined
? await site.queryAutoComplete(context, params)
: await site.queryAutoComplete(params);
if (mySeq !== PlaceSuggest.seq) {
// 已有更新的请求发出,丢弃本次结果
return [];
}
return PlaceSuggest.toItems(res.sites);
} catch (err) {
PlaceSuggest.handleError('补全', err);
// 补全失败时退一步用关键字搜索(部分环境 autoComplete 不可用但 searchByText 可用)
return await PlaceSuggest.searchFallback(q, context, mySeq);
}
}
/**
* 关键字搜索(补全失败时的兜底,也用于用户直接点"搜索")。
*/
static async search(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
const q: string = keyword.trim();
if (q === '' || PlaceSuggest.unavailable) {
return [];
}
PlaceSuggest.seq++;
const mySeq: number = PlaceSuggest.seq;
return await PlaceSuggest.searchFallback(q, context, mySeq);
}
private static async searchFallback(keyword: string, context: common.Context | undefined,
mySeq: number): Promise<PlaceItem[]> {
try {
const params: site.SearchByTextParams = {
query: keyword,
language: 'zh-CN'
};
const res: site.SearchByTextResult = context !== undefined
? await site.searchByText(context, params)
: await site.searchByText(params);
if (mySeq !== PlaceSuggest.seq) {
return [];
}
return PlaceSuggest.toItems(res.sites);
} catch (err) {
PlaceSuggest.handleError('搜索', err);
return [];
}
}
/**
* 打开系统地图选点页,返回用户选中的地点(用户取消返回 null)。
* 这是最省事也最不容易出错的方式:地图、搜索、周边都由系统界面提供。
*/
static async pickOnMap(context: common.UIAbilityContext): Promise<PlaceItem | null> {
try {
const options: sceneMap.LocationChoosingOptions = {
language: 'zh-CN',
searchEnabled: true,
showNearbyPoi: true
};
const r: sceneMap.LocationChoosingResult = await sceneMap.chooseLocation(context, options);
const item: PlaceItem = new PlaceItem();
item.siteId = r.siteId !== undefined ? r.siteId : '';
item.name = r.name !== undefined ? r.name : '';
item.address = r.address !== undefined ? r.address : '';
if (r.location !== undefined) {
item.lat = r.location.latitude;
item.lng = r.location.longitude;
item.hasCoord = true;
}
// 两者都空视为取消/无效选择
if (item.name === '' && item.address === '') {
return null;
}
return item;
} catch (err) {
PlaceSuggest.handleError('地图选点', err);
return null;
}
}
/** 生成"名称 — 地址"这种便于粘贴进备注/地点的单行文本 */
static formatOneLine(item: PlaceItem): string {
if (item.name !== '' && item.address !== '' && item.name !== item.address) {
return `${item.name}|${item.address}`;
}
return item.locationText;
}
private static toItems(sites: site.Site[] | undefined): PlaceItem[] {
const out: PlaceItem[] = [];
if (sites === undefined) {
return out;
}
for (let i = 0; i < sites.length; i++) {
const s: site.Site = sites[i];
const item: PlaceItem = new PlaceItem();
item.siteId = s.siteId !== undefined ? s.siteId : '';
item.name = s.name !== undefined ? s.name : '';
// formatAddress 是"省市区+街道门牌"的完整串,最适合写进 LOCATION
item.address = s.formatAddress !== undefined ? s.formatAddress : '';
if (item.address === '' && s.addressComponent !== undefined) {
// 极少数数据只有结构化 addressComponent(字段仅 countryName/locality/city/streetNumber),
// 按"国 → 城市 → 区县(locality) → 街道门牌"拼一个保底地址
item.address = PlaceSuggest.joinAddress(s.addressComponent);
}
if (s.location !== undefined) {
item.lat = s.location.latitude;
item.lng = s.location.longitude;
item.hasCoord = true;
}
if (item.name !== '' || item.address !== '') {
out.push(item);
}
}
return out;
}
/** 从结构化地址组件拼一条保底地址(SDK 里就这几个字段) */
private static joinAddress(ac: site.AddressComponent): string {
const parts: string[] = [];
if (ac.countryName !== undefined && ac.countryName !== '') {
parts.push(ac.countryName);
}
if (ac.city !== undefined && ac.city.cityName !== undefined && ac.city.cityName !== '') {
parts.push(ac.city.cityName);
}
if (ac.locality !== undefined && ac.locality !== '') {
parts.push(ac.locality);
}
if (ac.streetNumber !== undefined) {
const sn: site.StreetNumber = ac.streetNumber;
if (sn.streetName !== undefined && sn.streetName !== '') {
parts.push(sn.streetName);
}
if (sn.streetNumber !== undefined && sn.streetNumber !== '') {
parts.push(sn.streetNumber);
}
}
return parts.join('');
}
/**
* 统一错误处理:
* - 1002600004 地图权限未开通 → 标记不可用,界面不再重试(避免每次打字都打一遍网络)
* - 1002600003 鉴权失败(Profile 未含地图能力)
* - 1002600002 连不上服务器
*/
private static handleError(op: string, err: Object): void {
const e = err as BusinessError;
const code: number = e.code !== undefined ? e.code : 0;
PlaceSuggest.lastError = `${op}失败(code=${code}): ${e.message}`;
if (code === 1002600004 || code === 1002600003) {
PlaceSuggest.unavailable = true;
LogUtil.write(`[地点] ${op}不可用(code=${code}):需在 AGC 打开「地图服务」并重新申请 Profile`);
} else {
LogUtil.write(`[地点] ${PlaceSuggest.lastError}`);
}
}
}
+63 -4
View File
@@ -122,7 +122,23 @@ export class SyncEngine {
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
const auth: string = DavClient.authHeader(acc.username, acc.password);
// 0) 刷新服务器端日历本颜色(每次同步都校正)
await SyncEngine.refreshCalendarColors(acc, auth);
// ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
if (colorChanged) {
try {
const all: DavAccount[] = await AccountStore.loadAll(context);
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
if (idx >= 0) {
all[idx] = acc;
await AccountStore.saveAll(context, all);
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
}
}
// 1) 推送该账号日历本下的本地修改
await SyncEngine.pushDirtyForAccount(context, acc, auth);
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
@@ -157,6 +173,36 @@ export class SyncEngine {
throw err as Error;
}
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
// ⭐ SYNC_TODOS 同步该本里的待办(VTODO)。
// 原来整个同步流程只处理 VEVENT,`DavClient.reportTodos` 写了却没人调用,
// 导致待办数据从来不会从服务器拉下来 ——
// 用户取消勾选本(触发 pruneStaleHrefs 清行)后再勾回来,待办就永久消失了。
// 放在这里(VEVENT 处理之前)单独走一遍,不影响原有日程逻辑。
try {
const todoItems: RemoteItem[] = await DavClient.reportTodos(href, auth);
if (todoItems.length > 0) {
const todoRemote: RemoteEvent[] = [];
for (const ti of todoItems) {
try {
const raw: string = await DavClient.getRawStrict(ti.href, auth);
const parsed: RemoteEvent[] = IcsUtil.parseTodos(raw);
for (const pr of parsed) {
pr.etag = ti.etag;
todoRemote.push(pr);
}
} catch (e) {
LogUtil.write(`待办 GET 失败(跳过) ${ti.href}`);
}
}
if (todoRemote.length > 0) {
await EventDb.applyRemote(context, calKey, href, todoRemote, true);
LogUtil.write(`日历本[${i}]「${calName}」待办同步:${todoRemote.length} 条`);
}
}
} catch (e) {
// 待办同步失败不能影响日程同步(很多服务器对 VTODO 查询返回 403/不支持)
LogUtil.write(`日历本[${i}]「${calName}」待办同步失败(忽略):${(e as BusinessError).message ?? ''}`);
}
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
const byUid: Map<string, LocalEvent[]> = new Map();
@@ -363,8 +409,13 @@ export class SyncEngine {
/**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性)
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
*/
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> {
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
let changed: boolean = false;
let entries: DavColorEntry[] = [];
try {
entries = await DavClient.propfindColors(acc.serverUrl, auth);
@@ -382,9 +433,11 @@ export class SyncEngine {
if (found !== undefined && found.color !== '') {
while (acc.calendarColors.length <= i) {
acc.calendarColors.push('');
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
}
if (acc.calendarColors[i] !== found.color) {
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
changed = true;
}
acc.calendarColors[i] = found.color;
} else if (found === undefined) {
@@ -396,17 +449,23 @@ export class SyncEngine {
acc.calendarWritable.push('1');
}
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
let writable: string;
if (found !== undefined && found.privilegeKnown) {
// 服务器声明了权限:直接采用
acc.calendarWritable[i] = found.writable ? '1' : '0';
writable = found.writable ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
} else {
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
const w: boolean = await DavClient.probeWritable(target, auth);
acc.calendarWritable[i] = w ? '1' : '0';
writable = w ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
}
if (acc.calendarWritable[i] !== writable) {
changed = true;
}
acc.calendarWritable[i] = writable;
}
return changed;
}
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
@@ -795,10 +795,13 @@ export class SystemCalendarMirror {
/**
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
*
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。
* 三种情况(0.1.1 起,配合"卸载残留"问题):
* ① **还有账号** → 只清"accId 不在当前账号集合里"的 —— 原逻辑,精准不误伤。
* ② **账号库为空、且确认是真空**(`isEmptyVaultConfirmed()`)→ **全部清掉**。
* 这是卸载重装 / 用户重置账号库后的场景:旧镜像账户的 accId 不可能再匹配上,
* 留着只会变成系统日历里永远删不掉的幽灵本,还会继续提醒。
* ③ **账号库为空、但拿不准**(读取异常 / 密文解不开)→ **仍然跳过**。
* `loadAll()` 会把异常吞掉返回 `[]`,若不区分就会把用户真实在用的镜像全删了。
*
* @returns 删除的账户数
*/
@@ -812,8 +815,14 @@ export class SystemCalendarMirror {
} catch (err) {
return 0; // 读失败一律不动
}
let wipeAll: boolean = false;
if (accounts.length === 0) {
return 0; // 安全阀:见注释
// 空账号库:只有"确认空白"才敢全清,否则保守跳过(见上方 ③)
wipeAll = await AccountStore.isEmptyVaultConfirmed(context);
if (!wipeAll) {
return 0;
}
LogUtil.write('兜底清理孤儿镜像:账号库确认为空 → 清理全部镜像账户(卸载/重置后残留)');
}
const validAccIds: Set<string> = new Set<string>();
for (const a of accounts) {
@@ -838,11 +847,13 @@ export class SystemCalendarMirror {
} else {
sep = dash >= 0 ? dash : under;
}
if (sep <= 0) {
continue; // 解析不出 accId → 不动(宁可留,不误删)
// 非全清模式:必须能解析出 accId 才能比对,解析不出就跳过(宁可留,不误删)
// 全清模式:前缀已命中就是本应用建的,脏账户(解析不出 accId)也要删干净
if (!wipeAll && sep <= 0) {
continue;
}
const accId: string = bucket.substring(0, sep);
if (validAccIds.has(accId)) {
const accId: string = sep > 0 ? bucket.substring(0, sep) : bucket;
if (!wipeAll && validAccIds.has(accId)) {
continue;
}
try {
@@ -1146,7 +1157,9 @@ export class SystemCalendarMirror {
try {
await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) {
// 设色失败不影响镜像本身
// 设色失败不影响镜像本身,但要留痕(否则"颜色不变"无从排查)
const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败 ${accountName}: ${e.message}`);
}
return cal;
}
@@ -1159,7 +1172,8 @@ export class SystemCalendarMirror {
try {
await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) {
// 同上
const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败(新建) ${accountName}: ${e.message}`);
}
return cal;
}
+330
View File
@@ -0,0 +1,330 @@
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount } from './AccountStore';
import { AccountStore } from './AccountStore';
import { LocalEvent } from './EventDb';
import { EventDb } from './EventDb';
import { DavClient, DavHttpError } from './DavClient';
import { IcsUtil } from './IcsUtil';
import { LogUtil } from './LogUtil';
/**
* 待办(VTODO)操作。
*
* 现状:待办**只读展示**已实现(拉取/解析/入库/列表),本类补上唯一的写操作
* ——「勾选完成 / 取消完成」。
*
* ⚠️ 为什么不做"从本地字段重建 VTODO 再 PUT":
* VTODO 上有 SUMMARY / DESCRIPTION / DUE / PRIORITY / CATEGORIES / X-* 等字段,
* 本地库只存了其中几个。重建后 PUT 等于把没存的字段**全部抹掉**。
* 所以走"GET 原文 → 就地改 STATUS/COMPLETED/PERCENT-COMPLETE → PUT"。
*
* ⚠️ 为什么不能同步到手机系统待办:
* 系统未开放待办 API(@ohos.calendarManager 只有日程/VEVENT,无 Todo 对象),
* 第三方应用无法读写系统日历的「待办」页签。详见工单 #5。
*/
export class TodoService {
/**
* 设置待办完成状态并回写服务器。
*
* @param localId 本地 events 表行 id(DisplayEvent.id)
* @param done true=标记完成;false=取消完成
* @returns 成功返回 true;失败返回 false(**本地状态不变**,避免界面与服务器不一致)
*/
/**
* 更新待办内容(标题 / 备注 / 截止时间)。
*
* 同样走 GET 原文 → 改字段 → PUT:
* VTODO 上有本地库没保存的字段(PRIORITY / CATEGORIES / X-* 等),
* 重建整份会把这些抹掉。
*
* 只改 SUMMARY / DESCRIPTION / DUE 三项,**不动完成状态**
* (用户改的是内容,不该顺带把状态重置了)。
*
* @param dueMs 0 = 清除截止时间(删除 DUE 行)
*/
static async updateTodo(context: common.Context, localId: number, title: string,
desc: string, dueMs: number): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo') {
LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`);
return false;
}
if (row.href === '' || row.remotePath === '') {
LogUtil.write(`待办更新失败:缺少服务器路径 id=${localId}`);
return false;
}
const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href);
if (accResolved === null) {
LogUtil.write(`待办更新失败:找不到所属账号`);
return false;
}
const idx: number = accResolved.calendarHrefs.indexOf(row.href);
if (idx >= 0 && accResolved.calendarWritable.length > idx
&& accResolved.calendarWritable[idx] === '0') {
LogUtil.write(`待办更新失败:该日历本只读 id=${localId}`);
return false;
}
const auth: string = DavClient.authHeader(accResolved.username, accResolved.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
: `${row.href}/${row.remotePath}`;
try {
const original: string = await DavClient.getRawStrict(url, auth);
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`);
return false;
}
const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs);
const newEtag: string = await DavClient.putEvent(url, auth, updated);
await EventDb.updateTodoFields(context, localId, title, desc,
dueMs > 0 ? dueMs : 0, newEtag);
LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`);
return true;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`待办更新失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
} else {
LogUtil.write(`待办更新失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
}
return false;
}
}
/** 删除待办(DELETE 服务器资源 + 本地软删) */
static async deleteTodo(context: common.Context, localId: number): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo') {
LogUtil.write(`待办删除失败:行不存在或不是待办 id=${localId}`);
return false;
}
if (row.href === '' || row.remotePath === '') {
LogUtil.write(`待办删除失败:缺少服务器路径 id=${localId}`);
return false;
}
const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href);
if (accResolved === null) {
LogUtil.write(`待办删除失败:找不到所属账号`);
return false;
}
const idx: number = accResolved.calendarHrefs.indexOf(row.href);
if (idx >= 0 && accResolved.calendarWritable.length > idx
&& accResolved.calendarWritable[idx] === '0') {
LogUtil.write(`待办删除失败:该日历本只读 id=${localId}`);
return false;
}
const auth: string = DavClient.authHeader(accResolved.username, accResolved.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
: `${row.href}/${row.remotePath}`;
try {
await DavClient.deleteRemote(url, auth);
await EventDb.purge(context, localId);
LogUtil.write(`待办删除成功 id=${localId}(标题已脱敏)`);
return true;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`待办删除失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
} else {
LogUtil.write(`待办删除失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
}
return false;
}
}
/** 按 href 找所属账号(updateTodo/deleteTodo 共用) */
private static async findAccount(context: common.Context, href: string): Promise<DavAccount | null> {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
for (const a of accounts) {
if (a.calendarHrefs.includes(href)) {
return a;
}
}
return null;
}
/**
* 设置或取消待办的循环规则。
*
* @param rrule 规则原文(如 `FREQ=WEEKLY`);空串 = 取消循环
* @param dtstartMs 重复基准;<=0 时用当前 DUE 兜底
* @returns 成功 true
*
* ⚠️ 写回时**同时**给 DTSTART 和 DUE:
* RFC 5545 里 VTODO 的 DTSTART 是 OPTIONAL,但没有它时
* 不同客户端对 RRULE 基准的理解不一致(有的按 DUE 算),
* 显式给 DTSTART 能消除歧义。
*/
static async setRecurrence(context: common.Context, localId: number,
rrule: string, dtstartMs: number): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo') {
LogUtil.write(`待办循环设置失败:行不存在或不是待办 id=${localId}`);
return false;
}
if (row.href === '' || row.remotePath === '') {
LogUtil.write(`待办循环设置失败:缺少服务器路径 id=${localId}`);
return false;
}
const acc: DavAccount | null = await TodoService.findAccount(context, row.href);
if (acc === null) {
LogUtil.write(`待办循环设置失败:找不到所属账号`);
return false;
}
const idx: number = acc.calendarHrefs.indexOf(row.href);
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
LogUtil.write(`待办循环设置失败:该日历本只读 id=${localId}`);
return false;
}
const auth: string = DavClient.authHeader(acc.username, acc.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
: `${row.href}/${row.remotePath}`;
const dueMs: number = row.startTime > 0 ? row.startTime : 0;
const base: number = dtstartMs > 0 ? dtstartMs : (dueMs > 0 ? dueMs : Date.now());
try {
const original: string = await DavClient.getRawStrict(url, auth);
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
LogUtil.write(`待办循环设置失败:服务器内容不是 VTODO id=${localId}`);
return false;
}
const updated: string = IcsUtil.rewriteTodoRecurrence(
original, row.uid, rrule, base, dueMs);
const newEtag: string = await DavClient.putEvent(url, auth, updated);
await EventDb.updateTodoRecurrence(context, localId, rrule.trim(), base, newEtag);
LogUtil.write(`待办循环设置成功 id=${localId} rule=${rrule === '' ? '(取消)' : rrule}`);
return true;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`待办循环设置失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
} else {
LogUtil.write(`待办循环设置失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
}
return false;
}
}
/**
* 循环任务"完成一轮":把 DUE 推进到下一个周期,并保持未完成。
*
* 与 setCompleted 的区别:
* · 非循环 → 走 setCompleted(置 COMPLETED,永久结束)
* · 循环 → 走这里(DUE += 一个周期,STATUS 保持 NEEDS-ACTION)
*
* @returns 推进后的新 DUE(毫秒);失败返回 0
*/
static async completeRecurringRound(context: common.Context, localId: number): Promise<number> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null || row.kind !== 'todo' || row.rrule.trim() === '') {
return 0;
}
if (row.href === '' || row.remotePath === '') {
return 0;
}
const acc: DavAccount | null = await TodoService.findAccount(context, row.href);
if (acc === null) {
return 0;
}
const idx: number = acc.calendarHrefs.indexOf(row.href);
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
return 0;
}
// 从 FREQ 取周期,推下一个基准
const freq: string = IcsUtil.freqOfRrule(row.rrule);
// 基准优先用 DTSTART(真正的重复基准),没有则用当前 DUE
const base: number = row.dtstart > 0 ? row.dtstart : row.startTime;
const nextBase: number = IcsUtil.incForFreq(freq, base);
// 保留"截止 = 基准 + N"的偏移(原任务若是 09:00 截止,下一次还是 09:00)
const offset: number = row.startTime > 0 && row.dtstart > 0 ? row.startTime - row.dtstart : 0;
const nextDue: number = nextBase + offset;
const auth: string = DavClient.authHeader(acc.username, acc.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath
: `${row.href}/${row.remotePath}`;
try {
const original: string = await DavClient.getRawStrict(url, auth);
// 先把状态复位成未完成,再把基准与截止推到下一轮
let updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, false);
updated = IcsUtil.rewriteTodoRecurrence(updated, row.uid, row.rrule, nextBase, nextDue);
const newEtag: string = await DavClient.putEvent(url, auth, updated);
await EventDb.updateTodoAfterRound(context, localId, nextBase, nextDue, newEtag);
LogUtil.write(`循环待办推进到下一轮 id=${localId}(标题已脱敏)`);
return nextDue > 0 ? nextDue : nextBase;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`循环待办推进失败 HTTP ${(err as DavHttpError).status} id=${localId}`);
} else {
LogUtil.write(`循环待办推进失败 id=${localId} ${(err as BusinessError).message ?? ''}`);
}
return 0;
}
}
static async setCompleted(context: common.Context, localId: number, done: boolean): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null) {
LogUtil.write(`待办完成失败:本地行不存在 id=${localId}`);
return false;
}
if (row.kind !== 'todo') {
LogUtil.write(`待办完成失败:该行不是待办 id=${localId} kind=${row.kind}`);
return false;
}
if (row.href === '' || row.remotePath === '') {
LogUtil.write(`待办完成失败:缺少服务器路径(不可写)id=${localId}`);
return false;
}
if (row.completed === done) {
return true; // 状态没变化,直接算成功(幂等)
}
// 1) 找到该 href 所属账号,构造鉴权
const accounts: DavAccount[] = await AccountStore.loadAll(context);
let acc: DavAccount | null = null;
for (const a of accounts) {
if (a.calendarHrefs.includes(row.href)) {
acc = a;
break;
}
}
if (acc === null) {
LogUtil.write(`待办完成失败:找不到所属账号 href=${row.href}`);
return false;
}
// 只读日历本:推送必然 403,提前拦下给出明确结果
const idx: number = acc.calendarHrefs.indexOf(row.href);
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
LogUtil.write(`待办完成失败:该日历本只读 id=${localId}`);
return false;
}
const auth: string = DavClient.authHeader(acc.username, acc.password);
const url: string = row.href.endsWith('/') ? row.href + row.remotePath : `${row.href}/${row.remotePath}`;
try {
// 2) GET 原文(保住本地没存的字段)
const original: string = await DavClient.getRawStrict(url, auth);
if (!original.toUpperCase().includes('BEGIN:VTODO')) {
LogUtil.write(`待办完成失败:服务器返回的内容不是 VTODO id=${localId}`);
return false;
}
// 3) 只改状态相关字段
const updated: string = IcsUtil.rewriteTodoCompletion(original, row.uid, done);
// 4) PUT 回去,拿新 etag
const newEtag: string = await DavClient.putEvent(url, auth, updated);
// 5) 同步本地(成功后才改,保证两边一致)
await EventDb.setTodoCompleted(context, localId, done, newEtag);
LogUtil.write(`待办${done ? '完成' : '取消完成'}成功 id=${localId}(标题已脱敏)`);
return true;
} catch (err) {
if (err instanceof DavHttpError) {
const he = err as DavHttpError;
LogUtil.write(`待办写回失败 HTTP ${he.status}:id=${localId}(本地状态未改)`);
} else {
const e = err as BusinessError;
LogUtil.write(`待办写回失败:id=${localId} ${e.message ?? ''}(本地状态未改)`);
}
return false;
}
}
}
+32 -2
View File
@@ -24,14 +24,27 @@ export class EditCalendarItem {
href: string;
name: string;
color: string; // 服务器定义的颜色(calendar-color),可能为空
/** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */
comps: string;
selected: boolean;
constructor(href: string, name: string, color: string) {
constructor(href: string, name: string, color: string, comps: string = 'unknown') {
this.href = href;
this.name = name;
this.color = color;
this.comps = comps;
this.selected = false;
}
/** 是否待办本(纯待办或混合) */
isTodoBook(): boolean {
return this.comps === 'todo' || this.comps === 'both';
}
/** 是否日历本(纯日历、混合、或服务器未声明) */
isEventBook(): boolean {
return this.comps === 'event' || this.comps === 'both' || this.comps === 'unknown';
}
}
/**
@@ -179,7 +192,7 @@ struct EditAccountPage {
return;
}
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
new EditCalendarItem(e.href, e.displayName, e.color));
new EditCalendarItem(e.href, e.displayName, e.color, e.comps));
if (list.length === 0) {
LogUtil.write('编辑账号:未发现任何日历本');
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
@@ -246,6 +259,7 @@ struct EditAccountPage {
target.calendarHrefs = newHrefs;
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
target.calendarComps = selectedItems.map((c: EditCalendarItem): string => c.comps);
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
@@ -530,6 +544,22 @@ struct EditCalendarRow {
build() {
Row({ space: 12 }) {
// ⭐ SVG_ICON 类型图标:放在颜色圆点**前面**,
// 让用户先看清"这本是日历还是待办",再看它是什么颜色。
//
// 用 SVG 而不是字符:字符图标(▦ / ✓)依赖系统字体字形,
// 笔画粗细与视觉重量不统一,跨设备还可能缺字形变成方框。
// 这两个 SVG 共用同一套笔触(stroke 2、round 端点、24×24),
// 渲染成 16vp 时重量一致。
//
// 混合本(both):显示待办图标(它确实能存待办),
// 但用户看名字/颜色仍能分辨,不必塞两个图标挤在一行。
Image(this.item.comps === 'todo' || this.item.comps === 'both'
? $r('app.media.ic_todo_book')
: $r('app.media.ic_event_book'))
.width(16)
.height(16)
.objectFit(ImageFit.Contain)
if (this.item.color !== '') {
Circle()
.width(12)
+463 -18
View File
@@ -1,6 +1,6 @@
// entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
import { curves, router } from '@kit.ArkUI';
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { notificationManager } from '@kit.NotificationKit';
@@ -13,6 +13,7 @@ import { ReminderService } from '../common/ReminderService';
import { LogUtil } from '../common/LogUtil';
import { IcsUtil } from '../common/IcsUtil';
import { RruleUtil } from '../common/RruleUtil';
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
class SeriesBounds {
@@ -34,6 +35,34 @@ class BookChoice {
struct EventEditPage {
@State title: string = '';
@State location: string = '';
/**
* ⭐ 地点补全(鸿蒙 Map Kit / Site Kit):
* placeItems —— 候选数据(**不参与页面布局**,由 bindPopup 渲染成浮层)
* placeLoading —— 是否正在请求,浮层内显示"搜索中"
* placeOpen —— 浮层是否弹出(绑在输入框上,向上弹)
* placeHint —— 无结果/地图能力不可用时的提示(不阻塞手输地点)
*
* ⚠️ 为什么必须是浮层而不是内联列表:内联列表改变表单高度 ⇒ 输入框跟着移动 ⇒
* 每次"搜索中/出结果/结果变少"都要滚动纠正 ⇒ 用户看到输入框反复上下跳。
*/
@State placeItems: PlaceItem[] = [];
@State placeLoading: boolean = false;
@State placeOpen: boolean = false;
@State placeHint: string = '';
/** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */
private placePicked: boolean = false;
/**
* 最后一次**程序写入**输入框的文本(选中候选后回写的地址)。
* TextInput 的 text 被程序改写同样会触发 onChange;用它精确识别"这次 onChange
* 不是用户打字造成的",从而不重复发起搜索。
*/
private lastAppliedText: string = '';
/** 地点输入框当前是否聚焦(仅用于日志/状态,不再驱动滚动) */
private placeFocused: boolean = false;
/** 输入防抖定时器 */
private placeTimer: number = -1;
/** 表单滚动控制器(键盘避让由系统 RESIZE 模式负责,这里不再主动滚) */
private formScroller: Scroller = new Scroller();
@State description: string = '';
@State allDay: boolean = false;
@State startMs: number = 0;
@@ -41,7 +70,20 @@ struct EventEditPage {
@State books: BookChoice[] = [];
@State chosenKey: string = '';
@State isSaving: boolean = false;
/**
* 表单状态行文字。
* ⚠️ 它渲染在表单**最底部**("删除日程"按钮上方),用户在顶部点"保存"时根本看不见。
* 所以它只能作为"事后可复查的记录",**不能**作为唯一的错误反馈手段 ——
* 真正的提醒必须走 showFormError():Toast(必可见)+ 字段标红 + 滚到出错字段。
*/
@State statusMsg: string = '';
/**
* ⭐ 哪个字段校验没过('' = 没有)。
* 用途:把对应输入框描红并显示一条紧贴字段的红字说明,
* 让用户一眼看到"哪儿错了",而不是在一屏之外找一行小字。
* 取值:'title' | 'time' | ''
*/
@State errorField: string = '';
@State isExisting: boolean = false;
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
/**
@@ -70,6 +112,8 @@ struct EventEditPage {
@State sheetMode: string = 'picker';
@State pickerIsEnd: boolean = false;
@State pickDate: Date = new Date();
/** 新建"非今天"日程时的默认开始小时(24 小时制) */
private static readonly DEFAULT_START_HOUR: number = 9;
@State pickHour: number = 9;
@State pickMin: number = 0;
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
@@ -87,9 +131,36 @@ struct EventEditPage {
private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> {
// 键盘避让不阻塞页面加载:拿窗口是异步的,失败也只是少一层滚动校正
this.setupKeyboardAvoid();
return this.initPage();
}
/** 页面销毁:清掉地点补全的防抖定时器 */
aboutToDisappear(): void {
this.cancelSuggestTimer();
}
/**
* ⭐ 键盘避让:改成 RESIZE 模式。
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕。
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
* 聚焦的输入框由系统自动滚入可见区,**无需再手动 scrollBy**。
*
* ⚠️ 曾经写过"手动量矩形 + scrollBy 对齐"的方案,是错的方向:
* ① getRectangleById 返回 px、scrollBy 收 vp,单位混用会把输入框推飞;
* ② 更根本的是——只要候选参与布局,位置就会反复变化,
* 靠"事后纠正"必然表现为抖动。所以候选改成了浮层(见 placePopup)。
*/
private setupKeyboardAvoid(): void {
try {
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
}
}
private async initPage(): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
@@ -202,18 +273,214 @@ struct EventEditPage {
return;
}
}
// 新建:日期取所选日(未选则今天),时间取当前时刻;结束 = 开始 + 1 小时
// 新建:日期取所选日(未选则今天)。默认开始时刻分两种情况:
// ① 今天 → 取当前时刻("现在就记一笔"最自然;而且今天的 09:00 往往已经过了)
// ② 非今天 → 固定 09:00("现在几点"对别的日子毫无参考价值,09:00 才是通用默认)
// 结束时间统一 = 开始 + 1 小时。
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
new Date(base).getDate()).getTime();
const pickedDay = new Date(base);
const dayStart = new Date(pickedDay.getFullYear(), pickedDay.getMonth(),
pickedDay.getDate()).getTime();
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
if (dayStart === todayStart) {
// ① 今天:当前时刻
this.startMs = dayStart + now.getHours() * 3600000 + now.getMinutes() * 60000;
} else {
// ② 非今天:09:00
this.startMs = dayStart + EventEditPage.DEFAULT_START_HOUR * 3600000;
}
this.endMs = this.startMs + 3600000;
if (choices.length > 0) {
this.chosenKey = choices[0].calKey;
}
}
// ==================== 地点补全(鸿蒙 Map Kit / Site Kit) ====================
/**
* 输入防抖:停止输入 350ms 后才真正发请求。
* 好处:① 不把服务端配额/网络浪费在"每敲一个字"上;② 避免结果闪烁。
* ⚠️ 这里只用「关键词补全」这一种能力(不需要定位权限),
* 所以**不需要**申请 LOCATION 权限,符合"权限只在用户主动点该功能时申请"的约束。
*/
private scheduleSuggest(v: string): void {
this.cancelSuggestTimer();
const q: string = v.trim();
if (q.length < 1) {
this.closePlacePopup();
return;
}
this.placeTimer = setTimeout(() => {
this.placeTimer = -1;
this.runSuggest(q, false);
}, 350);
}
private cancelSuggestTimer(): void {
if (this.placeTimer !== -1) {
clearTimeout(this.placeTimer);
this.placeTimer = -1;
}
}
private async runSuggest(q: string, forceOpen: boolean): Promise<void> {
if (this.placePicked && !forceOpen) {
return;
}
if (PlaceSuggest.isUnavailable()) {
// 地图服务没开通:给一次明确提示,然后永久静默(不阻塞手输)
this.placeHint = '地图服务不可用,可直接手动输入地点';
this.placeItems = [];
this.placeOpen = true;
return;
}
// ⭐ 浮层一直保持展开(从"搜索中"过渡到结果),中间不收起、不重弹 ——
// 收起再弹会让浮层闪一下,看起来像"消失了又出现"。
this.placeLoading = true;
this.placeHint = '';
this.placeOpen = true;
try {
const c = this.getUIContext().getHostContext();
const ctx: common.Context | undefined = c;
// 先补全;补全空则内部会自动退到关键字搜索
let items: PlaceItem[] = await PlaceSuggest.query(q, ctx);
if (items.length === 0 && !PlaceSuggest.isUnavailable()) {
items = await PlaceSuggest.search(q, ctx);
}
// ⭐ 丢弃过期结果:用户又改了输入框,就别再覆盖成旧候选
if (q !== this.location.trim()) {
return;
}
this.placeItems = items.slice(0, 6);
if (this.placeItems.length === 0) {
this.placeHint = PlaceSuggest.isUnavailable()
? '地图服务不可用,可直接手动输入地点'
: '没有找到匹配地点,可直接手动输入';
// 无结果也留在浮层里给提示;用户再输入会被下一次请求覆盖
this.placeOpen = true;
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`地点补全异常: ${e.message}`);
this.placeItems = [];
this.placeHint = '地点搜索失败,可直接手动输入';
this.placeOpen = true;
} finally {
this.placeLoading = false;
}
}
/**
* ⭐ 候选浮层(bindPopup)的内容。
*
* 为什么是浮层而不是内联列表:
* 内联列表会改变表单高度 ⇒ 输入框位置随之变化 ⇒ 每次"搜索中/出结果/结果变少"
* 都要靠滚动去纠正 ⇒ 用户看到的就是**输入框上下跳好几次**。
* 浮层不参与布局,出现与消失**完全不移动输入框**,这是唯一能根治该问题的结构。
*
* 为什么向上弹(Placement.Top):
* 输入框在表单下部、键盘又在下方,向下弹必然被键盘盖住;
* 向上弹则整块候选都落在输入框与标题之间的空白区。
*/
@Builder
placePopup() {
Column({ space: 0 }) {
if (this.placeLoading) {
Row({ space: 8 }) {
LoadingProgress().width(16).height(16).color($r('app.color.text_hint'))
Text('搜索中…')
.fontSize(13)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
} else if (this.placeItems.length > 0) {
// 限高 + 内部滚动:候选最多 6 条,一次全铺开会顶到屏幕顶部
List() {
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
ListItem() {
Column({ space: 2 }) {
Text(it.title)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
if (it.subtitle !== '') {
Text(it.subtitle)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
}
.onClick(() => {
this.applyPlace(it);
})
}, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`)
}
.width('100%')
.constraintSize({ maxHeight: 216 })
.scrollBar(BarState.Auto)
.divider({ strokeWidth: 0.5, color: $r('app.color.shadow_color') })
} else {
Text(this.placeHint !== '' ? this.placeHint : '没有找到匹配地点,可直接手动输入')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
}
.width('100%')
.padding({ top: 4, bottom: 4 })
}
/** 选中一条候选:写进地点输入框并收起列表 */
private applyPlace(it: PlaceItem): void {
// ⭐ 顺序很关键:先收起浮层,再写文本。
// TextInput 的 text 被程序改写会触发 onChange,若此时 placePicked 已被
// onChange 重置为 false,就会**重新发起一次搜索** → 用户看到
// "候选框消失了一下又冒出来一次"。用 lastAppliedText 精确识别这类回写。
this.cancelSuggestTimer();
this.closePlacePopup();
this.placePicked = true;
this.lastAppliedText = it.locationText;
this.location = it.locationText;
}
/** 收起候选浮层(统一入口,保证 placePicked 之外的状态都被清干净) */
private closePlacePopup(): void {
this.placeOpen = false;
this.placeLoading = false;
this.placeItems = [];
this.placeHint = '';
}
/** 打开系统地图选点页(可搜索、可周边、可拖图钉) */
private async pickOnMap(): Promise<void> {
const c = this.getUIContext().getHostContext();
if (c === undefined) {
return;
}
const it: PlaceItem | null = await PlaceSuggest.pickOnMap(c as common.UIAbilityContext);
if (it === null) {
// 用户取消,或地图服务不可用
if (PlaceSuggest.isUnavailable()) {
this.placeHint = '地图服务不可用,可直接手动输入地点';
}
return;
}
this.applyPlace(it);
}
private chosenBook(): BookChoice | null {
const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
if (found !== undefined) {
@@ -432,20 +699,74 @@ struct EventEditPage {
this.endMs = dayStart + 86399999;
} else {
const s = new Date(this.startMs);
this.startMs = s.getTime() + 9 * 3600000;
this.startMs = new Date(s.getFullYear(), s.getMonth(), s.getDate()).getTime()
+ EventEditPage.DEFAULT_START_HOUR * 3600000;
this.endMs = this.startMs + 3600000;
}
}
/**
* ⭐ 统一的表单错误提示(**唯一的错误出口**)。
*
* 为什么需要专门做这件事:
* 校验提示原本只写进 `statusMsg`,而它渲染在表单最底部("删除日程"按钮上方)。
* 用户在顶部点"保存"时,那行红字远在屏幕之外 —— 看起来就是"点了没反应"。
*
* 三件事一起做,保证用户一定看得见、也知道该改哪儿:
* ① **Toast**:系统级浮在屏幕中上部,不受滚动位置影响,必定可见;
* ② **字段标红**:给对应输入框加红框 + 紧贴字段的红字说明;
* ③ **滚回出错字段并聚焦**:把视野直接带到问题所在(标题 → 打光标;时间 → 滚到时间卡片)。
*
* @param msg 给用户看的话术(要具体,说清"该做什么",不要只说"错误")
* @param field 'title' | 'time' | ''('' 表示没有具体字段,只弹 Toast)
*/
private showFormError(msg: string, field: string): void {
this.statusMsg = msg;
this.errorField = field;
// ① Toast:不走布局,无论滚到哪儿都能看到
try {
this.getUIContext().getPromptAction().showToast({
message: msg,
duration: 2500,
bottom: '70%' // 抬到屏幕偏上方,避免被键盘/底部按钮遮挡
});
} catch (err) {
// Toast 失败也不影响其它两种提示
}
// ③ 滚回出错位置:延时一帧,等状态刷新(标红后高度可能变化)后再滚
setTimeout(() => {
try {
if (field === 'title') {
this.formScroller.scrollEdge(Edge.Top);
try {
this.getUIContext().getFocusController().requestFocus('titleInput');
} catch (e2) {
// 聚焦失败(例如输入法正在切换)不阻塞流程
}
} else if (field === 'time') {
// 时间卡片在表单上部,滚到顶基本就能看到;再留一点余量
this.formScroller.scrollEdge(Edge.Top);
} else {
this.formScroller.scrollEdge(Edge.Top);
}
} catch (err2) {
const e = err2 as BusinessError;
LogUtil.write(`错误提示定位失败: ${e.message}`);
}
}, 60);
}
private validate(): boolean {
if (this.title.trim() === '') {
this.statusMsg = '请输入日程标题';
this.showFormError('请先填写日程标题', 'title');
return false;
}
if (this.endMs < this.startMs) {
this.statusMsg = '结束时间不能早于开始时间';
this.showFormError('结束时间不能早于开始时间', 'time');
return false;
}
// 通过校验:清掉上一轮的错误标记
this.errorField = '';
return true;
}
@@ -711,9 +1032,10 @@ struct EventEditPage {
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
// 这也是"必填项缺失",同样要弹 Toast + 滚到对应字段,不能只写底部状态行
this.showFormError(this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
: '请选择要同步到的 CalDAV 日历本', 'book');
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
return;
}
@@ -726,19 +1048,20 @@ struct EventEditPage {
return;
}
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误用 showFormError 强提示)。
if (!this.validate()) {
return;
}
const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
this.showFormError(this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
: '请选择要同步到的 CalDAV 日历本', 'book');
return;
}
this.isSaving = true;
this.statusMsg = '';
this.errorField = '';
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
@@ -798,6 +1121,16 @@ struct EventEditPage {
const ex = err as BusinessError;
LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`);
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
// ⭐ 这里紧接着会 router.back(),底部状态行根本来不及被看到 → 必须用 Toast
try {
this.getUIContext().getPromptAction().showToast({
message: '已保存到本地,同步到服务器失败,稍后会自动重试',
duration: 3000,
bottom: '70%'
});
} catch (e2) {
// Toast 失败不影响返回
}
// 数据仍在本地且带 dirty 标记,不会丢
router.back();
}
@@ -968,10 +1301,12 @@ struct EventEditPage {
.width('100%')
.padding({ left: 16, right: 16 })
Scroll() {
Scroll(this.formScroller) {
Column({ space: 14 }) {
// 标题
TextInput({ text: this.title, placeholder: '标题' })
// 标题(必填)
// ⭐ 校验失败时:输入框描红 + 紧贴下方一条红字说明。
// 只在 errorField === 'title' 时出现,用户开始输入即自动清除。
TextInput({ text: this.title, placeholder: '标题(必填)' })
.height(46)
.fontSize(16)
.backgroundColor($r('app.color.card_bg'))
@@ -981,9 +1316,28 @@ struct EventEditPage {
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
// 标红:用边框而不是背景,深色/浅色主题下都醒目且不难看
.border({
width: this.errorField === 'title' ? 1.5 : 0,
color: $r('app.color.error')
})
.onChange((v: string) => {
this.title = v;
// 用户开始填写就把红框撤掉,避免"改了还一直红着"
if (v.trim() !== '' && this.errorField === 'title') {
this.errorField = '';
this.statusMsg = '';
}
})
.id('titleInput')
if (this.errorField === 'title') {
Text('请填写标题后再保存')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%')
.padding({ left: 4 })
}
// 全天
Row() {
@@ -1012,6 +1366,19 @@ struct EventEditPage {
.padding(6)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 时间顺序错误时整卡描红,用户一眼能看到问题在这个卡片里
.border({
width: this.errorField === 'time' ? 1.5 : 0,
color: $r('app.color.error')
})
if (this.errorField === 'time') {
Text('结束时间不能早于开始时间')
.fontSize(12)
.fontColor($r('app.color.error'))
.width('100%')
.padding({ left: 4 })
}
// 重复
Column({ space: 8 }) {
@@ -1173,6 +1540,11 @@ struct EventEditPage {
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
.onClick(() => {
this.chosenKey = b.calKey;
// 选了日历本就把红框撤掉
if (this.errorField === 'book') {
this.errorField = '';
this.statusMsg = '';
}
})
}, (b: BookChoice) => b.calKey)
}
@@ -1183,11 +1555,20 @@ struct EventEditPage {
.width('100%')
.padding(14)
.borderRadius(12)
// 日历本未选且用户点了保存 → 描红,提示"必填"
.border({
width: this.errorField === 'book' ? 1.5 : 0,
color: $r('app.color.error')
})
.backgroundColor($r('app.color.card_bg'))
// 地点
TextInput({ text: this.location, placeholder: '地点(可选)' })
// 地点(输入几个字 → 系统地图服务补全候选)
// ⭐ 候选列表**不在布局流里**:它通过 bindPopup 挂在输入框上、向**上方**弹出。
// 这样候选的出现/消失完全不改变输入框位置 —— 从根上消除"输入框上下跳"。
Row({ space: 8 }) {
TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
.height(44)
.layoutWeight(1)
.fontSize(14)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
@@ -1195,8 +1576,69 @@ struct EventEditPage {
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => {
// ⭐ 程序改写 text(选中候选后回写地址)也会触发 onChange。
// 精确比对最后一次程序写入值:命中就不再发起搜索,
// 否则会"选完 → 候选框消失 → 又冒出来一次"。
if (v === this.lastAppliedText) {
this.lastAppliedText = '';
this.location = v;
return;
}
this.lastAppliedText = '';
this.location = v;
// 用户重新输入时解除"已选中"状态,恢复自动补全
this.placePicked = false;
this.scheduleSuggest(v);
})
.onSubmit(() => {
// 回车:立刻搜一次(不等防抖)
this.cancelSuggestTimer();
this.runSuggest(this.location, true);
})
.onFocus(() => {
// 键盘避让交给系统(RESIZE 已保证光标可见);
// 候选是浮层,不需要再滚动页面去给它腾位置
this.placeFocused = true;
})
.onBlur(() => {
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选浮层
this.placeFocused = false;
this.closePlacePopup();
})
.bindPopup($$this.placeOpen, {
builder: (): void => {
this.placePopup()
},
placement: Placement.Top,
popupColor: $r('app.color.card_bg'),
enableArrow: false,
showInSubWindow: false,
focusable: false,
autoCancel: true,
radius: 12,
onStateChange: (e: PopupStateChangeParam) => {
// 用户点浮层外部/floating 自动关闭时,同步状态,避免下次不弹
if (!e.isVisible && this.placeOpen) {
this.placeOpen = false;
}
}
})
.id('placeAnchor')
// 在地图上选点(系统地图界面,可选可搜可周边)
Text('地图')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.padding({ left: 12, right: 12 })
.height(44)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.onClick(() => {
this.pickOnMap();
})
}
.width('100%')
.id('placeRow')
// 描述
TextArea({ text: this.description, placeholder: '备注(可选)' })
@@ -1211,7 +1653,10 @@ struct EventEditPage {
this.description = v;
})
if (this.statusMsg !== '') {
// 状态行:只用于"保存失败/账号未配置"这类**系统级**信息,且必须有 Toast 同时提示。
// ⚠️ 校验类错误不走这里 —— 它们已经在对应字段旁就地显示,
// 再在这里重复一遍只会让用户以为有两处不同的问题。
if (this.statusMsg !== '' && this.errorField === '') {
Text(this.statusMsg)
.fontSize(13)
.fontColor($r('app.color.error'))
File diff suppressed because it is too large Load Diff
+257
View File
@@ -49,6 +49,7 @@ struct SettingsPage {
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
@State mirrorRunning: boolean = false; // 正在执行镜像
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
@State wipeAllRunning: boolean = false; // 正在清除全部镜像(卸载前清干净)
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
@@ -293,6 +294,51 @@ struct SettingsPage {
return this.allBooks.length - this.mirrorCandidates.length;
}
/** 已勾选的可镜像本数量(mirrorKeys 里可能残留已不在候选里的 bookId,按候选口径统计) */
private mirrorSelectedCount(): number {
return this.mirrorCandidates.filter((b: BackupTarget): boolean =>
this.mirrorKeys.indexOf(b.bookId) >= 0).length;
}
/**
* 「全选 / 全不选」要镜像的日历本。
* ⭐ 批量落盘只写一次 preferences、缓存只失效一次(与逐个 toggle 的关键区别);
* 全不选时逐本删除对应系统日历账户(与取消单个勾选同一语义)。
* 半选状态下点按 = 全选。
*/
private async toggleMirrorAll(): Promise<void> {
if (this.context === undefined || this.mirrorCandidates.length === 0) {
return;
}
const selectAll: boolean = this.mirrorSelectedCount() < this.mirrorCandidates.length;
if (selectAll) {
const ids: string[] = this.mirrorCandidates.map((b: BackupTarget): string => b.bookId);
this.mirrorKeys = ids;
await AppSettings.setMirrorBookIds(this.context, ids);
this.getUIContext().getPromptAction().showToast({
message: `已全选 ${ids.length} 个日历本,点「立即执行镜像」写入系统日历`
});
} else {
// 逐本删除系统日历账户;逐个独立 try,失败不影响其余
for (const k of this.mirrorKeys) {
try {
await SystemCalendarMirror.removeById(this.context, k);
} catch (err) {
const e = err as BusinessError;
console.error(`全不选:删除镜像账户失败 bookId=${k}: ${e.message}`);
}
}
this.mirrorKeys = [];
await AppSettings.setMirrorBookIds(this.context, []);
this.getUIContext().getPromptAction().showToast({
message: '已取消全部勾选,并清除对应的系统日历账户'
});
}
// ⭐ 勾选本集合变化 → 提醒侧"已镜像本"缓存失效并重排提醒
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
}
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
private isBookReadonly(b: BackupTarget): boolean {
return !b.serverWritable || this.manualKeys.includes(b.calKey);
@@ -636,6 +682,81 @@ struct SettingsPage {
}
}
/**
* ⭐「彻底清除全部镜像数据」(卸载前清干净用)。
*
* 为什么需要它:鸿蒙**没有**「应用即将被卸载」的回调,系统日历属于**沙箱外**数据,
* 卸载时不会自动清理。用户卸载后,系统日历里会永久留着一堆
* 「同步日历·xxx」账户(还会继续提醒),只有重装并重新同步才能被当孤儿清掉。
* ⇒ 提供显式入口,让用户在卸载前主动清干净。
*
* ⚠️ 清除范围(务必与文案一致):
* - 只删**系统日历里本应用建立的镜像账户及其日程**
* - **不动** CalDAV 账号配置(账号密码仍保留,重装后可直接用)
* - **不动** 服务器上的任何日程(同步过去的内容一条都不会少)
*/
private confirmWipeAllMirrors(): void {
if (this.context === undefined || this.wipeAllRunning) {
return;
}
this.getUIContext().showAlertDialog({
title: '彻底清除系统日历镜像数据',
message: '将删除本应用写入系统日历的全部镜像日历本及其日程。\n\n'
+ '只影响系统日历:你的 CalDAV 账号与服务器上的日程完全不受影响。\n'
+ '清除后系统日历里不再显示这些日程,也不再提醒;镜像开关会一并关闭。\n\n'
+ '准备卸载本应用时,请先点这里清干净(卸载时系统不会自动清除)。',
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {
}
},
secondaryButton: {
value: '确认清除',
action: (): void => {
this.doWipeAllMirrors();
}
}
});
}
private async doWipeAllMirrors(): Promise<void> {
if (this.context === undefined || this.wipeAllRunning) {
return;
}
this.wipeAllRunning = true;
try {
const n: number = await SystemCalendarMirror.removeAll(this.context);
// ⭐ 必须同时把"镜像开关"关掉并落库:否则下一轮同步会立刻按勾选重新建回来,
// 用户会以为"清了等于没清"。
this.mirrorEnabled = false;
await AppSettings.setMirrorEnabled(this.context, false);
// 镜像本没了 → 提醒要交回 App 负责,否则这些本会既没系统提醒、也没 App 提醒(漏提醒)
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
// ⭐ 清空结果必须说清楚:用户点这个按钮就是为了"确认卸载后不留东西",
// 所以"0 个"也要区分是权限没给(没清成)还是本来就干净(放心)
const granted: boolean = await SystemCalendarMirror.hasPermission();
let msg: string = '';
if (n > 0) {
msg = `已清除 ${n} 个镜像日历本,系统日历中不再有本应用的日程;镜像开关已关闭`;
} else if (!granted) {
msg = '未授予"写入系统日历"权限,无法清除。请先在设置中允许本应用读写日历后重试';
} else {
msg = '系统日历中已没有本应用的镜像数据,无需清除';
}
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 5000 });
} catch (err) {
const e = err as BusinessError;
this.getUIContext().getPromptAction().showToast({
message: `清除失败:${e.message ?? '请确认已授予"写入系统日历"权限'}`
});
} finally {
this.wipeAllRunning = false;
}
}
private async saveInterval(minutes: number): Promise<void> {
if (this.context === undefined) {
return;
@@ -978,6 +1099,35 @@ struct SettingsPage {
.fontColor($r('app.color.text_hint'))
.width('100%')
}
// ⭐ 全选 / 全不选:半选状态下点按 = 全选
Row({ space: 6 }) {
Text(`全选(已选 ${this.mirrorSelectedCount()}/${this.mirrorCandidates.length})`)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
Toggle({
type: ToggleType.Checkbox,
isOn: this.mirrorCandidates.length > 0
&& this.mirrorSelectedCount() === this.mirrorCandidates.length
})
.selectedColor($r('app.color.brand'))
.onChange((on: boolean): void => {
// 半选时勾选框显示为关,onChange(on=true) 走全选;全选时取消走全不选
if (on === (this.mirrorSelectedCount() === this.mirrorCandidates.length)) {
return; // 状态未变化(个别系统会重复回调),忽略
}
this.toggleMirrorAll();
})
}
.width('100%')
.padding({ top: 2, bottom: 2 })
.onClick(() => {
// 整行可点:直接按"是否已全选"翻转,比依赖 Checkbox 的 onChange 更可靠
this.toggleMirrorAll();
})
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
Row({ space: 6 }) {
@@ -1089,6 +1239,7 @@ struct SettingsPage {
this.runMirrorNow();
})
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
@@ -1132,6 +1283,82 @@ struct SettingsPage {
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// ⭐ WIPE_CARD_MOVED:数据清除独立成卡(不依赖镜像开关是否开启)
// 鸿蒙没有"应用即将被卸载"的回调,而系统日历属于沙箱外的系统数据,
// 卸载时不会自动清理 ⇒ 必须提供显式入口,让用户卸载前主动清干净。
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('清除系统日历数据')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('卸载本应用前请先清除,否则系统日历里会一直留着这些日程(还会提醒)')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Button(this.wipeAllRunning ? '正在清除…' : '彻底清除系统日历镜像数据')
.fontSize(14)
.fontColor($r('app.color.error'))
.backgroundColor(Color.Transparent)
.border({ width: 1, color: $r('app.color.error') })
.width('100%')
.enabled(!this.wipeAllRunning)
.onClick(() => {
this.confirmWipeAllMirrors();
})
Text('只删系统日历里的镜像本;CalDAV 账号与服务器上的日程一条都不会少。清除后镜像开关会自动关闭。')
.fontSize(11)
.fontColor($r('app.color.text_hint'))
.width('100%')
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.backgroundColor(Color.Transparent)
.width('100%')
.enabled(!this.purgeRunning)
.onClick(() => {
this.runPurgeOrphans();
})
if (this.accountDump !== '') {
Column({ space: 4 }) {
Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):')
.fontSize(11)
.fontColor($r('app.color.text_secondary'))
.width('100%')
Scroll() {
Text(this.accountDump)
.fontSize(10)
.lineHeight(14)
.fontColor($r('app.color.text_primary'))
.copyOption(CopyOptions.LocalDevice)
.width('100%')
}
.height(150)
.width('100%')
.backgroundColor($r('app.color.page_bg'))
.borderRadius(8)
.padding(6)
}
.width('100%')
.padding({ top: 4 })
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 默认视图:打开 App 后默认展示的视图(月/周/列表)
Column({ space: 8 }) {
Row({ space: 10 }) {
@@ -1470,6 +1697,36 @@ struct SettingsPage {
this.openDoc(AppSettings.HELP_TITLE, AppSettings.HELP_URL);
})
// FEEDBACK_CARD 问题反馈(FeedLite 平台,?sw=synccalendar 指定本项目站点)
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('问题反馈')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('遇到问题或有建议,点这里告诉我们')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('›')
.fontSize(18)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
.onClick(() => {
this.openDoc(AppSettings.FEEDBACK_TITLE, AppSettings.FEEDBACK_URL);
})
// 隐私政策与用户协议(点击弹层;bindSheet 挂在本卡片上,避免与其它弹层冲突)
Column({ space: 8 }) {
Row({ space: 10 }) {
-20
View File
@@ -44,26 +44,6 @@
],
"when": "inuse"
}
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:perm_location",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:perm_location_approx",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
],
"deliveryWithInstall": true,
@@ -19,6 +19,8 @@
{ "name": "grid_line", "value": "#14000000" },
{ "name": "today_bg", "value": "#14007DFF" },
{ "name": "selected_bg", "value": "#007DFF" },
{ "name": "chip_off_bg", "value": "#0D000000" }
{ "name": "chip_off_bg", "value": "#0D000000" },
{ "name": "event_mark", "value": "#D93025" },
{ "name": "soft_red", "value": "#F2938A" }
]
}
@@ -24,14 +24,6 @@
"name": "perm_read_whole_calendar",
"value": "读取所有日历账户的日程,用于在日历中统一展示"
},
{
"name": "perm_location",
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
},
{
"name": "perm_location_approx",
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
},
{
"name": "card_ability_desc",
"value": "同步日历服务卡片"
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<!-- 日历本图标:线性风格,与 todo_book 同一套笔触(stroke 2, round 端点) -->
<rect x="3" y="5" width="18" height="16" rx="3" fill="none" stroke="#666666" stroke-width="2"/>
<path d="M3 10 H21" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
<!-- 两个挂环 -->
<path d="M8 3 V7" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
<path d="M16 3 V7" fill="none" stroke="#666666" stroke-width="2" stroke-linecap="round"/>
<!-- 日期格:用两个小方块暗示"日程格" -->
<rect x="7" y="13" width="3.5" height="3.5" rx="1" fill="#666666"/>
<rect x="13.5" y="13" width="3.5" height="3.5" rx="1" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 792 B

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

After

Width:  |  Height:  |  Size: 779 B

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