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:
+57
-2
@@ -272,10 +272,39 @@ final class CalDav
|
||||
*/
|
||||
public static function resourceOf(array $r, string $calName): array
|
||||
{
|
||||
$ics = Ics::export([$r], $calName);
|
||||
$ics = Ics::export([self::withExceptions($r)], $calName);
|
||||
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 给一条日程行补上它的「重复系列例外」——
|
||||
* `_skip_dates`(= `EXDATE`,哪几次被取消)与 `_overrides`(= `RECURRENCE-ID` 覆盖行)。
|
||||
*
|
||||
* 🔴 v52b(2026-10-15):这两样必须进正文,也必须进 ETag。
|
||||
* 只进正文不进 ETag 的话:客户端推完覆盖行 → 服务器存下了 →
|
||||
* 但 ETag 没变 ⇒ 客户端拿 If-Match 再推一次时**判不出"已经是最新的"**,
|
||||
* 严重时会被判成"服务器没接受"而反复重推(甚至回滚自己的改动)。
|
||||
* 所以 ETag 由**最终正文**算(本函数产出的东西直接进 Ics::export)。
|
||||
*
|
||||
* ⚠️ `isset` 判据是**故意**的:`exportRows()` 出来的行已经带好了这两样
|
||||
* (批量查过,见 Calendar::exportRows),这里就别再逐条查 ——
|
||||
* 整本 REPORT 时那会变成 N+1。
|
||||
*/
|
||||
public static function withExceptions(array $r): array
|
||||
{
|
||||
$eid = (int)($r['id'] ?? 0);
|
||||
if ($eid <= 0) {
|
||||
return $r;
|
||||
}
|
||||
if (!isset($r['_skip_dates'])) {
|
||||
$r['_skip_dates'] = Calendar::skipsOf($eid);
|
||||
}
|
||||
if (!isset($r['_overrides'])) {
|
||||
$r['_overrides'] = Calendar::overridesOf($eid);
|
||||
}
|
||||
return $r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历本的 ctag 种子 —— 客户端拿 ctag 判「这本整体变没变」。
|
||||
*
|
||||
@@ -295,7 +324,33 @@ final class CalDav
|
||||
);
|
||||
$st->execute([(int)$cal['id']]);
|
||||
$r = $st->fetch() ?: [];
|
||||
return (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
|
||||
/* 🔴 v52b:**例外也要算进 ctag** ——
|
||||
客户端拿 ctag 判"这本整体变没变",没变就跳过整轮拉取。
|
||||
"只改其中一次"(取消某次 / 把某次挪到别的时间)**不动 calendar_events 那一行**
|
||||
(`updateEvent` 都没被调),所以只按上面那个种子算的话 ctag 恒定不变 ⇒
|
||||
客户端以为没变、永远拉不到这次改动(表现:"我在手机上改的,另一台设备上看不见")。 */
|
||||
$seed = (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
|
||||
|
||||
$st2 = Database::get()->prepare(
|
||||
'SELECT COUNT(*) AS n, COALESCE(SUM(CAST(o.sequence AS SIGNED)), 0) AS sq,
|
||||
COALESCE(MAX(o.updated_at), \'\') AS mx
|
||||
FROM calendar_event_overrides o
|
||||
JOIN calendar_events e ON e.id = o.event_id
|
||||
WHERE e.calendar_id = ?'
|
||||
);
|
||||
$st2->execute([(int)$cal['id']]);
|
||||
$o = $st2->fetch() ?: [];
|
||||
$seed .= '|' . (string)($o['n'] ?? 0) . '|' . (string)($o['mx'] ?? '') . '|' . (string)($o['sq'] ?? 0);
|
||||
|
||||
$st3 = Database::get()->prepare(
|
||||
'SELECT COUNT(*) AS n, COALESCE(MAX(s.created_at), \'\') AS mx
|
||||
FROM calendar_event_skips s
|
||||
JOIN calendar_events e ON e.id = s.event_id
|
||||
WHERE e.calendar_id = ?'
|
||||
);
|
||||
$st3->execute([(int)$cal['id']]);
|
||||
$s = $st3->fetch() ?: [];
|
||||
return $seed . '|' . (string)($s['n'] ?? 0) . '|' . (string)($s['mx'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+299
-28
@@ -415,11 +415,9 @@ final class Calendar
|
||||
*/
|
||||
public static function updateEvent(int $id, array $d): void
|
||||
{
|
||||
Database::get()->prepare(
|
||||
'UPDATE calendar_events SET title=?, location=?, starts_at=?, ends_at=?, all_day=?,
|
||||
reminder_minutes=?, status=?, transp=?, rrule=?, note=?, sequence = sequence + 1
|
||||
WHERE id=?'
|
||||
)->execute([
|
||||
$sets = 'title=?, location=?, starts_at=?, ends_at=?, all_day=?,
|
||||
reminder_minutes=?, status=?, transp=?, rrule=?, note=?';
|
||||
$args = [
|
||||
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
|
||||
self::textOrNull((string)($d['location'] ?? ''), 255),
|
||||
$d['starts_at'],
|
||||
@@ -430,8 +428,19 @@ final class Calendar
|
||||
!empty($d['transp']) ? 1 : 0,
|
||||
self::textOrNull((string)($d['rrule'] ?? ''), 255),
|
||||
self::textOrNull($d['note'] ?? ''),
|
||||
$id,
|
||||
]);
|
||||
];
|
||||
/* ⚠️ `extra`(未建模的 VEVENT 属性)**只在调用方显式给了才写**。
|
||||
网页的编辑表单不认识这个字段 —— 无条件写 NULL 的话,用户在网页上
|
||||
改一次标题,CalDAV 客户端写进来的 `X-*` 就被悄悄抹掉了。
|
||||
「没说」=「别动」,这条对往返保真很关键(与 reminder_minutes 不同:
|
||||
那一个表单一定会给,所以照旧无条件写)。 */
|
||||
if (array_key_exists('extra', $d)) {
|
||||
$sets .= ', extra=?';
|
||||
$args[] = self::textOrNull((string)$d['extra']);
|
||||
}
|
||||
$sets .= ', sequence = sequence + 1';
|
||||
$args[] = $id;
|
||||
Database::get()->prepare("UPDATE calendar_events SET {$sets} WHERE id=?")->execute($args);
|
||||
}
|
||||
|
||||
public static function deleteEvent(int $id): void
|
||||
@@ -504,6 +513,156 @@ final class Calendar
|
||||
return array_map(static fn($r) => (string)$r['skip_date'], $st->fetchAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这条日程的「例外日」**整组替换**成给定的几天(CalDAV PUT 的权威语义)。
|
||||
*
|
||||
* ⚠️ 为什么是"整组替换"而不是"逐个增删":CalDAV 的 PUT 语义是
|
||||
* **客户端推上来的整份资源就是这条日程的全部真相**(RFC 4791 §5.3.2)。
|
||||
* 只增不减的话,用户在客户端把某次"恢复"回来,服务器上那条排除记录
|
||||
* 永远删不掉 —— 表现为"取消了又改回来,可它还是不出现"。
|
||||
* ⚠️ 传空数组 = 清空(把之前排除的全恢复)。
|
||||
*/
|
||||
public static function setSkips(int $eventId, array $datesYmd): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$clean = [];
|
||||
foreach ($datesYmd as $d) {
|
||||
$d = substr(trim((string)$d), 0, 10);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
|
||||
$clean[$d] = true;
|
||||
}
|
||||
}
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->prepare('DELETE FROM calendar_event_skips WHERE event_id = ?')->execute([$eventId]);
|
||||
if ($clean) {
|
||||
$ins = $db->prepare(
|
||||
'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?)'
|
||||
);
|
||||
foreach (array_keys($clean) as $d) {
|
||||
$ins->execute([$eventId, $d]);
|
||||
}
|
||||
}
|
||||
$db->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `RECURRENCE-ID`(ICS 形态:`20261021T090000` / `20261021`)→ `Y-m-d`。
|
||||
*
|
||||
* 用来回答"这个覆盖行替换的是哪一天的发生",也是网页日历把覆盖行
|
||||
* 挪到新日期去画时的对齐依据。解析不出来就返回空串(调用方跳过)。
|
||||
*
|
||||
* ⚠️ 传进来的值必须已经是**本地浮动**形态(`Ics::parse()` 出来的
|
||||
* `recurrence_id` 就是这样),否则带 `Z`/`TZID` 的值会被截到**前一天**。
|
||||
* `public` 是因为 `dav.php` 也要用它把"取消某一次"折算成 EXDATE。
|
||||
*/
|
||||
public static function ridDay(string $rid): string
|
||||
{
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', trim($rid), $m)) {
|
||||
return $m[1] . '-' . $m[2] . '-' . $m[3];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条日程的**覆盖行**(重复系列里被改到别的时间的某几次)。
|
||||
*
|
||||
* @return array<int,array> 按 recurrence_id 升序
|
||||
*/
|
||||
public static function overridesOf(int $eventId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT * FROM calendar_event_overrides WHERE event_id = ? ORDER BY recurrence_id'
|
||||
);
|
||||
$st->execute([$eventId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批日程的覆盖行(`[event_id => [行…]]`)—— 导出/资源列表用。
|
||||
*
|
||||
* ⚠️ 单独提供一个批量入口是为了**避免 N+1**:一本日历几百条日程时,
|
||||
* 逐条查一次覆盖行会让 REPORT 从"一次查询"变成"几百次"。
|
||||
*
|
||||
* @param array<int,int|string> $eventIds
|
||||
*/
|
||||
public static function overridesFor(array $eventIds): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($eventIds as $i) {
|
||||
$i = (int)$i;
|
||||
if ($i > 0) {
|
||||
$ids[$i] = true;
|
||||
}
|
||||
}
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT * FROM calendar_event_overrides WHERE event_id IN ($in) ORDER BY event_id, recurrence_id"
|
||||
);
|
||||
$st->execute(array_keys($ids));
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
$out[(int)$r['event_id']][] = $r;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条日程的覆盖行**整组替换**(CalDAV PUT 的权威语义)。
|
||||
*
|
||||
* @param array<int,array> $rows 每行至少要有 recurrence_id / starts_at;
|
||||
* title 为空时回落到"(无标题日程)"
|
||||
*/
|
||||
public static function replaceOverrides(int $eventId, array $rows): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->prepare('DELETE FROM calendar_event_overrides WHERE event_id = ?')->execute([$eventId]);
|
||||
if ($rows) {
|
||||
$ins = $db->prepare(
|
||||
'INSERT INTO calendar_event_overrides
|
||||
(event_id, recurrence_id, title, location, starts_at, ends_at, all_day,
|
||||
note, status, sequence, extra)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)'
|
||||
);
|
||||
foreach ($rows as $r) {
|
||||
$rid = trim((string)($r['recurrence_id'] ?? ''));
|
||||
// 没有 RECURRENCE-ID 的行不是覆盖行(那应该是主事件)——跳过,
|
||||
// 免得把一条普通日程塞成"替换第 0 次"这种说不通的东西。
|
||||
if ($rid === '' || empty($r['starts_at'])) {
|
||||
continue;
|
||||
}
|
||||
$title = trim((string)($r['title'] ?? ''));
|
||||
$ins->execute([
|
||||
$eventId,
|
||||
mb_substr($rid, 0, 64, 'UTF-8'),
|
||||
mb_substr($title !== '' ? $title : '(无标题日程)', 0, 200, 'UTF-8'),
|
||||
self::textOrNull((string)($r['location'] ?? ''), 255),
|
||||
(string)$r['starts_at'],
|
||||
($r['ends_at'] ?? null) ?: null,
|
||||
!empty($r['all_day']) ? 1 : 0,
|
||||
self::textOrNull((string)($r['note'] ?? '')),
|
||||
self::statusOf($r['status'] ?? null),
|
||||
(int)($r['sequence'] ?? 0),
|
||||
self::textOrNull((string)($r['extra'] ?? '')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
$db->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。
|
||||
*
|
||||
@@ -567,7 +726,7 @@ final class Calendar
|
||||
$from = '1970-01-01';
|
||||
$to = '2100-01-01';
|
||||
$out = [];
|
||||
$seen = [];
|
||||
$gens = []; // key => 同一个节点铺出来的各天(v52:要合成一条跨天行,见下)
|
||||
$want = (int)$cal['id'];
|
||||
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
|
||||
// 🔴 v31 修的真 bug:`month()` 里**任务**与**纪念日**那两段是
|
||||
@@ -580,18 +739,63 @@ final class Calendar
|
||||
if ((int)($e['cal'] ?? 0) !== $want) {
|
||||
continue;
|
||||
}
|
||||
$key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date'];
|
||||
if (isset($seen[$key])) {
|
||||
continue; // 跨天日程会逐天出一条,导出时一条就够
|
||||
/* ⚠️ 分组键**不能含日期**:跨天条目会被 `month()` 逐天铺出来,
|
||||
含日期就各成一组(每天一条 VEVENT、UID 还相同)——实测那个 bug 的
|
||||
前半段就是这样:跨天住宿出 3 条,被 resources() 按 UID 去重后
|
||||
只留**第一天**那条,而第一天那条没有结束时刻 ⇒ 又变成 DTEND=DTSTART。
|
||||
所以键必须是"这一个节点"的标识:kind + ref_id。 */
|
||||
$key = $e['kind'] . '-' . (int)$e['ref_id'];
|
||||
/* 🔴🔴 v52(用户 2026-10-15 第 1 条):「旅程里面的行程,通过 CalDAV 同步了之后,
|
||||
时间是错的……它只有开始时间,**结束时间和开始时间是一样的**。」
|
||||
|
||||
真根因:这里对**派生条目**(观影 / 旅程)一律写死 `'ends_at' => null`,
|
||||
而 `Ics::vevent()` 遇到空的结束时刻会退化成「`DTEND` = `DTSTART`」
|
||||
(那行注释写的是"没有结束时刻就按开始时刻算,给一个等于开始的 DTEND 最稳")
|
||||
⇒ 同步到手机 / 雷鸟上,每条行程的结束时间都等于开始时间。
|
||||
|
||||
⚠️ 修法要点:`month()` 把**跨天条目逐天**铺出来(一天一条),
|
||||
每条各自带/不带 `end_time`(结束时刻**只在最后一天**给,
|
||||
与自定义日程同一口径)。所以**不能**在这里"第一天直接用 end_time"——
|
||||
那会把结束时刻贴到开始那天上。
|
||||
⇒ 改为:**同一个节点(同 key)先收集它铺出来的所有天**,
|
||||
再用"第一天的时间 + 最后一天的时间"拼成**一条**跨天行。
|
||||
这样才得到真正的 `DTSTART → DTEND` 跨度。
|
||||
⚠️ `uid` 仍是 `kind-ref_id@familybook`(不含日期)—— CalDAV 资源按 UID 去重,
|
||||
UID 带日期会让一个节点变成 N 个资源(参见 CalDav::resources 的说明)。 */
|
||||
if (!isset($gens[$key])) {
|
||||
$gens[$key] = [];
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$day = (string)$e['date'];
|
||||
$gens[$key][] = $e;
|
||||
}
|
||||
foreach ($gens as $key => $occ) {
|
||||
// 按日期排序,取首末两天
|
||||
usort($occ, static function (array $a, array $b): int {
|
||||
return strcmp((string)$a['date'], (string)$b['date']);
|
||||
});
|
||||
$firstDay = (string)$occ[0]['date'];
|
||||
$lastDay = (string)$occ[count($occ) - 1]['date'];
|
||||
$firstT = (string)($occ[0]['time'] ?? ''); // 'HH:MM' 或 ''
|
||||
$allDay = empty($firstT);
|
||||
$starts = $firstDay . ' ' . ($allDay ? '00:00:00' : $firstT . ':00');
|
||||
/* 结束时刻:从**最后一天**那一条里取 `end_time`(`month()` 只在末天给)。
|
||||
⚠️ 末天那条若也没有 end_time(本来就没填结束)⇒ 留 null,
|
||||
退回"DTEND = DTSTART"的旧行为(服务端渲染那边也是"没填结束")。
|
||||
⚠️ 结束若不在开始之后(数据倒挂)⇒ 也留 null,别导出负时长。 */
|
||||
$lastT = (string)($occ[count($occ) - 1]['end_time'] ?? '');
|
||||
$ends = null;
|
||||
if ($lastT !== '') {
|
||||
$ends = $lastDay . ' ' . $lastT . ':00';
|
||||
if ($ends <= $starts) {
|
||||
$ends = null;
|
||||
}
|
||||
}
|
||||
$e = $occ[0];
|
||||
$out[] = [
|
||||
'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook',
|
||||
'title' => (string)$e['title'],
|
||||
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
|
||||
'ends_at' => null,
|
||||
'all_day' => empty($e['time']) ? 1 : 0,
|
||||
'starts_at' => $starts,
|
||||
'ends_at' => $ends,
|
||||
'all_day' => $allDay ? 1 : 0,
|
||||
'location' => null,
|
||||
'note' => null,
|
||||
'status' => 'confirmed',
|
||||
@@ -654,7 +858,33 @@ final class Calendar
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
return $st->fetchAll();
|
||||
/* ---- 把重复系列的「例外」附着到对应主事件上(CalDAV 导出用)----
|
||||
🔴 v52b(2026-10-15):`Ics::export()` 需要这两样才能把正文写对 ——
|
||||
`_skip_dates`(EXDATE:哪几次被取消)与 `_overrides`(RECURRENCE-ID 覆盖行)。
|
||||
两样都是**批量查**的(`overridesFor` / 一次 IN 查询),不能逐条查:
|
||||
一本几百条日程时逐条查会让 REPORT 慢一个数量级。
|
||||
⚠️ 只给自定义本挂:派生日历(农历/节假日/观影/旅程)没有 RRULE,
|
||||
也就没有"某一次"可例外(Ics 那边也会因为没有 RRULE 而跳过 EXDATE)。 */
|
||||
$rows = $st->fetchAll();
|
||||
if ($rows) {
|
||||
$ids = array_map('intval', array_column($rows, 'id'));
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$sk = Database::get()->prepare(
|
||||
"SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in)"
|
||||
);
|
||||
$sk->execute($ids);
|
||||
$skips = [];
|
||||
foreach ($sk->fetchAll() as $r) {
|
||||
$skips[(int)$r['event_id']][] = (string)$r['skip_date'];
|
||||
}
|
||||
$ovr = self::overridesFor($ids);
|
||||
foreach ($rows as $i => $r) {
|
||||
$eid = (int)$r['id'];
|
||||
$rows[$i]['_skip_dates'] = $skips[$eid] ?? [];
|
||||
$rows[$i]['_overrides'] = $ovr[$eid] ?? [];
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -684,8 +914,8 @@ final class Calendar
|
||||
$db->prepare(
|
||||
'INSERT INTO calendar_events
|
||||
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
|
||||
reminder_minutes, status, transp, rrule, note, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||
reminder_minutes, status, transp, rrule, note, created_by, extra)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||
)->execute([
|
||||
$calendarId,
|
||||
$uid,
|
||||
@@ -700,6 +930,7 @@ final class Calendar
|
||||
self::textOrNull((string)($v['rrule'] ?? ''), 255),
|
||||
self::textOrNull((string)($v['note'] ?? '')),
|
||||
$createdBy ?: null,
|
||||
self::textOrNull((string)($v['extra'] ?? '')),
|
||||
]);
|
||||
return 'added';
|
||||
}
|
||||
@@ -921,6 +1152,7 @@ final class Calendar
|
||||
|
||||
// 一次把这批日程的「例外日期」取出来(N+1 会在重复日程多时明显变慢)
|
||||
$skips = [];
|
||||
$ovrs = [];
|
||||
if ($rows) {
|
||||
$eids = array_map('intval', array_column($rows, 'id'));
|
||||
$in2 = implode(',', array_fill(0, count($eids), '?'));
|
||||
@@ -931,6 +1163,20 @@ final class Calendar
|
||||
foreach ($sk->fetchAll() as $r) {
|
||||
$skips[(int)$r['event_id']][(string)$r['skip_date']] = true;
|
||||
}
|
||||
/* 🔴 v52b:「被**改到别的时间**的某一次」(CalDAV 的 RECURRENCE-ID 覆盖行)
|
||||
与 skips 一次批量取出来 —— 网页日历也得认它,否则手机上把周三那次
|
||||
改到周五,网页上周三**照样**画着(还在原时间),等于两边不一致。 */
|
||||
foreach (self::overridesFor($eids) as $eid => $list) {
|
||||
foreach ($list as $o) {
|
||||
/* ⚠️ 键必须是 **`Y-m-d`**(与 `Repeat::occurrences()` 返回的日期
|
||||
同形态),**不能**直接用 ICS 形态的 `recurrence_id` ——
|
||||
那样永远匹配不上,覆盖行会被静默忽略(网页上照样画在原时间)。 */
|
||||
$od = self::ridDay((string)$o['recurrence_id']);
|
||||
if ($od !== '') {
|
||||
$ovrs[(int)$eid][$od] = $o;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as $e) {
|
||||
@@ -953,29 +1199,54 @@ final class Calendar
|
||||
(string)$e['starts_at'], $rr, $from, $to,
|
||||
$skips[(int)$e['id']] ?? []
|
||||
);
|
||||
/* 🔴 v52b:覆盖行可能把某一次挪到**别的月份**去 ⇒ 只看"本窗口内的发生"
|
||||
会漏掉它。反着也收一遍:原发生不在窗口里、但**新时间**落在窗口内的那几次。
|
||||
⚠️ `$od` 已经是 `Y-m-d`(见上面建 `$ovrs` 时的 `ridDay`),别再转一次。 */
|
||||
foreach (($ovrs[(int)$e['id']] ?? []) as $od => $o) {
|
||||
$nd = substr((string)($o['starts_at'] ?? ''), 0, 10);
|
||||
if ($nd >= $from && $nd <= $to && !in_array($od, $days, true)) {
|
||||
$days[] = $od;
|
||||
}
|
||||
}
|
||||
foreach ($days as $day) {
|
||||
/* 这一次被改到别的时间了吗?有覆盖行就按覆盖行画。
|
||||
⚠️ 三个"日期"要分清,混用会画出"整串都堆在系列第一天"的怪相:
|
||||
· `$day` = 这一**次发生**在原系列里的日期(也是 `occ` 的值,
|
||||
详情浮层与编辑链路靠它定位"改哪一次");
|
||||
· `$base` = 这一次**画在哪一天**(有覆盖行 ⇒ 它改到的新日期);
|
||||
· `$bsd/$bed` = 用来算**时长**的起止(有覆盖行用覆盖行自己的,
|
||||
没给结束就别继承主事件的时长)。 */
|
||||
$ov = $ovrs[(int)$e['id']][$day] ?? null;
|
||||
$vd = $ov ?: $e;
|
||||
$base = $ov ? substr((string)$ov['starts_at'], 0, 10) : $day;
|
||||
$bsd = substr((string)$vd['starts_at'], 0, 10);
|
||||
$bed = substr((string)($vd['ends_at'] ?: $vd['starts_at']), 0, 10);
|
||||
$vtm = substr((string)$vd['starts_at'], 11, 5);
|
||||
// 没给结束时刻就是"没有结束"(不继承主事件的时长,
|
||||
// 否则"挪到下午茶"会被画成一个 9 小时的块)
|
||||
$vetm = $vd['ends_at'] ? substr((string)$vd['ends_at'], 11, 5) : '';
|
||||
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
|
||||
$span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400);
|
||||
$span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400);
|
||||
$first = true;
|
||||
foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) {
|
||||
$isLast = ($d2 === date('Y-m-d', strtotime($day . ' +' . $span . ' day')));
|
||||
foreach (self::eachDay($base, date('Y-m-d', strtotime($base . ' +' . $span . ' day')), $from, $to) as $d2) {
|
||||
$isLast = ($d2 === date('Y-m-d', strtotime($base . ' +' . $span . ' day')));
|
||||
$out[] = [
|
||||
'cal' => (int)$e['calendar_id'],
|
||||
'color' => (string)($c['color'] ?? '#4A7C82'),
|
||||
'calname' => (string)($c['name'] ?? ''),
|
||||
'title' => (string)$e['title'],
|
||||
'title' => (string)$vd['title'],
|
||||
'date' => $d2,
|
||||
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
|
||||
'time' => ($first && empty($e['all_day'])) ? $tm : '',
|
||||
'time' => ($first && empty($vd['all_day'])) ? $vtm : '',
|
||||
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
|
||||
'end_time'=> ($isLast && $etm !== '' && empty($e['all_day'])) ? $etm : '',
|
||||
'all_day' => !empty($e['all_day']),
|
||||
'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '',
|
||||
'all_day' => !empty($vd['all_day']),
|
||||
'kind' => 'custom',
|
||||
'ref_id' => (int)$e['id'],
|
||||
// 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息",
|
||||
// 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。
|
||||
'location' => (string)($e['location'] ?? ''),
|
||||
'note' => (string)($e['note'] ?? ''),
|
||||
'location' => (string)($vd['location'] ?? ''),
|
||||
'note' => (string)($vd['note'] ?? ''),
|
||||
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
|
||||
'occ' => $day,
|
||||
'repeat' => $rr,
|
||||
|
||||
+348
-14
@@ -15,6 +15,17 @@
|
||||
*
|
||||
* 时区:系统里存的一律是**本地时间**(不带时区),导出也不带 Z、不写 VTIMEZONE。
|
||||
* 家庭应用里"几点开会"就是墙上那个钟,硬套 UTC 反而会整体偏移。
|
||||
*
|
||||
* 🔴🔴 v52b(2026-10-15)修的真 bug:**入库方向原来只"删 Z",不做换算**。
|
||||
* `fromDateTimeValue()` 的正则写作 `/…Z?$/`,`Z` 被吃掉之后数值原样落库 ⇒
|
||||
* 客户端按 RFC 发的 `DTSTART:20261020T060000Z`(UTC 06:00)被当成**本地 06:00**
|
||||
* 存下来,阅读端就少 8 小时(SyncCalendar 报的正是这个)。
|
||||
* ⇒ 现在:带 `Z` 的按 UTC 换算到本地;带 `;TZID=` 的按那个时区换算;
|
||||
* 两者都没有的**仍按本地原样**(那才是"墙上那个钟",不能动)。
|
||||
* ⚠️ 同理,`RRULE` 里的 `UNTIL=…Z` 也必须换算成本地并去掉 `Z` ——
|
||||
* RFC 5545 §3.3.10 要求 `UNTIL` 与 `DTSTART` 是**同一个值类型/同一套时间基准**,
|
||||
* 一个 UTC 一个本地会整体错几个小时(表现是"重复提前结束/多一次")。
|
||||
* 自研客户端 SyncCalendar 0.0.8 起已改发浮动本地时间,与这里口径一致。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -26,6 +37,26 @@ final class Ics
|
||||
/** 导出的产品标识(RFC 5545 PRODID,客户端会拿它判断"是谁生成的") */
|
||||
private const PRODID = '-//FamilyBook//FamilyBook Calendar//CN';
|
||||
|
||||
/**
|
||||
* 我们**建模**(有对应数据库列)的 VEVENT 属性。
|
||||
*
|
||||
* 🔴 v52b(2026-10-15 用户:「既然发现了就直接全部修了」):此前的 parser 只认这几个,
|
||||
* **其余属性一律丢** —— 实测客户端 PUT 一份带 `X-` 自定义属性的 ICS,
|
||||
* 读回来一个都不剩。虽然我们不需要理解它们,但**丢掉就是丢数据**:
|
||||
* · `RDATE`(额外发生日)丢 = 系列少几次;
|
||||
* · 别人加的 `X-`(如 `X-APPLE-STRUCTURED-LOCATION`)丢 = 位置信息没了;
|
||||
* · 第三方客户端下次拉回来发现"我写的东西不见了",有些会改判定为冲突。
|
||||
* ⇒ 未被建模的属性**原样收进 `extra`**(见 parse),导出时原样吐回。
|
||||
*
|
||||
* ⚠️ `EXDATE` / `RECURRENCE-ID` 单独处理,不在这条"原样带过"的路里
|
||||
* (它们要落成 skips 表与覆盖行,见 §重复日程的例外)。
|
||||
*/
|
||||
private const MODELED = [
|
||||
'UID', 'DTSTAMP', 'DTSTART', 'DTEND', 'SUMMARY', 'LOCATION', 'DESCRIPTION',
|
||||
'RRULE', 'STATUS', 'TRANSP', 'SEQUENCE', 'CREATED', 'LAST-MODIFIED',
|
||||
'RECURRENCE-ID',
|
||||
];
|
||||
|
||||
/**
|
||||
* 把一个日历本导出成 .ics 文本。
|
||||
*
|
||||
@@ -44,6 +75,15 @@ final class Ics
|
||||
];
|
||||
foreach ($events as $e) {
|
||||
$out = array_merge($out, self::vevent($e));
|
||||
/* 🔴🔴 v52b:「重复系列里被改掉的某一次」必须与主事件写在**同一个资源**里
|
||||
(CalDAV 里"一个重复系列 = 一个 .ics 文件",RFC 4791 §4.1)。
|
||||
原来这里只吐主事件 ⇒ 客户端推上来的覆盖行**读不回去**,
|
||||
手机上"把周三那次改到周五"在下次同步后凭空消失。
|
||||
⚠️ 顺序:主事件在前、覆盖行在后(个别客户端按出现顺序建索引)。
|
||||
⚠️ 覆盖行的 UID 与主事件**必须相同** —— 那是它们属于同一系列的凭据。 */
|
||||
foreach ((array)($e['_overrides'] ?? []) as $o) {
|
||||
$out = array_merge($out, self::overrideVevent($e, $o));
|
||||
}
|
||||
}
|
||||
$out[] = 'END:VCALENDAR';
|
||||
// 折行 + 统一 CRLF(RFC 5545 要求行尾是 CRLF,虽然多数客户端也收 LF)
|
||||
@@ -100,9 +140,42 @@ final class Ics
|
||||
这条分支一直没被走到(本轮为了验 PUT 重复日程才第一次踩到)。 */
|
||||
/* ⚠️ 值类型是 **RECUR**,不是 TEXT —— 里面的逗号是**结构分隔符**
|
||||
(如 `BYDAY=MO,TU,WE,TH,FR`),**绝不能**用 escapeText 转义,
|
||||
否则客户端会把它当成一整块而解析失败。原样拼接即可。 */
|
||||
$out[] = 'RRULE:' . (string)$e['rrule'];
|
||||
否则客户端会把它当成一整块而解析失败。原样拼接即可。
|
||||
⚠️ `rruleLocal()` 只动 `UNTIL=` 那一小段(把它掰成本地形态),
|
||||
其余一字不改;对已经是对的规则是**幂等**的。 */
|
||||
$out[] = 'RRULE:' . self::rruleLocal((string)$e['rrule'], $allDay);
|
||||
}
|
||||
|
||||
/* ---- 重复系列的"例外日"(RFC 5545 EXDATE)----
|
||||
服务器把"这一次被取消 / 被挪走"存在 `calendar_event_skips`(与网页端
|
||||
"只删这一次"共用同一张表),导出时必须还原成 EXDATE —— 否则客户端
|
||||
下次拉回来会把那一次**又画上**(用户看到的怪相:"我在手机上删了它,
|
||||
过一会儿它自己回来了",SyncCalendar 那边叫幽灵日程)。
|
||||
⚠️ 值必须与 DTSTART **同形态**:全天系列给 `EXDATE;VALUE=DATE:20261022`,
|
||||
计时系列给 `EXDATE:20261022T090000`(时刻取主事件的,因为"哪一次"
|
||||
是按时钟定的)。形态不一对,客户端会静默忽略整行。 */
|
||||
$skipDates = (array)($e['_skip_dates'] ?? []);
|
||||
if ($skipDates && trim((string)($e['rrule'] ?? '')) !== '') {
|
||||
$vals = [];
|
||||
foreach ($skipDates as $sd) {
|
||||
$one = self::exdateValue((string)$sd, (string)$starts, $allDay);
|
||||
if ($one !== '') {
|
||||
$vals[] = $one;
|
||||
}
|
||||
}
|
||||
if ($vals) {
|
||||
$out[] = 'EXDATE' . ($allDay ? ';VALUE=DATE' : '') . ':' . implode(',', $vals);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 未建模的属性原样带回(X-* / RDATE / CATEGORIES…)----
|
||||
我们**不解释**它们,但必须让它们活过一轮 PUT→GET —— 否则第三方客户端
|
||||
会发现"我写上去的东西不见了",轻则丢信息(如苹果的结构化位置),
|
||||
重则判定同步冲突。 */
|
||||
foreach (self::extraLines($e) as $l) {
|
||||
$out[] = $l;
|
||||
}
|
||||
|
||||
$out[] = 'STATUS:' . strtoupper((string)($e['status'] ?: 'confirmed'));
|
||||
$out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT');
|
||||
$out[] = 'SEQUENCE:' . (int)($e['sequence'] ?? 0);
|
||||
@@ -131,6 +204,86 @@ final class Ics
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条**覆盖行**(重复系列里被改写的那一次)→ VEVENT 的若干行。
|
||||
*
|
||||
* RFC 5545 §3.8.4.4 / RFC 4791 §4.1:覆盖行 = 与主事件**同 UID** 的第二个 VEVENT,
|
||||
* 用 `RECURRENCE-ID`(= **原来的**那次发生时刻)指明它替换的是哪一次,
|
||||
* 而它自己的 `DTSTART` 是**改到的新时间**。这就是客户端"仅这一次改到别的时间"
|
||||
* 在协议上的标准表达(Google / 苹果 / 雷鸟都在用)。
|
||||
*
|
||||
* 🔴🔴 v52b(2026-10-15)新增。在这之前服务器把覆盖行当普通日程吞掉,
|
||||
* 于是"改某一次"永远存不下来。
|
||||
*
|
||||
* ⚠️ 三个"必须":
|
||||
* · `UID` 与主事件**相同**(不同 UID = 变成两条不相干的日程);
|
||||
* · `RECURRENCE-ID` 值与主事件的 `DTSTART` **同形态**(全天给 `VALUE=DATE`);
|
||||
* · **不写 `RRULE`** —— 重复规则只属于主事件,覆盖行带 RRULE 会让
|
||||
* 部分客户端把它展开成一整个新系列("改一次"变成"多出一整串")。
|
||||
*/
|
||||
private static function overrideVevent(array $master, array $o): array
|
||||
{
|
||||
$uid = (string)($master['uid'] ?? '');
|
||||
if ($uid === '') {
|
||||
$uid = Calendar::newUid();
|
||||
}
|
||||
$rid = (string)($o['recurrence_id'] ?? '');
|
||||
$allDay = !empty($o['all_day']);
|
||||
$starts = (string)($o['starts_at'] ?? '');
|
||||
$ends = (string)($o['ends_at'] ?? '');
|
||||
$stamp = self::stampOf($o['updated_at'] ?? $o['created_at'] ?? $master['updated_at'] ?? null);
|
||||
|
||||
$out = ['BEGIN:VEVENT', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
|
||||
if ($rid !== '') {
|
||||
$out[] = 'RECURRENCE-ID' . ($allDay ? ';VALUE=DATE' : '') . ':' . $rid;
|
||||
}
|
||||
if ($allDay) {
|
||||
$d = self::dateValue($starts);
|
||||
$out[] = 'DTSTART;VALUE=DATE:' . $d;
|
||||
$out[] = 'DTEND;VALUE=DATE:' . self::nextDay($d);
|
||||
} else {
|
||||
$out[] = 'DTSTART:' . self::dateTimeValue($starts);
|
||||
$out[] = 'DTEND:' . self::dateTimeValue($ends !== '' ? $ends : $starts);
|
||||
}
|
||||
$out[] = 'SUMMARY:' . self::escapeText((string)($o['title'] ?? ''));
|
||||
if (($o['location'] ?? '') !== '') {
|
||||
$out[] = 'LOCATION:' . self::escapeText((string)$o['location']);
|
||||
}
|
||||
if (($o['note'] ?? '') !== '') {
|
||||
$out[] = 'DESCRIPTION:' . self::escapeText((string)$o['note']);
|
||||
}
|
||||
foreach (self::extraLines($o) as $l) {
|
||||
$out[] = $l;
|
||||
}
|
||||
$out[] = 'STATUS:' . strtoupper((string)($o['status'] ?: 'confirmed'));
|
||||
$out[] = 'TRANSP:OPAQUE';
|
||||
$out[] = 'SEQUENCE:' . (int)($o['sequence'] ?? 0);
|
||||
$out[] = 'END:VEVENT';
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 被排除的那一天 → `EXDATE` 的值(与 `DTSTART` 同形态)。
|
||||
*
|
||||
* @param string $dateYmd '2026-10-22'
|
||||
* @param string $masterStarts 主事件的 starts_at('Y-m-d H:i:s',供取时刻)
|
||||
*/
|
||||
private static function exdateValue(string $dateYmd, string $masterStarts, bool $allDay): string
|
||||
{
|
||||
$d = str_replace('-', '', substr(trim($dateYmd), 0, 10));
|
||||
if (strlen($d) !== 8) {
|
||||
return '';
|
||||
}
|
||||
if ($allDay) {
|
||||
return $d;
|
||||
}
|
||||
$t = substr($masterStarts, 11, 8);
|
||||
if (strlen($t) !== 8) {
|
||||
$t = '00:00:00';
|
||||
}
|
||||
return $d . 'T' . str_replace(':', '', $t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个 .ics 文本,返回里面的 VEVENT。
|
||||
*
|
||||
@@ -184,6 +337,20 @@ final class Ics
|
||||
}
|
||||
continue;
|
||||
}
|
||||
/* 「重复日程的例外」必须**逐个**收,不能走下面"同名只留第一个"那条路:
|
||||
· `EXDATE` 可以写**多行**、每行还可以**逗号分隔**多个值
|
||||
(Google / 苹果导出的就是多行)。只留第一个 = 少排除好几次。
|
||||
· 未被建模的属性同理,同一个名字可能出现多次(如两个 `X-`)。 */
|
||||
if ($name === 'EXDATE') {
|
||||
$cur['_exdate'][] = ['v' => $value, 'p' => $params];
|
||||
continue;
|
||||
}
|
||||
if (!in_array($name, self::MODELED, true)) {
|
||||
// ⚠️ 存**拆折行之后**的单行(parse 开头已经反折行过)——
|
||||
// 导出时 export() 会统一重新折行,这里再折一次会折出双倍缩进。
|
||||
$cur['_extra'][] = $line;
|
||||
continue;
|
||||
}
|
||||
// 同名属性出现多次时保留第一个(RFC 里多数属性都是单值的)
|
||||
if (!array_key_exists($name, $cur)) {
|
||||
$cur[$name] = $value;
|
||||
@@ -195,7 +362,17 @@ final class Ics
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 把解析出来的属性归一成我们要落库的形状 */
|
||||
/**
|
||||
* 把解析出来的属性归一成我们要落库的形状。
|
||||
*
|
||||
* 🔴 v52b(2026-10-15):补三样原来**根本没有**的东西 ——
|
||||
* `recurrence_id`(这一条是重复系列里的哪一次)、
|
||||
* `skip_dates`(这一条自己排除了哪些天)、
|
||||
* `extra`(未建模属性原样带过)。
|
||||
* 缺 `recurrence_id` 的后果最重:客户端把"被改到别的时间的某一次"作为
|
||||
* **第二个 VEVENT** 推上来时,它会被当成一条**普通日程**,
|
||||
* 与主事件撞同一个 UID ⇒ 要么被 UID 判重吃掉、要么变成两条(幽灵日程)。
|
||||
*/
|
||||
private static function normalizeVevent(array $p): array
|
||||
{
|
||||
$startRaw = (string)($p['DTSTART'] ?? '');
|
||||
@@ -205,9 +382,11 @@ final class Ics
|
||||
$allDay = stripos($params, 'VALUE=DATE') !== false
|
||||
|| preg_match('/^\d{8}$/', $startRaw) === 1;
|
||||
|
||||
$starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw);
|
||||
$starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw, $params);
|
||||
$ends = $endRaw !== ''
|
||||
? ($allDay ? self::fromDateValue($endRaw) : self::fromDateTimeValue($endRaw))
|
||||
? ($allDay
|
||||
? self::fromDateValue($endRaw)
|
||||
: self::fromDateTimeValue($endRaw, (string)($p['DTEND_PARAMS'] ?? '')))
|
||||
: null;
|
||||
// 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止"
|
||||
if ($allDay && $ends !== null && $starts !== null) {
|
||||
@@ -215,6 +394,13 @@ final class Ics
|
||||
$ends = $d ? date('Y-m-d', $d) . ' 00:00:00' : null;
|
||||
}
|
||||
|
||||
// 重复系列里的"哪一次"(RFC 5545 RECURRENCE-ID)。统一转成**本地浮动**形态,
|
||||
// 与 DTSTART 同基准 —— 否则客户端下轮拿它去比对会发生"对不上"。
|
||||
$ridRaw = trim((string)($p['RECURRENCE-ID'] ?? ''));
|
||||
$rid = $ridRaw === ''
|
||||
? ''
|
||||
: self::canonicalLocalIcs($ridRaw, (string)($p['RECURRENCE-ID_PARAMS'] ?? ''), $allDay);
|
||||
|
||||
return [
|
||||
'uid' => (string)($p['UID'] ?? ''),
|
||||
'title' => self::unescapeText((string)($p['SUMMARY'] ?? '')),
|
||||
@@ -223,12 +409,158 @@ final class Ics
|
||||
'all_day' => $allDay,
|
||||
'location' => self::nullOr(self::unescapeText((string)($p['LOCATION'] ?? ''))),
|
||||
'note' => self::nullOr(self::unescapeText((string)($p['DESCRIPTION'] ?? ''))),
|
||||
'rrule' => self::nullOr((string)($p['RRULE'] ?? '')),
|
||||
// ⚠️ RRULE 存**去掉前缀**的原文(导出侧补 `RRULE:`),
|
||||
// 但 `UNTIL` 要换算成本地并与 DTSTART 同形态,见 rruleLocal()。
|
||||
'rrule' => self::nullOr(self::rruleLocal((string)($p['RRULE'] ?? ''), $allDay)),
|
||||
'status' => strtolower((string)($p['STATUS'] ?? '')) ?: 'confirmed',
|
||||
'reminder_minutes' => self::triggerMinutes((string)($p['_trigger'] ?? '')),
|
||||
'recurrence_id' => $rid,
|
||||
'skip_dates' => self::exdateDates($p['_exdate'] ?? []),
|
||||
'extra' => implode("\n", (array)($p['_extra'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* EXDATE 的值 → 被排除的那几天(`Y-m-d`)。
|
||||
*
|
||||
* ⚠️ **只取日期粒度**:服务器把"某一次例外"存在 `calendar_event_skips.skip_date`
|
||||
* (DATE 列,与网页端"只删这一次"共用同一张表),所以落库时天以下是多余精度。
|
||||
* 重复日程在家庭场景一天最多发生一次(没有 HOURLY 规则),够用。
|
||||
* ⚠️ 带 `TZID` / 结尾 `Z` 的值要**先换算到本地再取日期**:UTC 23:00 的排除
|
||||
* 在本地可能是**第二天**,直接截前 8 位会排错一天。
|
||||
*/
|
||||
private static function exdateDates(array $list): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($list as $one) {
|
||||
$params = (string)($one['p'] ?? '');
|
||||
foreach (explode(',', (string)($one['v'] ?? '')) as $v) {
|
||||
$v = trim($v);
|
||||
if ($v === '') {
|
||||
continue;
|
||||
}
|
||||
$parts = self::localParts($v, $params);
|
||||
$day = $parts !== null ? $parts[0] : '';
|
||||
if ($day !== '') {
|
||||
$out[$day] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_keys($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* ICS 时间值 → **本地墙钟**的 [日期, 时刻]。
|
||||
*
|
||||
* 🔴🔴 v52b(2026-10-15)修的就是这里:原来"看到 `Z` 就当没看见",
|
||||
* 直接把数值原样落库 ⇒ 客户端发 UTC 06:00、服务器存成本地 06:00,
|
||||
* 阅读端少 8 小时。现在三种情况分开处理:
|
||||
* · 结尾 `Z` → 按 UTC 换算到本地;
|
||||
* · 有 `TZID=xxx` → 按该时区换算到本地(时区名不认识则退回原样);
|
||||
* · 两者都没有 → **原样**(浮动时间=本地墙钟,这是本系统的约定,不能动)。
|
||||
*
|
||||
* @param string $v ICS 原始值(`20261020T060000Z` / `20261020` / `20261020T160000`)
|
||||
* @param string $params 该属性的参数串(可能含 `TZID=Asia/Shanghai`)
|
||||
* @return array{0:string,1:string}|null [Y-m-d, H:i:s];无法解析时 null
|
||||
*/
|
||||
private static function localParts(string $v, string $params = ''): ?array
|
||||
{
|
||||
$v = trim($v);
|
||||
if ($v === '') {
|
||||
return null;
|
||||
}
|
||||
// 日期值(全天):没有时刻,也没有时区可言
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $v, $m)) {
|
||||
return [$m[1] . '-' . $m[2] . '-' . $m[3], '00:00:00'];
|
||||
}
|
||||
if (!preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/', $v, $m)) {
|
||||
// 不是紧凑形态(极少数客户端写 `2026-10-20 06:00:00`)→ 交给 strtotime
|
||||
$t = strtotime($v);
|
||||
return $t === false ? null : [date('Y-m-d', $t), date('H:i:s', $t)];
|
||||
}
|
||||
|
||||
$raw = $m[1] . $m[2] . $m[3] . 'T' . $m[4] . $m[5] . $m[6];
|
||||
$zone = null;
|
||||
if ($m[7] === 'Z') {
|
||||
$zone = new \DateTimeZone('UTC');
|
||||
} elseif (preg_match('/(?:^|;)TZID=([^;:]+)/i', $params, $tz)) {
|
||||
try {
|
||||
$zone = new \DateTimeZone(str_replace('"', '', $tz[1]));
|
||||
} catch (\Throwable $e) {
|
||||
// 时区名不认识(拼错 / 山寨 VTIMEZONE)→ 当浮动时间,别让整条日程解析失败
|
||||
$zone = null;
|
||||
}
|
||||
}
|
||||
|
||||
$dt = \DateTime::createFromFormat('Ymd\THis', $raw, $zone ?: null);
|
||||
if ($dt === false) {
|
||||
return null;
|
||||
}
|
||||
if ($zone !== null) {
|
||||
$dt->setTimezone(new \DateTimeZone(date_default_timezone_get()));
|
||||
}
|
||||
return [$dt->format('Y-m-d'), $dt->format('H:i:s')];
|
||||
}
|
||||
|
||||
/**
|
||||
* ICS 时间值 → **本地浮动**的紧凑 ICS 形态(`20261020T160000` / `20261020`)。
|
||||
* 用于 `RECURRENCE-ID`:它必须与 `DTSTART` 同一套基准,客户端才比对得上。
|
||||
*/
|
||||
private static function canonicalLocalIcs(string $v, string $params, bool $allDay): string
|
||||
{
|
||||
$p = self::localParts($v, $params);
|
||||
if ($p === null) {
|
||||
return '';
|
||||
}
|
||||
$ymd = str_replace('-', '', $p[0]);
|
||||
return $allDay ? $ymd : $ymd . 'T' . str_replace(':', '', $p[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `RRULE` 的 `UNTIL` 归一:换算成本地、去掉 `Z`,并与 `DTSTART` 同形态。
|
||||
*
|
||||
* RFC 5545 §3.3.10:`UNTIL` 的值类型必须与 `DTSTART` 一致 ——
|
||||
* `DTSTART` 是本地浮动时,`UNTIL=…Z`(UTC)会让系列整体错几个小时
|
||||
* (表现是"重复提前几天结束"或"多出一次")。服务器既然统一用本地墙钟,
|
||||
* 就必须把进来的 UTC `UNTIL` 也掰成本地。
|
||||
*
|
||||
* ⚠️ 其余部分(`FREQ`/`COUNT`/`BYDAY`…)**一字不动**:那是 RECUR 值,
|
||||
* 里面逗号是**结构分隔符**,绝不能当文本转义或重排。
|
||||
* ⚠️ 幂等:`UNTIL` 已经不带 `Z` 时,走一遍结果不变。
|
||||
*/
|
||||
private static function rruleLocal(string $rrule, bool $allDay): string
|
||||
{
|
||||
$rrule = trim($rrule);
|
||||
if ($rrule === '' || stripos($rrule, 'UNTIL=') === false) {
|
||||
return $rrule;
|
||||
}
|
||||
return (string)preg_replace_callback(
|
||||
'/UNTIL=([0-9]{8}(?:T[0-9]{6}Z?)?)/i',
|
||||
static function (array $m) use ($allDay): string {
|
||||
$ics = self::canonicalLocalIcs($m[1], '', $allDay);
|
||||
return 'UNTIL=' . ($ics !== '' ? $ics : $m[1]);
|
||||
},
|
||||
$rrule
|
||||
);
|
||||
}
|
||||
|
||||
/** `extra` 字段(多行文本)→ 要原样写回的行数组 */
|
||||
private static function extraLines(array $row): array
|
||||
{
|
||||
$raw = (string)($row['extra'] ?? '');
|
||||
if (trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $l) {
|
||||
$l = trim($l);
|
||||
if ($l !== '') {
|
||||
$out[] = $l;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** TRIGGER:-PT15M / TRIGGER:-P1D / TRIGGER;VALUE=DATE-TIME:... → 分钟数 */
|
||||
private static function triggerMinutes(string $v): ?int
|
||||
{
|
||||
@@ -289,15 +621,17 @@ final class Ics
|
||||
return date('Ymd', $t);
|
||||
}
|
||||
|
||||
/** '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00' */
|
||||
private static function fromDateTimeValue(string $v): ?string
|
||||
/**
|
||||
* '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00'(本地墙钟)。
|
||||
*
|
||||
* ⚠️ 第二参数(该属性的参数串)**必须传**:`;TZID=…` 就在里面。
|
||||
* 不传的后果与"删 Z"是同一类静默错误 —— 带 TZID 的值会被当成浮动时间,
|
||||
* 整条日程偏几个小时。见 localParts() 的说明。
|
||||
*/
|
||||
private static function fromDateTimeValue(string $v, string $params = ''): ?string
|
||||
{
|
||||
$v = trim($v);
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/', $v, $m)) {
|
||||
return sprintf('%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]);
|
||||
}
|
||||
$t = strtotime($v);
|
||||
return $t === false ? null : date('Y-m-d H:i:s', $t);
|
||||
$p = self::localParts($v, $params);
|
||||
return $p === null ? null : $p[0] . ' ' . $p[1];
|
||||
}
|
||||
|
||||
/** '20261016' → '2026-10-16 00:00:00' */
|
||||
|
||||
+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