Commit Graph
100 Commits
Author SHA1 Message Date
laoyang 413900bbce feat(待办): 提醒 / 左滑删除 / 优先级标签地点 / 编辑热区 / 卡片刷新 (#38 #39 #40 #41 #42)
一次性完成待办模块的 5 项缺口。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 编译
`BUILD SUCCESSFUL`

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

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

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

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

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

两个关键点:

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

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

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

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

## 编译
`BUILD SUCCESSFUL`

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

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

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

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

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

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

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

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

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

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

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

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

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

## 编译
`BUILD SUCCESSFUL`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 编译
`BUILD SUCCESSFUL`

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

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

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

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

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

## 真机验证(5NC0225B27004718)

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

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

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

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

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

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

## 改动

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

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

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

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

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

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

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

## 真机验证(5NC0225B27004718)

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

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

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

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

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

## 修正的过时内容

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

## 新增

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 陷阱资源

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

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

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

## 验证结果

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 验证状态

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 根因(两层)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验证:
- 编译通过(BUILD SUCCESSFUL);包内版本 800/0.0.8。
- 离线用例覆盖:三种格式生成(确认半角点、无空格)、64 字符截断、
  身份判定五种情形(含"别的 App 同名账户不被删""accId 失效的孤儿仍被清"
  "读不到账号时一律不删""格式 3 兜底失效")—— **全部通过**。
- 手机未连接 → 未装机(待用户插线后安装测试)。
2026-10-14 10:09:48 +08:00
laoyang ed26da8dc4 0.0.7:CalDAV 地址自动发现 + 人话错误提示 + 只读公开日历
背景:用户反馈(Nextcloud/内网)——DAVx5 用 CalDAV 地址能完整读取,换本应用
读不出日历;用"分享链接"绑定时提示服务器返回 405。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

二、转场动效(意见 2:使用系统转场,淡入淡出,曲线优先弹簧曲线)
- 全部 7 个 @Entry 页面新增 pageTransition():只做 opacity 淡入淡出,
  不调用 .slide() / .translate(),即不做左右平移 / 上下位移 / 单帧直接切换。
- 转场曲线使用 curves.springMotion() 弹簧曲线。
2026-09-28 14:20:14 +08:00
laoyang 76d82886e4 修复了进入日历没有日程卡片的bug。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-16 12:34:39 +08:00
laoyang cc7523a8ec 修复了从卡片进入日历无法定位当前日期的bug。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-16 11:38:01 +08:00
laoyang 314ed4b75c 删除了设置页面中的功能引导选项。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-16 10:29:46 +08:00
laoyang 2173b06b43 新增了列表显示模式,用户可以在设置中进行选择。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-16 10:07:57 +08:00
laoyang 5ab9648b5a 修复了新增日历本颜色不对的问题。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-15 23:03:58 +08:00
laoyang c53456a2e1 修复了添加账号时全选状态错误问题。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-15 16:28:02 +08:00
laoyang 7216024204 更改设置中软件特性和用户帮助为链接形式。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-15 16:09:34 +08:00
laoyang 7cf9203ec3 修改了用户协议等打开方式。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-15 15:07:42 +08:00
laoyang 297f76f00d 增加了几个截图,用来展示效果。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-15 12:52:22 +08:00
laoyang 5eaeeb0f4c 增强了安全配置。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-15 12:51:58 +08:00
laoyang 088f7b3773 增加了用户隐私协议等,增加了引导卡片。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-15 09:06:15 +08:00
laoyang a7503a7a1f 更改了readme
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-14 22:02:03 +08:00
laoyang 64a58170ac 更改了几处交互逻辑。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-14 21:53:15 +08:00
laoyang c04a9a28cf 更改了日程的样式显示,现在按照时间轴显示。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-14 21:28:51 +08:00
laoyang df3928c099 更改了2x4卡片的样式。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-14 11:09:15 +08:00
laoyang fa152415cd 为日程列表增加了一条当前时间的横线,用来表示当前进度。
Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-14 10:18:31 +08:00
laoyang 3c9b4dcfdd 1.修改了2x2的卡片样式。
2.为卡片增加了添加按钮,可以通过卡片直接添加日程。

Signed-off-by: Yang Yongquan <i@yangyq.net>
2026-09-14 09:07:47 +08:00
laoyang d1252ed2b7 1.修改了设置页面的展示顺序。
2.在设置页面新增了软件特性和使用帮助。
3.设置页面中,静音和只读合并。
2026-09-14 08:24:36 +08:00
laoyang 5b6be08d9a 完成了用户账户加密,更改了只读日程的显示样式;日程中的地址,可以通过高德地图导航了。 2026-09-14 07:55:52 +08:00
laoyang 6899a5074b 修改了通知相关的功能,能够正常通知了。 2026-09-13 23:35:52 +08:00
laoyang e62416e5ce 为了持续同步,申请了常驻通知栏权限,在设置中开启后,可以常驻通知栏,进行后台同步。如果不需要,可以在设置中关闭。 2026-09-13 22:02:08 +08:00
laoyang d9c212785d 新增了提醒静音功能,可以在设置中关闭某个日历本的提醒。 2026-09-13 21:56:51 +08:00
laoyang 0fef40c7f3 修复了部分日程没有提醒时间的问题。 2026-09-13 21:50:28 +08:00
laoyang 04fdf3d386 1.增加了重力感应,也就是横屏响应式布局。
2.修改了沉浸式布局。
3.修改了添加日程和编辑日程页面,增加了重复、提醒等功能。
4.修改了权限问题,如果日历本是只读,则有删除线做标识。同时,对于只读的日程,点击后将不再进入编辑页面,而是展示详情。
5.系统日历的处理。给用户两个选择,第一个选择就是只显示系统日历。第二种选择,用户可以选择一个caldav账户中的某一个日历本,把系统日历中的日程,包括日历日程和应用创建的日程,都读取出来,然后加入到这个日历本下,最后同步到caldav的服务器上,这样的好处是,手机丢失了,或者换了手机品牌型号,手机上的日程仍然在自己的caldav服务器上有一个备份。当然,系统日历中的caldav日历,就不会再读取了。
2026-09-13 20:25:18 +08:00
laoyang 61bd6fc75f 修复了重复日历第一次不显示的问题。 2026-09-13 17:48:39 +08:00
laoyang e0c6aa46d0 删除了日视图,感觉跟列表视图差不多,有点鸡肋。周视图改为和月视图一样,左右滑动进行切换。 2026-09-13 16:44:33 +08:00
laoyang e27c8c36d7 添加了后台同步功能,应用置于后台,仍然可以进行同步。 2026-09-13 16:33:53 +08:00
laoyang 0a204b6754 修复了卡片显示bug。如果有跨多天的日程,卡片会从多天日程的开始那一天开始显示,已经修复了。 2026-09-13 16:18:56 +08:00
laoyang 7091cc9535 增加了readme文档。 2026-09-13 16:12:19 +08:00
laoyang 2af19637b6 在日历首页上,将按钮进行合并收拢,放在一个设置按钮下。 2026-09-13 16:08:03 +08:00
laoyang c1543a0e33 add LICENSE.
Signed-off-by: 老杨 <i@yangyq.net>
2026-09-13 08:02:50 +00:00