v51b DAV 例外:时区换算 + RECURRENCE-ID/EXDATE/多 VEVENT 不再被丢
根因三处(此前一个资源只能有一个 VEVENT、只认前 9 个字段): · Ics::normalizeVevent() 固定 9 字段,没有 recurrence_id/exdate; · dav.php 只取 $list[0](覆盖行全丢); · calendar_events 没有对应列。 修法: · 时区 —— 带 Z / TZID 的值按 zone 换算成本地墙钟,浮动值不动; UNTIL 一起归一(必须与 DTSTART 同值类型/同基准);DTSTAMP/CREATED/ LAST-MODIFIED 仍按 RFC 保留 UTC。 · 例外 —— 新增 calendar_event_overrides 卫星表(uk(event_id,recurrence_id), FK CASCADE)存覆盖行;EXDATE 落既有的 calendar_event_skips(与网页端 「只删这一次」共用);PUT 的例外语义 = 权威替换。 · 未建模属性(X-* 等)落 calendar_events.extra 原样回吐, 表单保存不认识时不动它(防网页编辑抹掉客户端的 X-*)。 · 「RECURRENCE-ID + STATUS:CANCELLED」折算成 EXDATE(RFC 5545 §3.8.4.4 与 EXDATE 等价),并保留"正文里只有覆盖行"的退化路径。 · ETag / ctag 必须含例外,否则只改「某一次」别的设备永远拉不到。 · 网页月历 month() 认覆盖行(按新时间画、原时间不留影、不被 EXDATE 那天画)。 Schema 33 → 34(VERSION 人读 + fingerprint() 才是真正判据)。 验证:Ics 离线单测 30/30;端到端 DAV 例外探针 56/56;v45 既有回归 24/24。 反证:退回旧行为 → 6 条断言变红(网页上"已取消"的那次仍被画出来)。
This commit is contained in:
+70
-2
@@ -146,6 +146,26 @@ final class Schema
|
||||
* 「哪些行被删了」(没有 deleted_at 就只能硬删除 ⇒ 客户端永远看不到墓碑
|
||||
* ⇒ 一直显示幽灵数据)。⛔ 凭据/字典/本地偏好等 8 张表**故意不加** ——
|
||||
* 同步白名单应当显式而简短。
|
||||
* 34 → **CalDAV 的重复日程例外(2026-10-15 用户:「既然发现了就直接全部修了」)**:
|
||||
* ① `calendar_events` 加 `extra` —— **未建模的 VEVENT 属性原样保留**
|
||||
* (`X-*` / `RDATE` / `CATEGORIES`…)。原来 parser 只认固定 9 个属性,
|
||||
* 其余一律丢,客户端写上去的东西**读不回来**(实测 PUT 带 `X-` 属性,
|
||||
* 回来一个都不剩)。
|
||||
* ② 新增 `calendar_event_overrides` —— 重复系列里**被改到别的时间的某一次**。
|
||||
* 动机(实测出来的真 bug):CalDAV 客户端把"仅这一次改到周五"写成
|
||||
* **同一个 .ics 里的第二个 VEVENT**(同 UID + `RECURRENCE-ID`),
|
||||
* 而原来的 `dav.php` PUT 只取 `$list[0]` ⇒ 覆盖行直接进垃圾桶,
|
||||
* 于是"改某一次"永远同步不过去,下轮还会把被排除那次又拉回来(幽灵日程)。
|
||||
* ⚠️ 为什么**不**把覆盖行塞进 `calendar_events`(加一个 `recurrence_id` 列):
|
||||
* 那一列会被所有既有查询看到 —— `exportRows()` 的
|
||||
* `SELECT * FROM calendar_events WHERE calendar_id = ?`、日历视图的展开、
|
||||
* 提醒扫描…**每一处都得多判一次"这行是不是主事件"**,漏一处就是
|
||||
* "同一次日程出现两条"。而"被改掉的某一次"本来就不是"日程本体",
|
||||
* 与 `calendar_event_skips` 是同一类东西 ⇒ 并列放一张卫星表,
|
||||
* 既有查询**一行都不用改**。
|
||||
* ⚠️ 与 `calendar_event_skips`(= `EXDATE`,只取消)的分工:
|
||||
* 被**取消**的那次 → skips;被**挪到别的时间**的那次 → overrides
|
||||
* (后者自带新时间,语义上是"替换那一次",RFC 5545 §3.8.4.4)。
|
||||
*/
|
||||
/**
|
||||
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
||||
@@ -153,7 +173,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '33';
|
||||
public const VERSION = '34';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -1742,6 +1762,54 @@ CREATE TABLE IF NOT EXISTS `calendar_event_skips` (
|
||||
COMMENT='重复日程里被单独取消/挪走的某一次(RFC 5545 的 EXDATE)'
|
||||
SQL);
|
||||
|
||||
// ---- CalDAV 的重复日程例外(版本 34,2026-10-15)----
|
||||
/* ① 「被改到别的时间的某一次」= 覆盖行(RFC 5545 §3.8.4.4 / RFC 4791 §4.1)。
|
||||
CalDAV 客户端表达"把第 3 次改到周五"用的是**同一个 .ics 里的第二个 VEVENT**
|
||||
(同 UID + `RECURRENCE-ID: <原发生时刻>` + 自己的 `DTSTART` = 新时间)。
|
||||
原来 dav.php 的 PUT 只取 `$list[0]` ⇒ 覆盖行被丢掉 ⇒ 手机上改的那一次
|
||||
**永远同步不过去**,而且下一轮拉取还会把被排除的那次又画回来。
|
||||
|
||||
⚠️ 为什么不加在 `calendar_events` 里(加 `recurrence_id` 列):
|
||||
那样所有既有查询都会看到这些行,**每一处都得补一句"排除覆盖行"**
|
||||
(`exportRows` 的 `SELECT *`、日历视图展开、提醒扫描、API…),
|
||||
漏一处就是"同一次日程显示两条"。放卫星表则既有查询**一行都不用改** ——
|
||||
与 `calendar_event_skips` 同一个道理。
|
||||
⚠️ `UNIQUE KEY (event_id, recurrence_id)` 是幂等的基石:客户端每轮都把这
|
||||
整个系列推一遍,没有它就每同步一次多一条覆盖行。
|
||||
⚠️ 与 skips 的分工:**取消**这一次 → skips;**挪走**这一次 → 本表。
|
||||
两者都可能同时存在(一个系列里既有取消的、又有挪走的)。 */
|
||||
$db->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `calendar_event_overrides` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程的主事件(calendar_events.id)',
|
||||
`recurrence_id` VARCHAR(64) NOT NULL COMMENT 'RFC 5545 RECURRENCE-ID:被替换的那次**原发生时刻**(本地浮动 ICS 形态,如 20261021T090000 / 20261021)',
|
||||
`title` VARCHAR(200) NOT NULL,
|
||||
`location` VARCHAR(255) DEFAULT NULL,
|
||||
`starts_at` DATETIME NOT NULL COMMENT '改到的新开始时刻',
|
||||
`ends_at` DATETIME DEFAULT NULL,
|
||||
`all_day` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`note` TEXT DEFAULT NULL,
|
||||
`status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed',
|
||||
`sequence` INT NOT NULL DEFAULT 0,
|
||||
`extra` TEXT DEFAULT NULL COMMENT '该 VEVENT 里未被建模的属性原文(X-* 等),往返带过',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ceo` (`event_id`, `recurrence_id`),
|
||||
CONSTRAINT `fk_ceo_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='重复日程里被改到别的时间的某一次(RFC 5545 的 RECURRENCE-ID 覆盖行)'
|
||||
SQL);
|
||||
|
||||
/* ② `calendar_events.extra`:未被建模的 VEVENT 属性原样保留。
|
||||
parser 原来只认固定 9 个属性,其余一律丢 —— 实测 PUT 一份带 `X-` 自定义
|
||||
属性的 ICS,读回来一个都不剩。虽然我们不需要理解它们,但**丢掉就是丢数据**:
|
||||
`RDATE`(额外发生日)丢 = 系列少几次;别人的 `X-APPLE-STRUCTURED-LOCATION`
|
||||
丢 = 位置没了;第三方客户端发现"我写的没了"还可能改判为冲突。
|
||||
⇒ 原样存、原样吐回,成本只有一列。 */
|
||||
self::addColumn($db, 'calendar_events', 'extra',
|
||||
"ADD COLUMN `extra` TEXT DEFAULT NULL COMMENT '未被建模的 VEVENT 属性原文(X-* 等,按行存),CalDAV 导出时原样带回' AFTER `sequence`");
|
||||
|
||||
// ---- 旅程的活动「是否公开」(版本 13,用户 2026-10-03 第 4 条)----
|
||||
// 用户原话:「旅程的活动中,只显示 is_public=1 的活动」。
|
||||
// ⚠️ 放在 **trip_details**(1:1 的卫星表)而不是 events:
|
||||
@@ -2138,7 +2206,7 @@ SQL);
|
||||
'person_tags', 'note_shares', 'notifications',
|
||||
// ---- ensure() 版本迁移段里建的 ----
|
||||
'trip_node_revisions', 'task_boards', 'task_categories',
|
||||
'calendar_event_skips', 'anniversaries', 'anniversary_people',
|
||||
'calendar_event_skips', 'calendar_event_overrides', 'anniversaries', 'anniversary_people',
|
||||
'ledgers', 'ledger_entries', 'ledger_shares',
|
||||
'ledger_entry_snapshots', 'person_jobs', 'person_notes',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user