2026-10-02 09:28:07 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 日历 / 日程(v14 新模块,导航条上叫「日程」)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 三条核心设计(用户 2026-10-01 第 7 条逐条对应):
|
|
|
|
|
|
* ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。
|
2026-11-05 17:26:37 +08:00
|
|
|
|
* ② **观影与旅程是每人默认的两本**,名字就叫「观影」「旅程」,内容**从 events 派生** ——
|
2026-10-02 09:28:07 +08:00
|
|
|
|
* 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。
|
|
|
|
|
|
* 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。
|
|
|
|
|
|
* ③ **没有具体时分的日程 = 全天日程**(all_day=1)。
|
|
|
|
|
|
*
|
2026-11-05 17:26:37 +08:00
|
|
|
|
* 日历其实是"综合视图":自定义日程 + 观影 + 旅程 + 有截止时间的任务,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
* 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class Calendar
|
|
|
|
|
|
{
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */
|
|
|
|
|
|
public const ATTACH = 'calendar_event';
|
|
|
|
|
|
|
2026-10-04 10:46:50 +08:00
|
|
|
|
/** 系统预置的默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom')
|
|
|
|
|
|
*
|
|
|
|
|
|
* v24 第 10 批 #4:加了两个**不可删除的只读本** ——
|
|
|
|
|
|
* · lunar 农历(每天的农历日期 + 农历节日),由 Holiday::lunarRange 派生;
|
|
|
|
|
|
* · holiday 节假日(法定假日的**休息与调休**),由 Holiday::year 派生,
|
|
|
|
|
|
* 并且**自动更新**(官方一公布就换用官方数据,见 Holiday 的类注释)。
|
|
|
|
|
|
* 它们和 movie/trip 一样是"从别处派生、不落 calendar_events"的视图本,
|
|
|
|
|
|
* 而且 kind ≠ 'custom' → Calendar::delete() 天然删不掉(用户要求"不可删除")。
|
|
|
|
|
|
*/
|
2026-10-02 09:28:07 +08:00
|
|
|
|
public const DEFAULT_KINDS = [
|
2026-10-06 12:08:40 +08:00
|
|
|
|
/* 🔴 v24 第 21 批 第 7 条:**每人必须有"我自己的日程本"** —— 原来这里
|
|
|
|
|
|
只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/节气),
|
|
|
|
|
|
一本可写的普通本都没有。后果不只是"列表里缺一本":
|
|
|
|
|
|
|
|
|
|
|
|
· 新建日程的"放在哪本"里全是派生本,默认选中排最前的**观影** →
|
|
|
|
|
|
用户直接打字点"加上",建出来的其实是一条**观影记录**(不是日程)。
|
|
|
|
|
|
这是"建错东西",事后极难发现(日历里看着是多了条日程,
|
|
|
|
|
|
可观影列表里莫名多了一条记录);
|
|
|
|
|
|
· 想建一条真正的日程得先自己去"日历本"页建一本 —— 而用户根本没被告知。
|
|
|
|
|
|
|
|
|
|
|
|
所以把"我的日程"补进默认本里(kind='custom' = 可写)。
|
|
|
|
|
|
⚠️ 幂等:seedDefaults 按 `owner_id + kind + name` 去重,
|
|
|
|
|
|
老用户升级时(Schema 里会跑 seedDefaultsForFamily)补一本,不会重复建。
|
|
|
|
|
|
⚠️ 顺序:`mine()` 是 `ORDER BY kind='custom', position, id` ——
|
|
|
|
|
|
custom 排最前,所以它天然就是"新建日程"的默认本。 */
|
|
|
|
|
|
'custom' => ['我的日程', '#4A7C82'],
|
2026-10-04 10:46:50 +08:00
|
|
|
|
'movie' => ['观影', '#7A4E8C'],
|
2026-11-05 17:26:37 +08:00
|
|
|
|
/* 🔴 #71(用户 2026-11-05):「旅程的日历本同步到日历本,它的日历本名字
|
|
|
|
|
|
就叫"旅程",观影就叫"观影"。我看现在"旅程"叫"旅行",这个不对,应该改过来。」
|
|
|
|
|
|
|
|
|
|
|
|
⇒ 「旅行」→「旅程」。模块本身一直叫"旅程"(页面/菜单/接口全是旅程),
|
|
|
|
|
|
只有这本默认日历的名字是"旅行" —— 用户进日历看到"旅行"会觉得是另一个东西。
|
|
|
|
|
|
⚠️ 改这里只影响**新家庭首次 seed**;已有家庭的库里数据要单独 UPDATE
|
|
|
|
|
|
(见同批部署的 SQL)。
|
|
|
|
|
|
⚠️ `movie` 那条**不要动** —— 用户明确说"观影就叫观影",本来就是对的。 */
|
|
|
|
|
|
'trip' => ['旅程', '#3F7D5A'],
|
2026-10-04 10:46:50 +08:00
|
|
|
|
'lunar' => ['农历', '#8C6D3F'],
|
|
|
|
|
|
'holiday' => ['节假日', '#B0342C'],
|
2026-10-04 19:43:00 +08:00
|
|
|
|
// v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个
|
|
|
|
|
|
// 系统日历本,用来存放24节气。」)—— 两个都由 Holiday 派生、都不可删。
|
|
|
|
|
|
'traditional' => ['传统节日', '#A6305C'],
|
|
|
|
|
|
'solarterm' => ['二十四节气', '#307AA6'],
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-04 10:46:50 +08:00
|
|
|
|
/** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */
|
2026-10-06 12:08:40 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* **纯派生、完全不能写入**的日历本:内容按日期算出来(农历/节假日/传统节日/节气)。
|
|
|
|
|
|
* 往这些本子里加日程毫无意义 —— 加了也不会显示(Calendar::month 只从
|
|
|
|
|
|
* kind='custom' 取 calendar_events),只会留下一堆"看不见的日程"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v24 第 21 批 第 7 条:这里原来把 `movie` / `trip` 也算进来了,
|
|
|
|
|
|
* 而 `calendar/event_add` 里**紧跟着**就有两段专门处理它们的分支
|
|
|
|
|
|
* (「已加进观影」/「已加进旅程」)—— 那两段于是**永远走不到**:
|
|
|
|
|
|
* 校验先一步把请求打回去了。用户的日历本选择器里明明列着「观影」和「旅行」
|
|
|
|
|
|
* (它们是可写的:加一条 = 建一条真正的观影/旅程记录),
|
|
|
|
|
|
* 选中却必定失败 —— 这就是"选了观影本、点了加上、只得到一句错误"的根因。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 判据要按**能不能写**来分,不是按"是不是派生":
|
|
|
|
|
|
* · lunar / holiday / traditional / solarterm —— 纯算出来,只读;
|
|
|
|
|
|
* · movie / trip —— 也是派生(内容来自观影/旅程表),但**通过各自的
|
|
|
|
|
|
* 业务路径可写**(日历这边只是把"加一条"转成"建一条记录")。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const READONLY_KINDS = ['lunar', 'holiday', 'traditional', 'solarterm'];
|
2026-10-04 10:46:50 +08:00
|
|
|
|
|
2026-10-06 12:08:40 +08:00
|
|
|
|
/** 这本日历的内容是不是"自动生成、不能直接往里写日程" */
|
2026-10-04 10:46:50 +08:00
|
|
|
|
public static function isReadonlyKind(string $kind): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
return in_array($kind, self::READONLY_KINDS, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 建本时的预设色板(与影院管理同一个交互:取色器 + 点一下就换)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 一律挑**中低饱和**的:月网格里日程条是**白字压在色块上**,
|
|
|
|
|
|
* 太浅看不清字,太艳会盖过整个月视图。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const PRESET_CAL_COLORS = [
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'#A63030', '#A65C30', '#A68930', '#97A630',
|
|
|
|
|
|
'#6BA630', '#3FA630', '#30A64E', '#30A67A',
|
|
|
|
|
|
'#30A6A6', '#307AA6', '#304EA6', '#3F30A6',
|
|
|
|
|
|
'#6B30A6', '#9730A6', '#A63089', '#A6305C',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 提醒档位:提前多久(分钟)—— 对应 RFC 5545 的 VALARM / TRIGGER。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 顺序就是用户 2026-10-02 第 6 条原话的顺序,别按数值大小重排:
|
|
|
|
|
|
* 下拉里"5 分钟、10 分钟、15 分钟…1 天"读起来是递进的,
|
|
|
|
|
|
* 按数值排也一样,但一改动顺序用户就会以为自己选错了。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const REMINDERS = [
|
|
|
|
|
|
5 => '提前 5 分钟',
|
|
|
|
|
|
10 => '提前 10 分钟',
|
|
|
|
|
|
15 => '提前 15 分钟',
|
|
|
|
|
|
30 => '提前 30 分钟',
|
|
|
|
|
|
60 => '提前 1 小时',
|
|
|
|
|
|
120 => '提前 2 小时',
|
|
|
|
|
|
240 => '提前 4 小时',
|
|
|
|
|
|
480 => '提前 8 小时',
|
|
|
|
|
|
720 => '提前 12 小时',
|
|
|
|
|
|
1440 => '提前 1 天',
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/** 新建一本 */
|
|
|
|
|
|
public static function create(int $familyId, int $ownerId, string $name, string $color, string $kind = 'custom'): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$db->prepare(
|
|
|
|
|
|
'INSERT INTO calendars (family_id, owner_id, name, color, kind, position) VALUES (?,?,?,?,?,?)'
|
|
|
|
|
|
)->execute([
|
|
|
|
|
|
$familyId, $ownerId, mb_substr(trim($name), 0, 50, 'UTF-8'),
|
|
|
|
|
|
// ⚠️ 统一**大写** hex:normalize() 会小写化,而我读回来的颜色要跟
|
|
|
|
|
|
// 常量里的 '#7A4E8C' 之类直接比字符串(日历本颜色是给页面用的,
|
|
|
|
|
|
// 大小写混着存会让"颜色一样吗"这种判断时对时错)。
|
|
|
|
|
|
strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'),
|
2026-10-04 10:46:50 +08:00
|
|
|
|
// ⚠️ 别写成 `'kind' => …`:这是个**位置参数**数组(对应 6 个 ? 占位符),
|
|
|
|
|
|
// 混一个命名键进去会让 PDO 报 `Invalid parameter number` ——
|
|
|
|
|
|
// 而且是**静默的**:整个 seedDefaults 抛异常,日历本一本都建不出来。
|
|
|
|
|
|
isset(self::DEFAULT_KINDS[$kind]) || $kind === 'custom' ? $kind : 'custom',
|
2026-10-02 09:28:07 +08:00
|
|
|
|
self::nextPosition($ownerId),
|
|
|
|
|
|
]);
|
|
|
|
|
|
return (int)$db->lastInsertId();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function update(int $id, string $name, string $color): void
|
|
|
|
|
|
{
|
|
|
|
|
|
Database::get()->prepare('UPDATE calendars SET name = ?, color = ? WHERE id = ?')
|
|
|
|
|
|
->execute([
|
|
|
|
|
|
mb_substr(trim($name), 0, 50, 'UTF-8'),
|
|
|
|
|
|
strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'),
|
|
|
|
|
|
$id,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 删一本(默认本不许删 —— 删了观影/旅行就再也回不来了) */
|
|
|
|
|
|
public static function delete(int $id): void
|
|
|
|
|
|
{
|
|
|
|
|
|
Database::get()->prepare("DELETE FROM calendars WHERE id = ? AND kind = 'custom'")->execute([$id]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function find(int $id): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
$st = Database::get()->prepare('SELECT * FROM calendars WHERE id = ?');
|
|
|
|
|
|
$st->execute([$id]);
|
|
|
|
|
|
$r = $st->fetch();
|
|
|
|
|
|
return $r ?: null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 我能看到的日历本 = 我自己的 + 别人共享给我的(带上我对它的权限)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* `perm` 的含义:'owner'(我的,想怎么改都行)/ 'write' / 'read'。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function mine(int $familyId, int $personId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
// ⚠️ 共享给我的本子也要带上**主人是谁**(页面要显示"谁共享给我的"),
|
|
|
|
|
|
// 而且按"我看他叫什么"显示(owner_nick)—— 用户第 3 条。
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
"SELECT c.*, 'owner' AS perm, 0 AS shared,
|
|
|
|
|
|
p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color,
|
|
|
|
|
|
n.nickname AS owner_nick
|
|
|
|
|
|
FROM calendars c
|
|
|
|
|
|
JOIN persons p ON p.id = c.owner_id
|
|
|
|
|
|
LEFT JOIN nicknames n ON n.target_id = c.owner_id AND n.viewer_id = ?
|
|
|
|
|
|
WHERE c.family_id = ? AND c.owner_id = ?
|
|
|
|
|
|
UNION ALL
|
|
|
|
|
|
SELECT c.*, s.perm AS perm, 1 AS shared,
|
|
|
|
|
|
p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color,
|
|
|
|
|
|
n.nickname AS owner_nick
|
|
|
|
|
|
FROM calendar_shares s
|
|
|
|
|
|
JOIN calendars c ON c.id = s.calendar_id
|
|
|
|
|
|
JOIN persons p ON p.id = c.owner_id
|
|
|
|
|
|
LEFT JOIN nicknames n ON n.target_id = c.owner_id AND n.viewer_id = ?
|
|
|
|
|
|
WHERE s.person_id = ? AND c.family_id = ?
|
|
|
|
|
|
ORDER BY kind = 'custom', position, id"
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$personId, $familyId, $personId, $personId, $personId, $familyId]);
|
|
|
|
|
|
return $st->fetchAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 这本日历我能写吗(owner 或共享为 write) */
|
|
|
|
|
|
public static function canWrite(array $cal, int $personId): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
if ((int)$cal['owner_id'] === $personId) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return ($cal['perm'] ?? '') === 'write';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-26 14:59:57 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 「日程表单里可以选来放的日历本」= 我会看到的本 里,**真正能往里放日程**的那些。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴🔴 v95(用户 2026-10-26):「网页端,添加日程,如果日历本是只读的,
|
|
|
|
|
|
* 例如别人共享给我只读,或者系统自带日历、观影、纪念日等,
|
|
|
|
|
|
* 就不要出现在日历本选框中,只出现那些可写的。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么**不能**只写 `canWrite()` —— 这是本轮最容易写错的地方,
|
|
|
|
|
|
* 我一开始也以为 canWrite 就够了,实测(`_v95_dump_mine.py`)打脸:
|
|
|
|
|
|
*
|
|
|
|
|
|
* kind perm canWrite isReadonlyKind
|
|
|
|
|
|
* movie owner **Y** n ← 派生本,canWrite 放行!
|
|
|
|
|
|
* trip owner **Y** n ← 同上
|
|
|
|
|
|
* lunar owner **Y** **Y** ← 系统本,perm 是 owner 所以 canWrite 也放行!
|
|
|
|
|
|
* holiday owner **Y** **Y**
|
|
|
|
|
|
* custom/read read n n ← 只有"只读共享"这一类能被 canWrite 拦住
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 三条判据缺一不可:
|
|
|
|
|
|
* ① `canWrite()` —— 拦掉「别人共享给我且只读」
|
|
|
|
|
|
* ② `!isReadonlyKind()` —— 拦掉系统只读本(农历/节假日/传统节日/节气)。
|
|
|
|
|
|
* 它们的 `owner_id` 就是我,所以 ① 恒真 —— 光靠 ① 一本也拦不住。
|
|
|
|
|
|
* ③ `kind === 'custom'` —— 拦掉观影/旅行派生本。
|
|
|
|
|
|
* 这两本**确实可写**(往里加一条 = 建一条观影/旅程记录,见
|
|
|
|
|
|
* `event_add` 里那两段分支),但它们不是"放日程的地方",
|
|
|
|
|
|
* 放在"新建日程"的下拉里只会让人建出别的东西 —— 用户点名要去掉。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 别图省事写成 kind 白名单(`in_array($kind, [...])`):系统日历本每加一本
|
|
|
|
|
|
* 就要改一次、漏改就是"这本漏网"。② 用的是 `READONLY_KINDS` 常量,
|
|
|
|
|
|
* 新增系统本时只要登记在那里就自动生效。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 这个函数是**唯一口径**:`calendar/index.php`、`index.php`(首页卡片)、
|
|
|
|
|
|
* API 都该走它。v82 那次首页自己写了一份 `array_filter(canWrite)`,
|
|
|
|
|
|
* 结果两个入口的下拉内容可以不一样 —— 一处改了另一处忘改。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function writableForEvent(int $familyId, int $personId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::filterWritableForEvent(self::mine($familyId, $personId), $personId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 上面那个的「已有一份 `mine()` 结果」版本 —— 给**手上已经有 `$cals`** 的调用方用
|
|
|
|
|
|
* (比如 `calendar/index.php` 的模板:那里拿到的是 `$cals`,没有 `$familyId`)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 判据必须**只有这一份**。把过滤逻辑在模板里再抄一遍,
|
|
|
|
|
|
* 就是 v82 那个"两个入口的下拉内容可以不一样"的老毛病。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function filterWritableForEvent(array $cals, int $personId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach ($cals as $c) {
|
|
|
|
|
|
if (!self::canWrite($c, $personId)) {
|
|
|
|
|
|
continue; /* ① 只读共享 */
|
|
|
|
|
|
}
|
|
|
|
|
|
if (self::isReadonlyKind((string)($c['kind'] ?? ''))) {
|
|
|
|
|
|
continue; /* ② 系统只读本 */
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((string)($c['kind'] ?? '') !== 'custom') {
|
|
|
|
|
|
continue; /* ③ 观影/旅行派生本 */
|
|
|
|
|
|
}
|
|
|
|
|
|
$out[] = $c;
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// ---------------- 我的日程显示偏好(v24 用户 2026-10-03 第 4、5 条)----------------
|
|
|
|
|
|
|
|
|
|
|
|
/** 三种视图(用户第 4 条:设置里可选默认) */
|
|
|
|
|
|
public const VIEWS = ['month' => '月', 'week' => '周', 'day' => '天'];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 打开日程页默认看哪个视图(每人一份)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「在日程的设置中,可以设置默认显示的视图模式,月视图还是周视图还是日视图。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 只在用户**没显式指定 view** 时才用它 —— 手点了「周」「月」按钮之后,
|
|
|
|
|
|
* 那个选择必须优先,否则每次翻页都被默认值拽回去,等于按钮点不动。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function defaultView(int $personId): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$v = (string)UserSetting::get($personId, 'cal_view', '');
|
|
|
|
|
|
return isset(self::VIEWS[$v]) ? $v : 'month';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 保存默认视图(非法值当没设过,回落 month) */
|
|
|
|
|
|
public static function saveDefaultView(int $personId, string $v): void
|
|
|
|
|
|
{
|
|
|
|
|
|
UserSetting::set($personId, 'cal_view', isset(self::VIEWS[$v]) ? $v : 'month');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 我在日历页上**显式隐藏了**哪几本(每人一份,存本子 id 的 JSON 数组)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 存"关掉的"而不是"打开的":本子会不断新建(也可能别人共享给我),
|
|
|
|
|
|
* 存"打开的"会导致**新本子默认不显示** —— 用户会以为"共享了但还是看不到"。
|
|
|
|
|
|
* 存"关掉的",新本子天然是显示的,符合直觉。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array<int,bool> 本子 id => true
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function hiddenCalendars(int $personId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$raw = (string)UserSetting::get($personId, 'cal_hidden', '');
|
|
|
|
|
|
$dec = $raw !== '' ? json_decode($raw, true) : null;
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
if (is_array($dec)) {
|
|
|
|
|
|
foreach ($dec as $id) {
|
|
|
|
|
|
$id = (int)$id;
|
|
|
|
|
|
if ($id > 0) {
|
|
|
|
|
|
$out[$id] = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function saveHiddenCalendars(int $personId, array $ids): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach ($ids as $id) {
|
|
|
|
|
|
$id = (int)$id;
|
|
|
|
|
|
if ($id > 0 && !in_array($id, $out, true)) {
|
|
|
|
|
|
$out[] = $id;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
UserSetting::set($personId, 'cal_hidden', json_encode($out));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 按 id 取一本「我能看到的」日历(带 perm / shared)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 判权限时**必须**走这里而不是 Calendar::find():find() 返回的是 calendars
|
|
|
|
|
|
* 表原行,里面没有 perm —— 于是「共享给我的、可读写的本子」一律被判成不能写,
|
|
|
|
|
|
* 被共享的人建了日程也改不了(蹲在 calendar/event 页上看只读提示)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function mineOne(int $familyId, int $personId, int $calendarId): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (self::mine($familyId, $personId) as $c) {
|
|
|
|
|
|
if ((int)$c['id'] === $calendarId) {
|
|
|
|
|
|
return $c;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/** 给某人补建两本默认日历(幂等) */
|
|
|
|
|
|
public static function seedDefaults(int $familyId, int $personId): void
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (self::DEFAULT_KINDS as $kind => list($name, $color)) {
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT id FROM calendars WHERE owner_id = ? AND kind = ? AND name = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$personId, $kind, $name]);
|
|
|
|
|
|
if ($st->fetchColumn() === false) {
|
|
|
|
|
|
self::create($familyId, $personId, $name, $color, $kind);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 13:44:24 +08:00
|
|
|
|
/** 升级 / 安装时给全家每个账号补一遍(老库也要有这些本) */
|
2026-10-02 09:28:07 +08:00
|
|
|
|
public static function seedDefaultsForFamily(int $familyId): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
"SELECT p.id FROM persons p JOIN accounts a ON a.person_id = p.id
|
|
|
|
|
|
WHERE p.family_id = ? AND a.status = 'active'"
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$familyId]);
|
|
|
|
|
|
foreach ($st->fetchAll() as $r) {
|
|
|
|
|
|
self::seedDefaults($familyId, (int)$r['id']);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 13:44:24 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 补一本「我的日程」(kind='custom')—— 每个人都要有。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v24 第 21 批 第 7 条:`DEFAULT_KINDS` 里原来只有 6 本**派生**本,
|
|
|
|
|
|
* 于是"新建日程 → 放在哪本"全是派生本、默认还选中排最前的**观影** ——
|
|
|
|
|
|
* 用户直接打字点「加上」,建出来的是**观影记录**("建错东西",
|
|
|
|
|
|
* 事后极难发现)。所以默认清单里补了 'custom' => 「我的日程」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ **但只补清单是不够的**:`seedDefaults()` 只在**安装**与**结构升级**
|
|
|
|
|
|
* 那两条路径上被调用,而线上这套库早就升过级了 → 老用户**永远拿不到**这一本
|
|
|
|
|
|
* (实测线上:owner=1 可写的普通本 0 本,唯一那本 custom 是用户自己建的)。
|
|
|
|
|
|
* 所以必须有这条"按需补"的路径,由**打开日历时**顺手调一次(见 calendar 路由)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 幂等:`owner_id + kind='custom'` 只要有一本就不再补 —— 名字**不计入判据**,
|
|
|
|
|
|
* 否则用户把自己的本子改名成别的之后,会又冒出一本「我的日程」
|
|
|
|
|
|
* (`seedDefaults()` 是按 `owner_id + kind + name` 判重的,这里刻意更宽)。
|
|
|
|
|
|
* ⚠️ 一次请求只跑一条 `SELECT`(有就不再写),对打开日历的开销可忽略。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function ensurePersonalBook(int $familyId, int $personId): void
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($familyId <= 0 || $personId <= 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
"SELECT id FROM calendars WHERE owner_id = ? AND kind = 'custom' LIMIT 1"
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$personId]);
|
|
|
|
|
|
if ($st->fetchColumn() !== false) {
|
|
|
|
|
|
return; // 已经有一本可写的普通本了(不管它叫什么名字)
|
|
|
|
|
|
}
|
|
|
|
|
|
[$name, $color] = self::DEFAULT_KINDS['custom'];
|
|
|
|
|
|
self::create($familyId, $personId, $name, $color, 'custom');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ---------------- 共享 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
public static function shares(int $calendarId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
|
|
|
|
|
|
FROM calendar_shares s JOIN persons p ON p.id = s.person_id
|
|
|
|
|
|
WHERE s.calendar_id = ? ORDER BY p.id'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$calendarId]);
|
|
|
|
|
|
return $st->fetchAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function setShare(int $calendarId, int $personId, string $perm): void
|
|
|
|
|
|
{
|
|
|
|
|
|
Database::get()->prepare(
|
|
|
|
|
|
'INSERT INTO calendar_shares (calendar_id, person_id, perm) VALUES (?,?,?)
|
|
|
|
|
|
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
|
|
|
|
|
|
)->execute([$calendarId, $personId, $perm === 'write' ? 'write' : 'read']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function dropShare(int $calendarId, int $personId): void
|
|
|
|
|
|
{
|
|
|
|
|
|
Database::get()->prepare('DELETE FROM calendar_shares WHERE calendar_id = ? AND person_id = ?')
|
|
|
|
|
|
->execute([$calendarId, $personId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- 日程 ----------------
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 新建一条日程。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ v24(第 5、6 条):起止是**完整的 DATETIME(精确到秒)**,不再分成
|
|
|
|
|
|
* "日期一列 + 时刻一列" —— 分开存就表达不了"今天 22:30 到明天 01:15",
|
|
|
|
|
|
* 而且用户填的时候也得分两处。全天日程仍然写 DATETIME,只是时间部分是 00:00:00。
|
|
|
|
|
|
* ⚠️ uid 一定要在**插入时**生成:CalDAV 客户端靠 UID 认日程,
|
|
|
|
|
|
* 没有 UID 的日程每同步一次就被当成新日程再建一条(无限重复)。
|
|
|
|
|
|
*/
|
2026-10-02 09:28:07 +08:00
|
|
|
|
public static function addEvent(int $calendarId, array $d): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$db->prepare(
|
|
|
|
|
|
'INSERT INTO calendar_events
|
2026-10-03 08:18:05 +08:00
|
|
|
|
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
|
|
|
|
|
|
reminder_minutes, status, transp, rrule, note, created_by)
|
|
|
|
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
2026-10-02 09:28:07 +08:00
|
|
|
|
)->execute([
|
|
|
|
|
|
$calendarId,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
self::newUid(),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
self::textOrNull((string)($d['location'] ?? ''), 255),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$d['starts_at'],
|
|
|
|
|
|
$d['ends_at'] ?: null,
|
|
|
|
|
|
!empty($d['all_day']) ? 1 : 0,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
self::reminderMinutes($d['reminder_minutes'] ?? null),
|
|
|
|
|
|
self::statusOf($d['status'] ?? null),
|
|
|
|
|
|
!empty($d['transp']) ? 1 : 0,
|
|
|
|
|
|
self::textOrNull((string)($d['rrule'] ?? ''), 255),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
self::textOrNull($d['note'] ?? ''),
|
|
|
|
|
|
$d['created_by'] ?: null,
|
|
|
|
|
|
]);
|
|
|
|
|
|
return (int)$db->lastInsertId();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 改一条日程。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ SEQUENCE 每次 +1(RFC 5545):CalDAV 客户端拿它判断"服务端这版比我新吗"。
|
|
|
|
|
|
* 不加的话,改完日程客户端可能还抱着旧的不放。
|
|
|
|
|
|
*/
|
2026-10-02 09:28:07 +08:00
|
|
|
|
public static function updateEvent(int $id, array $d): void
|
|
|
|
|
|
{
|
2026-10-15 20:08:05 +08:00
|
|
|
|
$sets = 'title=?, location=?, starts_at=?, ends_at=?, all_day=?,
|
|
|
|
|
|
reminder_minutes=?, status=?, transp=?, rrule=?, note=?';
|
|
|
|
|
|
$args = [
|
2026-10-02 09:28:07 +08:00
|
|
|
|
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
self::textOrNull((string)($d['location'] ?? ''), 255),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$d['starts_at'],
|
|
|
|
|
|
$d['ends_at'] ?: null,
|
|
|
|
|
|
!empty($d['all_day']) ? 1 : 0,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
self::reminderMinutes($d['reminder_minutes'] ?? null),
|
|
|
|
|
|
self::statusOf($d['status'] ?? null),
|
|
|
|
|
|
!empty($d['transp']) ? 1 : 0,
|
|
|
|
|
|
self::textOrNull((string)($d['rrule'] ?? ''), 255),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
self::textOrNull($d['note'] ?? ''),
|
2026-10-15 20:08:05 +08:00
|
|
|
|
];
|
|
|
|
|
|
/* ⚠️ `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);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function deleteEvent(int $id): void
|
|
|
|
|
|
{
|
2026-10-03 08:18:05 +08:00
|
|
|
|
Attachment::removeAll(self::ATTACH, $id);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/** 一条日程(含所属本子;用来判权限) */
|
|
|
|
|
|
public static function event(int $id): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT e.*, c.owner_id, c.name AS cal_name, c.color AS cal_color, c.kind AS cal_kind
|
|
|
|
|
|
FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id
|
|
|
|
|
|
WHERE e.id = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$id]);
|
|
|
|
|
|
$r = $st->fetch();
|
|
|
|
|
|
return $r ?: null; // 「能不能写」另走 canWriteEvent()(要按人查共享表,塞不进这一行)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 这条日程我能写吗(按它所在本子的归属与共享权限判)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 单独开一个方法而不是让调用方自己拼数组给 canWrite():
|
|
|
|
|
|
* canWrite() 吃的是「日历本」那一行(要带 perm),拿日程行直接喂进去
|
|
|
|
|
|
* 会因为没有 perm 而恒判成不能写。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function canWriteEvent(array $event, int $personId): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
if ((int)($event['owner_id'] ?? 0) === $personId) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT perm FROM calendar_shares WHERE calendar_id = ? AND person_id = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([(int)$event['calendar_id'], $personId]);
|
|
|
|
|
|
return (string)$st->fetchColumn() === 'write';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// ---------------- 重复日程的「某一次」例外(用户 2026-10-03 第 5 条)----------------
|
|
|
|
|
|
|
|
|
|
|
|
/** 把某一天标记为"跳过"(取消这一次 / 这一次被挪走) */
|
|
|
|
|
|
public static function skipOnce(int $eventId, string $dateYmd): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$d = substr(trim($dateYmd), 0, 10);
|
|
|
|
|
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
Database::get()->prepare(
|
|
|
|
|
|
'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?)
|
|
|
|
|
|
ON DUPLICATE KEY UPDATE skip_date = VALUES(skip_date)'
|
|
|
|
|
|
)->execute([$eventId, $d]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 取消"跳过"标记(撤销一次取消) */
|
|
|
|
|
|
public static function unskipOnce(int $eventId, string $dateYmd): void
|
|
|
|
|
|
{
|
|
|
|
|
|
Database::get()->prepare('DELETE FROM calendar_event_skips WHERE event_id = ? AND skip_date = ?')
|
|
|
|
|
|
->execute([$eventId, substr(trim($dateYmd), 0, 10)]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 这条日程被跳过了哪些日期 */
|
|
|
|
|
|
public static function skipsOf(int $eventId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT skip_date FROM calendar_event_skips WHERE event_id = ? ORDER BY skip_date'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$eventId]);
|
|
|
|
|
|
return array_map(static fn($r) => (string)$r['skip_date'], $st->fetchAll());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 20:08:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 把这条日程的「例外日」**整组替换**成给定的几天(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 11:30:48 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么要独立一条而不是改原日程:原日程是**整个系列**的定义,
|
|
|
|
|
|
* 「仅此一次」的改动不能碰它。所以:
|
|
|
|
|
|
* ① 在原系列里把这一天标记为跳过(不再出现原样那次);
|
|
|
|
|
|
* ② 新建一条只发生这一次的日程,内容 = 用户改后的。
|
|
|
|
|
|
* 这样"改过的那一天"与"其余各次"各自独立,之后怎么改都不会互相干扰。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return int 新建的那条日程 id
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function detachOnce(array $ev, string $date, array $d, int $byId = 0): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$date = substr(trim($date), 0, 10);
|
|
|
|
|
|
self::skipOnce((int)$ev['id'], $date);
|
|
|
|
|
|
|
|
|
|
|
|
// 时刻:沿用用户改后的;他改的是"那一天的"日程,日期必须锁在 $date
|
|
|
|
|
|
$starts = (string)($d['starts_at'] ?? '');
|
|
|
|
|
|
$ends = (string)($d['ends_at'] ?? '');
|
|
|
|
|
|
$startDay = substr($starts, 0, 10);
|
|
|
|
|
|
if ($startDay !== $date) {
|
|
|
|
|
|
// 用户没改日期(或者改到别的天了)→ 把这一天对齐到 $date,
|
|
|
|
|
|
// 只取他改的时刻部分;否则"仅此一次"会莫名其妙跳走一天。
|
|
|
|
|
|
$starts = $date . ' ' . (substr($starts, 11, 8) !== '' ? substr($starts, 11, 8) : '00:00:00');
|
|
|
|
|
|
}
|
|
|
|
|
|
$ends = $ends !== '' ? $date . ' ' . (substr($ends, 11, 8) ?: '00:00:00') : null;
|
|
|
|
|
|
|
|
|
|
|
|
$new = $d;
|
|
|
|
|
|
$new['starts_at'] = $starts;
|
|
|
|
|
|
$new['ends_at'] = $ends;
|
|
|
|
|
|
$new['rrule'] = ''; // 单次日程不带重复规则
|
|
|
|
|
|
// ⚠️ addEvent 内部会自己 newUid(),别在这里另设 $new['uid'](会被忽略,
|
|
|
|
|
|
// 只是让人误以为"UID 是我指定的")。created_by 走 $d['created_by']。
|
|
|
|
|
|
$new['created_by'] = $byId ?: ($d['created_by'] ?? null);
|
|
|
|
|
|
return self::addEvent((int)$ev['calendar_id'], $new);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/** 一条日程的 UID(CalDAV 那边按 UID 取) */
|
|
|
|
|
|
public static function newUid(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return bin2hex(random_bytes(12)) . '@familybook';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 导出 ics 用的行集(用户 2026-10-02 第 7 条)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 三本日历的内容来源不一样,但**导出时都要有东西**:
|
|
|
|
|
|
* · custom → calendar_events 里真存着的自定义日程;
|
|
|
|
|
|
* · movie → events(kind=movie) 派生;
|
|
|
|
|
|
* · trip → trip_nodes 派生(与日历视图同一口径:出节点,不出整趟旅程)。
|
|
|
|
|
|
* 统一成 calendar_events 那一套字段名(starts_at/ends_at/title/...),
|
|
|
|
|
|
* Ics 那边就不必分辨来源是哪种。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array<int,array>
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function exportRows(array $cal, int $familyId, int $me): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$kind = (string)$cal['kind'];
|
|
|
|
|
|
if ($kind === 'movie' || $kind === 'trip') {
|
|
|
|
|
|
// 派生本:先按一个足够宽的范围取日历视图,再把每一条映射成可导出的行
|
|
|
|
|
|
$from = '1970-01-01';
|
|
|
|
|
|
$to = '2100-01-01';
|
|
|
|
|
|
$out = [];
|
2026-10-15 20:08:05 +08:00
|
|
|
|
$gens = []; // key => 同一个节点铺出来的各天(v52:要合成一条跨天行,见下)
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$want = (int)$cal['id'];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// 🔴 v31 修的真 bug:`month()` 里**任务**与**纪念日**那两段是
|
|
|
|
|
|
// **不带 kind 判断**的(它们本就不属于任何日历本,`cal` 给的是 0)。
|
|
|
|
|
|
// 于是"取观影本"也会把全家 130 年的生日 + 所有任务一起带出来 ——
|
|
|
|
|
|
// 实测线上 /dav/1.ics/ 的 2981 条里,绝大多数是 anniv(生日)。
|
|
|
|
|
|
// ⇒ 导出必须只认**真正属于这一本**的条目。
|
|
|
|
|
|
// ⚠️ 判据是 `cal` 相等,不是 `kind` 白名单:白名单每加一个
|
|
|
|
|
|
// 系统日历本就要改一次,漏改就是"这本里混进别人"。
|
|
|
|
|
|
if ((int)($e['cal'] ?? 0) !== $want) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-15 20:08:05 +08:00
|
|
|
|
/* ⚠️ 分组键**不能含日期**:跨天条目会被 `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] = [];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
2026-10-15 20:08:05 +08:00
|
|
|
|
$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];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$out[] = [
|
|
|
|
|
|
'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook',
|
|
|
|
|
|
'title' => (string)$e['title'],
|
2026-10-15 20:08:05 +08:00
|
|
|
|
'starts_at' => $starts,
|
|
|
|
|
|
'ends_at' => $ends,
|
|
|
|
|
|
'all_day' => $allDay ? 1 : 0,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'location' => null,
|
|
|
|
|
|
'note' => null,
|
|
|
|
|
|
'status' => 'confirmed',
|
|
|
|
|
|
'transp' => 0,
|
|
|
|
|
|
'sequence' => 0,
|
|
|
|
|
|
'created_at' => null,
|
|
|
|
|
|
'updated_at' => null,
|
|
|
|
|
|
'reminder_minutes' => null,
|
|
|
|
|
|
'rrule' => null,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([(int)$cal['id']]);
|
2026-10-08 20:05:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 🔴 v31:系统日历本(农历 / 节假日 / 传统节日 / 节气)原来是**空的** ——
|
|
|
|
|
|
// exportRows 只认 custom/movie/trip,于是手机上把这几个本订阅进来
|
|
|
|
|
|
// 里面一条都没有,用户会以为"同步坏了"。
|
|
|
|
|
|
// 它们的内容是纯日期推导(Holiday::*),没有 calendar_events 行,只能派生。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 范围必须**封顶**,不能像派生本那样 1970→2100:
|
|
|
|
|
|
// · 农历是**每天一条** —— 130 年 ≈ 47500 条,单个 multistatus 会有十几 MB;
|
|
|
|
|
|
// · 客户端订阅的也是"现在前后若干年"。
|
|
|
|
|
|
// 给 [今年-1, 今年+5]:往前一年覆盖"今年年初要看的去年腊月",往后五年
|
|
|
|
|
|
// 够手机翻日历用,总量(农历 ≈ 2200 条)与真实使用相符。
|
|
|
|
|
|
if ($kind !== 'custom') {
|
|
|
|
|
|
$y0 = (int)date('Y') - 1;
|
|
|
|
|
|
$y1 = (int)date('Y') + 5;
|
|
|
|
|
|
$want = (int)$cal['id'];
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach (self::month($familyId, [$cal], $me, $y0 . '-01-01', $y1 . '-12-31') as $e) {
|
|
|
|
|
|
// 只认属于这一本的(`month()` 里任务/纪念日那两段不带 kind 判断,
|
|
|
|
|
|
// 给任意 cal 都会一起出来 —— 见上面派生本那段注释)
|
|
|
|
|
|
if ((int)($e['cal'] ?? 0) !== $want) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$day = (string)$e['date'];
|
|
|
|
|
|
// ⚠️ UID 必须**带日期**:这些条目 ref_id 恒为 0,
|
|
|
|
|
|
// 照搬 `kind-ref_id@familybook` 会让整本共用一个 UID,
|
|
|
|
|
|
// 按 UID 去重后就只剩一天(见 CalDav::resources)。
|
|
|
|
|
|
$out[] = [
|
|
|
|
|
|
'uid' => (string)$e['kind'] . '-' . $day . '@familybook',
|
|
|
|
|
|
'title' => (string)$e['title'],
|
|
|
|
|
|
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
|
|
|
|
|
|
'ends_at' => null,
|
|
|
|
|
|
'all_day' => 1,
|
|
|
|
|
|
'location' => null,
|
|
|
|
|
|
'note' => null,
|
|
|
|
|
|
'status' => 'confirmed',
|
|
|
|
|
|
'transp' => 0,
|
|
|
|
|
|
'sequence' => 0,
|
|
|
|
|
|
'created_at' => null,
|
|
|
|
|
|
'updated_at' => null,
|
|
|
|
|
|
'reminder_minutes' => null,
|
|
|
|
|
|
'rrule' => null,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
2026-10-15 20:08:05 +08:00
|
|
|
|
/* ---- 把重复系列的「例外」附着到对应主事件上(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;
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 13:22:37 +08:00
|
|
|
|
/** 按 UID 在本子里找那条主事件的 id(找不到给 0) */
|
|
|
|
|
|
private static function eventIdByUid(int $calendarId, string $uid): int
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($uid === '') {
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ? ORDER BY id LIMIT 1'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$calendarId, $uid]);
|
|
|
|
|
|
return (int)$st->fetchColumn();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
2026-10-21 13:22:37 +08:00
|
|
|
|
* 覆盖行的 UID —— **只在"正文里有覆盖行、却没有对应主事件"时**才用。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么必须换 UID 而不能沿用原 UID:原 UID 是主事件的身份证。真拿它去插一条
|
|
|
|
|
|
* 独立日程,等主事件后来真的导进来时,`importEvent()` 会按 UID 撞上这条孤儿
|
|
|
|
|
|
* ⇒ `updateEvent()` 把主事件(连同 RRULE)改写成这一个孤儿的模样。
|
|
|
|
|
|
* 加 `#rid:<RECURRENCE-ID>` 后缀既保证**跨次导入稳定**(不会重复插),
|
|
|
|
|
|
* 又让两件事在 UID 上彻底分开。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function orphanUid(string $uid, string $rid): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$suffix = '#rid:' . substr($rid, 0, 32);
|
|
|
|
|
|
if ($uid === '') {
|
|
|
|
|
|
return self::newUid();
|
|
|
|
|
|
}
|
|
|
|
|
|
// `calendar_events.uid` 是 VARCHAR(255):先按余量截断再拼后缀
|
|
|
|
|
|
$keep = 255 - mb_strlen($suffix, 'UTF-8');
|
|
|
|
|
|
return mb_substr($uid, 0, max(1, $keep), 'UTF-8') . $suffix;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 写一条**覆盖行**(重复系列里被改写的那一次)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 语义(RFC 5545 §3.8.4.4):`RECURRENCE-ID` 指明替换的是**原发生时刻**,
|
|
|
|
|
|
* 这一行自己的 `starts_at` 是**改到的新时间**。所以:
|
|
|
|
|
|
* · 主事件(`calendar_events`)**一个字都不动** —— 尤其不能碰它的 `rrule`;
|
|
|
|
|
|
* · 覆盖行落进 `calendar_event_overrides`(卫星表,既有的取数链已经全认它);
|
|
|
|
|
|
* · 顺手把**原发生那一天**记进 skips,否则网页/APP/CalDAV 会同时画出
|
|
|
|
|
|
* "原时间那次"和"改后那次",变成一天两条。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function upsertOverride(int $eventId, array $v, string $rid): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$rid = substr(trim($rid), 0, 64);
|
|
|
|
|
|
$title = trim((string)($v['title'] ?? ''));
|
|
|
|
|
|
Database::get()->prepare(
|
|
|
|
|
|
'INSERT INTO calendar_event_overrides
|
|
|
|
|
|
(event_id, recurrence_id, title, location, starts_at, ends_at, all_day,
|
|
|
|
|
|
note, status, sequence, extra)
|
|
|
|
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
|
|
|
|
ON DUPLICATE KEY UPDATE
|
|
|
|
|
|
title = VALUES(title), location = VALUES(location),
|
|
|
|
|
|
starts_at = VALUES(starts_at), ends_at = VALUES(ends_at),
|
|
|
|
|
|
all_day = VALUES(all_day), note = VALUES(note), status = VALUES(status),
|
|
|
|
|
|
sequence = VALUES(sequence), extra = VALUES(extra)'
|
|
|
|
|
|
)->execute([
|
|
|
|
|
|
$eventId,
|
|
|
|
|
|
$rid,
|
|
|
|
|
|
mb_substr($title !== '' ? $title : '(无标题日程)', 0, 200, 'UTF-8'),
|
|
|
|
|
|
self::textOrNull((string)($v['location'] ?? ''), 255),
|
|
|
|
|
|
(string)$v['starts_at'],
|
|
|
|
|
|
($v['ends_at'] ?? null) ?: null,
|
|
|
|
|
|
!empty($v['all_day']) ? 1 : 0,
|
|
|
|
|
|
self::textOrNull((string)($v['note'] ?? '')),
|
|
|
|
|
|
self::statusOf($v['status'] ?? null),
|
|
|
|
|
|
(int)($v['sequence'] ?? 0),
|
|
|
|
|
|
self::textOrNull((string)($v['extra'] ?? '')),
|
|
|
|
|
|
]);
|
|
|
|
|
|
$day = self::ridDay($rid);
|
|
|
|
|
|
if ($day !== '') {
|
|
|
|
|
|
self::skipOnce($eventId, $day);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 导入一份 ics 的**全部 VEVENT**(网页「导入 .ics」走这个,别自己 for 循环调
|
|
|
|
|
|
* `importEvent`)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴🔴 v71(用户 2026-10-19)修的真 bug —— 三个日历本导入后"日程不全":
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「日历本共享后,双方都能看到对方共享给自己的日历本,但是有两个
|
|
|
|
|
|
* 日历本,里面的日程是不对的,或者说不全的。有很多日程都没有…我把这三个
|
|
|
|
|
|
* 日历本的原来的ics给你,你可以比对一下,这三个ics和数据库中是否一致。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实测三份 ics 与线上库逐条对账(2026-10-19):
|
|
|
|
|
|
*
|
|
|
|
|
|
* 本子 VEVENT 不同UID 有RID 库内条数 库里rrule
|
|
|
|
|
|
* Bowieの课表 28 14 14 14 7
|
|
|
|
|
|
* 工作学习 411 385 28 385 19
|
|
|
|
|
|
* 重要 _ 健康 204 148 60 148 29
|
|
|
|
|
|
*
|
|
|
|
|
|
* `库内条数 == 不同 UID 数` 是铁证:这些文件里**同一个 UID 下有多个 VEVENT**
|
|
|
|
|
|
* (1 条带 RRULE 的主事件 + 若干条带 `RECURRENCE-ID` 的**覆盖行**,也就是
|
|
|
|
|
|
* "这几节课单独调课/补课")。而 `importEvent()` 只按 `(calendar_id, uid)` 判重
|
|
|
|
|
|
* ⇒ 第 2 条起全走 `updateEvent()`,把主事件**覆盖行一条条改掉**。
|
|
|
|
|
|
* 后果有两层,第二层更致命:
|
|
|
|
|
|
* ① 覆盖行本身全丢(Bowie 的课表少 14 条、工作学习少 26 条、重要/健康少 56 条);
|
|
|
|
|
|
* ② **主事件被改写成最后一条覆盖行的样子**(时间挪了、`rrule` 被清空)
|
|
|
|
|
|
* ⇒ 一整个"每周三的系列"退化成"某一天的一条日程",系列里其余几十次
|
|
|
|
|
|
* 一起消失。库里残留 RRULE 数 = ICS 的 RRULE 数 − 有多 VEVENT 的 UID 数,
|
|
|
|
|
|
* 三个本子分别 13−6=7 / 25−6=19 / 53−24=29,与实测 7/19/29 **完全吻合**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 所以正确做法是分三遍走:主事件先落 → 覆盖行挂到主事件上 → EXDATE 最后替换。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array<int,array> $list `Ics::parse()` 的返回值
|
|
|
|
|
|
* @return array{added:int,updated:int,overrides:int,skips:int}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function importIcs(int $calendarId, array $list, int $createdBy): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$added = 0;
|
|
|
|
|
|
$updated = 0;
|
|
|
|
|
|
$ovrN = 0;
|
|
|
|
|
|
|
|
|
|
|
|
$skipByUid = []; // uid => [Y-m-d…](EXDATE 要先等主事件有了 id 才能落)
|
|
|
|
|
|
$defer = []; // 覆盖行留到第二遍(主事件必须已经在库里)
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- 第一遍:主事件(没有 RECURRENCE-ID 的那些)---- */
|
|
|
|
|
|
$seenUid = [];
|
|
|
|
|
|
foreach ($list as $v) {
|
|
|
|
|
|
if (($v['starts_at'] ?? null) === null) {
|
|
|
|
|
|
continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年
|
|
|
|
|
|
}
|
|
|
|
|
|
if (trim((string)$v['title']) === '') {
|
|
|
|
|
|
$v['title'] = '(无标题日程)';
|
|
|
|
|
|
}
|
|
|
|
|
|
$rid = trim((string)($v['recurrence_id'] ?? ''));
|
|
|
|
|
|
if ($rid !== '') {
|
|
|
|
|
|
$defer[] = $v;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$uid = (string)($v['uid'] ?? '');
|
|
|
|
|
|
if ($uid !== '') {
|
|
|
|
|
|
if (isset($seenUid[$uid])) {
|
|
|
|
|
|
/* 同一份文件里同 UID 出现**多个无 RECURRENCE-ID** 的 VEVENT:
|
|
|
|
|
|
协议上说不通(一个系列只能有一个主事件),当成覆盖行更错
|
|
|
|
|
|
(没有 RECURRENCE-ID 就不知道该替换哪一次)。
|
|
|
|
|
|
宁可只收第一条,也不要把主事件越改越歪。 */
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$seenUid[$uid] = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (self::importEvent($calendarId, $v, $createdBy) === 'added') {
|
|
|
|
|
|
$added++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$updated++;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($uid !== '' && !empty($v['skip_dates'])) {
|
|
|
|
|
|
$skipByUid[$uid] = array_merge($skipByUid[$uid] ?? [], (array)$v['skip_dates']);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- 第二遍:覆盖行 ---- */
|
|
|
|
|
|
foreach ($defer as $v) {
|
|
|
|
|
|
$uid = (string)($v['uid'] ?? '');
|
|
|
|
|
|
if ($uid !== '' && !empty($v['skip_dates'])) {
|
|
|
|
|
|
$skipByUid[$uid] = array_merge($skipByUid[$uid] ?? [], (array)$v['skip_dates']);
|
|
|
|
|
|
}
|
|
|
|
|
|
$r = self::importEvent($calendarId, $v, $createdBy);
|
|
|
|
|
|
if ($r === 'override') {
|
|
|
|
|
|
$ovrN++;
|
|
|
|
|
|
} elseif ($r === 'added') {
|
|
|
|
|
|
$added++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$updated++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- 第三遍:EXDATE(`calendar_event_skips`)----
|
|
|
|
|
|
⚠️ **整组替换**而不是逐条追加:ics 是这本日期的权威快照,重新导入
|
|
|
|
|
|
就该是文件里写的那些。但**文件完全没写 EXDATE 的 UID 一律不动** ——
|
|
|
|
|
|
很多导出格式只在需要时才吐 EXDATE,无脑清空会把用户在网页上
|
|
|
|
|
|
手动取消的那些"这一次"全抹掉。
|
|
|
|
|
|
🔴 必须把**第二遍写进去的覆盖行日期并回来**:`upsertOverride()` 为了
|
|
|
|
|
|
"别一天画两条"会把原发生日记进 skips,而 `setSkips()` 是整组替换
|
|
|
|
|
|
⇒ 直接拿 EXDATE 覆盖会把它们全删掉,于是原时间那次和改后那次
|
|
|
|
|
|
一起画出来(本轮实测就是这么翻的:覆盖行 56 条,skip 却只剩 EXDATE 那些)。 */
|
|
|
|
|
|
$skipN = 0;
|
|
|
|
|
|
foreach ($skipByUid as $uid => $dates) {
|
|
|
|
|
|
$eid = self::eventIdByUid($calendarId, (string)$uid);
|
|
|
|
|
|
if ($eid <= 0) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$clean = array_values(array_unique(array_filter(array_map(
|
|
|
|
|
|
static fn($d) => substr(trim((string)$d), 0, 10),
|
|
|
|
|
|
(array)$dates
|
|
|
|
|
|
), static fn($d) => (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $d))));
|
|
|
|
|
|
if (!$clean) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 覆盖行占掉的那些天:它们必须继续被跳过
|
|
|
|
|
|
foreach (self::overridesOf($eid) as $o) {
|
|
|
|
|
|
$od = self::ridDay((string)($o['recurrence_id'] ?? ''));
|
|
|
|
|
|
if ($od !== '' && !in_array($od, $clean, true)) {
|
|
|
|
|
|
$clean[] = $od;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self::setSkips($eid, $clean);
|
|
|
|
|
|
$skipN += count($clean);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'added' => $added,
|
|
|
|
|
|
'updated' => $updated,
|
|
|
|
|
|
'overrides' => $ovrN,
|
|
|
|
|
|
'skips' => $skipN,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 导入**一条** ics 里的 VEVENT。
|
2026-10-03 08:18:05 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 判重用 UID + 日历本:同一本日历里 UID 已存在就**更新**而不是再插一条 ——
|
|
|
|
|
|
* 否则同一个 .ics 导入两次,日历里就是两份一模一样的日程(用户最常见的操作
|
|
|
|
|
|
* "导一次没看清,再导一次"会立刻翻车)。
|
|
|
|
|
|
*
|
2026-10-21 13:22:37 +08:00
|
|
|
|
* 🔴 v71:带 `RECURRENCE-ID` 的 VEVENT **不是**一条独立日程,而是"系列里某一次"
|
|
|
|
|
|
* 的替换 ⇒ 必须落进 `calendar_event_overrides`,**绝不能** `updateEvent()`
|
|
|
|
|
|
* 去改主事件(那会把主事件的 RRULE 和时刻一起改掉)。
|
|
|
|
|
|
* ⚠️ 只有这里返回 `'override'` —— 网页导入请走 `importIcs()`,
|
|
|
|
|
|
* 它负责顺序(主事件先落、覆盖行后挂),单独调本函数会漏掉顺序。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return string 'added' | 'updated' | 'override'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public static function importEvent(int $calendarId, array $v, int $createdBy): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$uid = (string)($v['uid'] ?? '');
|
2026-10-21 13:22:37 +08:00
|
|
|
|
$rid = trim((string)($v['recurrence_id'] ?? ''));
|
|
|
|
|
|
|
|
|
|
|
|
if ($rid !== '') {
|
|
|
|
|
|
$master = self::eventIdByUid($calendarId, $uid);
|
|
|
|
|
|
if ($master > 0) {
|
|
|
|
|
|
self::upsertOverride($master, $v, $rid);
|
|
|
|
|
|
return 'override';
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 只有覆盖行、没有主事件的残缺文件(实测「重要/健康」有 4 组、
|
|
|
|
|
|
「工作学习」有 2 组):退化成一条独立日程,但 **UID 要换掉** ——
|
|
|
|
|
|
沿用原 UID 的话,主事件日后真导进来会被这条撞成"改掉主事件"。
|
|
|
|
|
|
⚠️ 换过之后**必须再按新 UID 找一次**:否则同一份文件导第二次时,
|
|
|
|
|
|
按原 UID 永远找不到上一轮那条孤儿 ⇒ 每次都再插一条(实测 +4 / +2)。 */
|
|
|
|
|
|
$uid = self::orphanUid($uid, $rid);
|
|
|
|
|
|
$v['uid'] = $uid;
|
|
|
|
|
|
unset($v['recurrence_id']);
|
|
|
|
|
|
$again = self::eventIdByUid($calendarId, $uid);
|
|
|
|
|
|
if ($again > 0) {
|
|
|
|
|
|
self::updateEvent($again, $v);
|
|
|
|
|
|
return 'updated';
|
|
|
|
|
|
}
|
|
|
|
|
|
} elseif ($uid !== '') {
|
|
|
|
|
|
$old = self::eventIdByUid($calendarId, $uid);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if ($old > 0) {
|
|
|
|
|
|
self::updateEvent($old, $v);
|
|
|
|
|
|
return 'updated';
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$uid = self::newUid();
|
|
|
|
|
|
}
|
|
|
|
|
|
$db->prepare(
|
|
|
|
|
|
'INSERT INTO calendar_events
|
|
|
|
|
|
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
|
2026-10-15 20:08:05 +08:00
|
|
|
|
reminder_minutes, status, transp, rrule, note, created_by, extra)
|
|
|
|
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
)->execute([
|
|
|
|
|
|
$calendarId,
|
|
|
|
|
|
$uid,
|
|
|
|
|
|
mb_substr(trim((string)($v['title'] ?? '')), 0, 200, 'UTF-8'),
|
|
|
|
|
|
self::textOrNull((string)($v['location'] ?? ''), 255),
|
|
|
|
|
|
$v['starts_at'],
|
|
|
|
|
|
$v['ends_at'] ?: null,
|
|
|
|
|
|
!empty($v['all_day']) ? 1 : 0,
|
|
|
|
|
|
self::reminderMinutes($v['reminder_minutes'] ?? null),
|
|
|
|
|
|
self::statusOf($v['status'] ?? null),
|
|
|
|
|
|
0,
|
|
|
|
|
|
self::textOrNull((string)($v['rrule'] ?? ''), 255),
|
|
|
|
|
|
self::textOrNull((string)($v['note'] ?? '')),
|
|
|
|
|
|
$createdBy ?: null,
|
2026-10-15 20:08:05 +08:00
|
|
|
|
self::textOrNull((string)($v['extra'] ?? '')),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
]);
|
|
|
|
|
|
return 'added';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */
|
2026-10-06 03:14:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 提醒值 → 存的字符串(v24 第 20 批 第 8 条:**可以多选**)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「日程的提醒,是可以多选的,就是说,一个日程,
|
|
|
|
|
|
* 我可以选择让它5分钟提醒,10分钟提醒等等。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 存的形态:**逗号分隔的分钟数升序**,如 `5,15,60`。
|
|
|
|
|
|
* 为什么不动表结构(不加新列、不改成 JSON):
|
|
|
|
|
|
* · 那一列是 `INT`,存 `"5,15,60"` 时 MySQL 会做隐式转换 —— 有风险,所以
|
|
|
|
|
|
* 把列改成 VARCHAR(见 Schema 的升级段),但**老值一个都不丢**(数字串照样读)。
|
|
|
|
|
|
* · 逗号串人眼可读、`WHERE reminder_minutes LIKE …` 也还能粗筛,
|
|
|
|
|
|
* 而 JSON 存进去就再也说不清"这列以前存过什么"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param mixed $v 表单来的(数组 或 逗号串 或 单个数字)
|
|
|
|
|
|
* @return string|null 合法时返回规范化串,一个都没有时返回 null(=不提醒)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function reminderMinutes($v): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
$mins = self::reminderList($v);
|
|
|
|
|
|
return $mins ? implode(',', $mins) : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-09 07:25:08 +08:00
|
|
|
|
* v32 第 3 条:**到点的提醒**(浏览器通知用)。
|
2026-10-06 03:14:18 +08:00
|
|
|
|
*
|
2026-10-09 07:25:08 +08:00
|
|
|
|
* 用户原话:「还有日历中日程到期提醒时,比如一个日程,设置了10点开始,
|
|
|
|
|
|
* 提前十分钟提醒,则需要在9:50时浏览器中发提醒通知。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $from 窗口起点(含),'Y-m-d H:i:s'
|
|
|
|
|
|
* @param string $to 窗口终点(含)
|
|
|
|
|
|
* @return array<int,array{id:int,title:string,starts_at:string,at:string,cal:string,href:string}>
|
|
|
|
|
|
* `at` = 该触发提醒的时刻(starts_at - 提前分钟数)
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么**只按 starts_at 查**、不做重复日程展开:
|
|
|
|
|
|
* 重复日程(RRULE)会在每周三都"提醒一次",但那需要把系列展开到具体某天,
|
|
|
|
|
|
* 而**同一次发生**在任何一次轮询里都必须算出**同一个提醒时刻**(否则会重复弹)。
|
|
|
|
|
|
* 窗口很短(前端每次只看"现在前后几分钟"),而"下次发生"恰好落在这几分钟里的
|
|
|
|
|
|
* 概率极低、影响也只是一个提醒来得晚一点。
|
|
|
|
|
|
* ⇒ 这一版覆盖"用户举的那个例子"(单次日程 10:00 提前 10 分钟 → 9:50 弹),
|
|
|
|
|
|
* 重复日程的提醒留待需要时再展开(不假装支持)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 多档提醒(`reminder_minutes` 是 `'5,15'`)会返回**多条**(每档一条):
|
|
|
|
|
|
* 用户既然设了两档,就该被提醒两次 —— 靠前端按 `id@at` 去重,不会重复弹同一条。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function remindersBetween(int $familyId, int $personId,
|
|
|
|
|
|
string $from, string $to): array
|
|
|
|
|
|
{
|
|
|
|
|
|
// 我可见的日历本 = 我建的 + 共享给我的(与页面口径一致,别另发明一套)
|
|
|
|
|
|
$books = [];
|
|
|
|
|
|
foreach (self::mine($familyId, $personId) as $c) {
|
|
|
|
|
|
if (in_array((string)$c['kind'], ['movie', 'trip'], true)) {
|
|
|
|
|
|
continue; // 派生本没有 calendar_events 行,且它们本就没有提醒
|
|
|
|
|
|
}
|
|
|
|
|
|
$books[] = (int)$c['id'];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$books) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
$in = implode(',', array_fill(0, count($books), '?'));
|
|
|
|
|
|
// ⚠️ 范围要放宽 `最大档位` 分钟:提醒时刻 = 开始 - N 分钟,
|
|
|
|
|
|
// 所以"开始时间"可能在窗口**之后**(10:00 的事件,9:50 时窗口是过去)。
|
|
|
|
|
|
// 取 REMINDERS 里最大档位当余量,一次查全。
|
|
|
|
|
|
$maxLead = max(array_keys(self::REMINDERS));
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
"SELECT e.id, e.title, e.starts_at, e.reminder_minutes, e.all_day,
|
|
|
|
|
|
c.name AS calname
|
|
|
|
|
|
FROM calendar_events e
|
|
|
|
|
|
JOIN calendars c ON c.id = e.calendar_id
|
|
|
|
|
|
WHERE e.calendar_id IN ($in)
|
|
|
|
|
|
AND e.reminder_minutes IS NOT NULL AND e.reminder_minutes <> ''
|
|
|
|
|
|
AND e.status <> 'cancelled'
|
|
|
|
|
|
AND e.starts_at >= ? AND e.starts_at <= ?
|
|
|
|
|
|
ORDER BY e.starts_at"
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute(array_merge($books,
|
|
|
|
|
|
[date('Y-m-d H:i:s', strtotime($from) - $maxLead * 60),
|
|
|
|
|
|
date('Y-m-d H:i:s', strtotime($to) + $maxLead * 60)]));
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
$fTs = strtotime($from);
|
|
|
|
|
|
$tTs = strtotime($to);
|
|
|
|
|
|
foreach ($st->fetchAll() as $e) {
|
|
|
|
|
|
if (!empty($e['all_day'])) {
|
|
|
|
|
|
continue; // 全天日程没有"开始时点",提前 N 分钟无从计算
|
|
|
|
|
|
}
|
|
|
|
|
|
$sTs = strtotime((string)$e['starts_at']);
|
|
|
|
|
|
if ($sTs === false) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach (self::reminderList($e['reminder_minutes'] ?? null) as $lead) {
|
|
|
|
|
|
$atTs = $sTs - $lead * 60;
|
|
|
|
|
|
if ($atTs < $fTs || $atTs > $tTs) {
|
|
|
|
|
|
continue; // 这一档的提醒时刻不在本窗口
|
|
|
|
|
|
}
|
|
|
|
|
|
$out[] = [
|
|
|
|
|
|
'id' => (int)$e['id'],
|
|
|
|
|
|
'title' => (string)$e['title'],
|
|
|
|
|
|
'starts_at' => (string)$e['starts_at'],
|
|
|
|
|
|
'at' => date('Y-m-d H:i:s', $atTs),
|
|
|
|
|
|
'lead' => (int)$lead,
|
|
|
|
|
|
'cal' => (string)$e['calname'],
|
|
|
|
|
|
'href' => url('calendar/event', ['id' => (int)$e['id']]),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
usort($out, static fn(array $a, array $b): int => strcmp($a['at'], $b['at']));
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。
|
2026-10-06 03:14:18 +08:00
|
|
|
|
* ⚠️ 白名单:只认 `self::REMINDERS` 里那几个档位。手搓请求塞 999999
|
|
|
|
|
|
* 不能变成"提前 999999 分钟提醒"(那会导出一个客户端算不明白的 VALARM)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param mixed $v 数组 / "5,15" / "5" / null
|
|
|
|
|
|
* @return int[]
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function reminderList($v): array
|
2026-10-03 08:18:05 +08:00
|
|
|
|
{
|
|
|
|
|
|
if ($v === null || $v === '' || $v === false) {
|
2026-10-06 03:14:18 +08:00
|
|
|
|
return [];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
2026-10-06 03:14:18 +08:00
|
|
|
|
if (is_array($v)) {
|
|
|
|
|
|
$raw = $v;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$raw = explode(',', (string)$v);
|
|
|
|
|
|
}
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach ($raw as $one) {
|
|
|
|
|
|
$one = trim((string)$one);
|
|
|
|
|
|
if ($one === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$n = (int)$one;
|
|
|
|
|
|
if (isset(self::REMINDERS[$n]) && !in_array($n, $out, true)) {
|
|
|
|
|
|
$out[] = $n;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
sort($out);
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 一个日程的提醒 → 人话('提前 5 分钟、提前 1 小时');没有则空串 */
|
|
|
|
|
|
public static function reminderWords($v): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$words = [];
|
|
|
|
|
|
foreach (self::reminderList($v) as $n) {
|
|
|
|
|
|
$words[] = (string)self::REMINDERS[$n];
|
|
|
|
|
|
}
|
|
|
|
|
|
return implode('、', $words);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 老调用点还在用「单值」语义时(如 _event_fields 回显)取一个"是否含某一档" */
|
|
|
|
|
|
public static function hasReminder($v, int $mins): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
return in_array($mins, self::reminderList($v), true);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static function statusOf($v): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$v = (string)$v;
|
|
|
|
|
|
return in_array($v, ['confirmed', 'tentative', 'cancelled'], true) ? $v : 'confirmed';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 01:20:40 +08:00
|
|
|
|
/* 🔴 v47(用户 2026-10-13):「日程的那个状态,什么确定、待定、取消,
|
|
|
|
|
|
我觉得没有什么意义,把它给删掉吧,不要了。」
|
|
|
|
|
|
⇒ `statusWord()` **删除**(浮层自 v44 起就已不显示状态,它最后也没有消费者了)。
|
|
|
|
|
|
⚠️ `statusOf()` **保留** —— 它在 Calendar 的三处写入路径(新建/更新/派生本)里
|
|
|
|
|
|
仍用于把外部传入的 status 规范化成合法枚举;列与导出契约都不动,
|
|
|
|
|
|
等"整体清理"那一轮再一起收(用户明确说了会做)。
|
2026-10-12 00:24:20 +08:00
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 某个月(或某段日期)里**全部**日程:
|
|
|
|
|
|
* 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip)
|
|
|
|
|
|
* + 有截止时间的任务(tasks.due_at)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 统一成一种形状返回,模板不必分辨来源:
|
|
|
|
|
|
* ['cal'=>日历本id, 'color'=>本子颜色, 'title', 'date'=>'Y-m-d', 'time'=>'HH:MM'|'',
|
|
|
|
|
|
* 'all_day'=>bool, 'kind'=>'custom|movie|trip|task', 'ref_id'=>宿主id, 'href'=>点进去的地址]
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 观影/旅行只显示**本家庭**的记录(与观影模块同一个口径:能登录就能看)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function month(int $familyId, array $calendars, int $personId, string $from, string $to): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
$byKind = [];
|
|
|
|
|
|
foreach ($calendars as $c) {
|
|
|
|
|
|
$byKind[(string)$c['kind']][] = $c;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 自定义日程 ----
|
|
|
|
|
|
$ids = [];
|
|
|
|
|
|
foreach ($calendars as $c) {
|
|
|
|
|
|
if ((string)$c['kind'] === 'custom') {
|
|
|
|
|
|
$ids[] = (int)$c['id'];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($ids) {
|
|
|
|
|
|
$in = implode(',', array_fill(0, count($ids), '?'));
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// ⚠️ 取数范围要**比 [from,to] 放宽**:重复日程的种子(starts_at)可能远早于
|
|
|
|
|
|
// 本月("从去年起每周三"),按 starts_at 直接卡范围会把整个系列漏掉。
|
|
|
|
|
|
// 这里取到 `to` 为止、往前不设限,展开阶段再按 [from,to] 裁。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
"SELECT * FROM calendar_events
|
2026-10-03 08:18:05 +08:00
|
|
|
|
WHERE calendar_id IN ($in)
|
2026-10-03 11:30:48 +08:00
|
|
|
|
AND DATE(starts_at) <= ?
|
2026-10-03 08:18:05 +08:00
|
|
|
|
ORDER BY starts_at"
|
2026-10-02 09:28:07 +08:00
|
|
|
|
);
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$st->execute(array_merge($ids, [$to]));
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$calById = [];
|
|
|
|
|
|
foreach ($calendars as $c) {
|
|
|
|
|
|
$calById[(int)$c['id']] = $c;
|
|
|
|
|
|
}
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$rows = $st->fetchAll();
|
|
|
|
|
|
|
|
|
|
|
|
// 一次把这批日程的「例外日期」取出来(N+1 会在重复日程多时明显变慢)
|
|
|
|
|
|
$skips = [];
|
2026-10-15 20:08:05 +08:00
|
|
|
|
$ovrs = [];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
if ($rows) {
|
|
|
|
|
|
$eids = array_map('intval', array_column($rows, 'id'));
|
|
|
|
|
|
$in2 = implode(',', array_fill(0, count($eids), '?'));
|
|
|
|
|
|
$sk = Database::get()->prepare(
|
|
|
|
|
|
"SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in2)"
|
|
|
|
|
|
);
|
|
|
|
|
|
$sk->execute($eids);
|
|
|
|
|
|
foreach ($sk->fetchAll() as $r) {
|
|
|
|
|
|
$skips[(int)$r['event_id']][(string)$r['skip_date']] = true;
|
|
|
|
|
|
}
|
2026-10-15 20:08:05 +08:00
|
|
|
|
/* 🔴 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($rows as $e) {
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$c = $calById[(int)$e['calendar_id']] ?? null;
|
2026-10-14 19:05:13 +08:00
|
|
|
|
/* 🔴 v49(用户 2026-10-14 第 2 条):条目详情浮层要按来源给按钮 ——
|
|
|
|
|
|
自定义日程给「详情/编辑/删除」,别的模块派生的只给「详情」。
|
|
|
|
|
|
「删除」还要求**我在这本日历上可写** ⇒ 这里就把结论算好带出去
|
|
|
|
|
|
(`fb_entry_json()` 读 `_can_write`)。模板里再逐条判一次会重复三处。 */
|
|
|
|
|
|
$canW = $c !== null && self::canWrite($c, $personId);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$sd = substr((string)$e['starts_at'], 0, 10);
|
|
|
|
|
|
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$tm = substr((string)$e['starts_at'], 11, 5);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户 2026-10-03 第 5 条:周/日视图里色块的**高度要按日程时长**画
|
|
|
|
|
|
// (从开始时间到结束时间),所以结束时刻必须带出去。
|
|
|
|
|
|
$etm = $e['ends_at'] ? substr((string)$e['ends_at'], 11, 5) : '';
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$rr = (string)($e['rrule'] ?? '');
|
|
|
|
|
|
// 用户 2026-10-03 第 5/6 条:重复日程按规则展开成若干次发生;
|
|
|
|
|
|
// 不重复的走同一条路(occurrences 会返回它自己那一天)。
|
|
|
|
|
|
$days = Repeat::occurrences(
|
|
|
|
|
|
(string)$e['starts_at'], $rr, $from, $to,
|
|
|
|
|
|
$skips[(int)$e['id']] ?? []
|
|
|
|
|
|
);
|
2026-10-15 20:08:05 +08:00
|
|
|
|
/* 🔴 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-03 11:30:48 +08:00
|
|
|
|
foreach ($days as $day) {
|
2026-10-15 20:08:05 +08:00
|
|
|
|
/* 这一次被改到别的时间了吗?有覆盖行就按覆盖行画。
|
|
|
|
|
|
⚠️ 三个"日期"要分清,混用会画出"整串都堆在系列第一天"的怪相:
|
|
|
|
|
|
· `$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) : '';
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
|
2026-10-15 20:08:05 +08:00
|
|
|
|
$span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400);
|
2026-10-30 23:43:10 +08:00
|
|
|
|
/* 🔴 v88(工单 #35):`$first` 原来是"窗口内遍历的第一个",**这是错的**。
|
|
|
|
|
|
日视图只查一天时,如果那天正好是跨天日程的**中间天**,
|
|
|
|
|
|
它就成了 `$first` ⇒ 被当作"开始日"处理(但 `time` 是空的),
|
|
|
|
|
|
于是落到全天行;同理 `$isLast` 也会把中间天当结束日。
|
|
|
|
|
|
⇒ 一律用**真实日期**跟 `$base`/`$base+span` 比较,不看遍历顺序。
|
|
|
|
|
|
|
|
|
|
|
|
另外把「这条跨度」本身也带出去(`span_days` / `seg`),
|
|
|
|
|
|
让端上/网页端不必再靠"同一次发生铺了几条"去猜 ——
|
|
|
|
|
|
那个推断在窗口被裁剪时(日视图)必然不成立。 */
|
|
|
|
|
|
$baseEnd = date('Y-m-d', strtotime($base . ' +' . $span . ' day'));
|
|
|
|
|
|
foreach (self::eachDay($base, $baseEnd, $from, $to) as $d2) {
|
|
|
|
|
|
$isFirst = ($d2 === $base);
|
|
|
|
|
|
$isLast = ($d2 === $baseEnd);
|
|
|
|
|
|
// 段形态:整天(span=0)就是 only
|
|
|
|
|
|
if ($span === 0) {
|
|
|
|
|
|
$segKind = 'only';
|
|
|
|
|
|
} elseif ($isFirst) {
|
|
|
|
|
|
$segKind = 'start';
|
|
|
|
|
|
} elseif ($isLast) {
|
|
|
|
|
|
$segKind = 'end';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$segKind = 'mid';
|
|
|
|
|
|
}
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => (int)$e['calendar_id'],
|
|
|
|
|
|
'color' => (string)($c['color'] ?? '#4A7C82'),
|
|
|
|
|
|
'calname' => (string)($c['name'] ?? ''),
|
2026-10-15 20:08:05 +08:00
|
|
|
|
'title' => (string)$vd['title'],
|
2026-10-03 11:30:48 +08:00
|
|
|
|
'date' => $d2,
|
|
|
|
|
|
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
|
2026-10-30 23:43:10 +08:00
|
|
|
|
'time' => ($isFirst && empty($vd['all_day'])) ? $vtm : '',
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
|
2026-10-15 20:08:05 +08:00
|
|
|
|
'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '',
|
|
|
|
|
|
'all_day' => !empty($vd['all_day']),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
'kind' => 'custom',
|
|
|
|
|
|
'ref_id' => (int)$e['id'],
|
2026-10-30 23:43:10 +08:00
|
|
|
|
/* 跨度信息(v88):网页端据此把一条画成多列分段;
|
|
|
|
|
|
日视图下窗口只给一天也不会判错。
|
|
|
|
|
|
`span_days` = 跨了几天(0 表示当天来回)。 */
|
|
|
|
|
|
'span_days' => $span,
|
|
|
|
|
|
'seg' => $segKind,
|
2026-10-12 00:24:20 +08:00
|
|
|
|
// 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息",
|
|
|
|
|
|
// 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。
|
2026-10-15 20:08:05 +08:00
|
|
|
|
'location' => (string)($vd['location'] ?? ''),
|
|
|
|
|
|
'note' => (string)($vd['note'] ?? ''),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
|
|
|
|
|
|
'occ' => $day,
|
|
|
|
|
|
'repeat' => $rr,
|
2026-10-14 19:05:13 +08:00
|
|
|
|
// v49:这一条我能不能改/删(浮层据此决定显不显「编辑/删除」)
|
|
|
|
|
|
'_can_write' => $canW,
|
2026-10-03 11:30:48 +08:00
|
|
|
|
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 观影(events kind=movie)----
|
|
|
|
|
|
if (isset($byKind['movie'])) {
|
|
|
|
|
|
$c = $byKind['movie'][0];
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
"SELECT id, title, starts_at, ends_at FROM events
|
|
|
|
|
|
WHERE family_id = ? AND kind = 'movie' AND starts_at IS NOT NULL
|
|
|
|
|
|
AND DATE(starts_at) BETWEEN ? AND ?
|
|
|
|
|
|
ORDER BY starts_at"
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$familyId, $from, $to]);
|
|
|
|
|
|
foreach ($st->fetchAll() as $e) {
|
|
|
|
|
|
$day = substr((string)$e['starts_at'], 0, 10);
|
|
|
|
|
|
$t = substr((string)$e['starts_at'], 11, 5);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户 2026-10-03 第 5 条:周/日视图色块高度按真实时长画 ——
|
|
|
|
|
|
// 一部电影两小时,在时间网格里就该占两小时(ends_at 库里本来就有)。
|
|
|
|
|
|
$et = $e['ends_at'] ? substr((string)$e['ends_at'], 11, 5) : '';
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => (int)$c['id'],
|
|
|
|
|
|
'color' => (string)$c['color'],
|
|
|
|
|
|
'calname' => (string)$c['name'],
|
|
|
|
|
|
'title' => '🎬 ' . (string)$e['title'],
|
|
|
|
|
|
'date' => $day,
|
|
|
|
|
|
'time' => $t === '00:00' ? '' : $t,
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'end_time'=> $t === '00:00' ? '' : ($et === '00:00' ? '' : $et),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'all_day' => $t === '00:00',
|
|
|
|
|
|
'kind' => 'movie',
|
|
|
|
|
|
'ref_id' => (int)$e['id'],
|
|
|
|
|
|
'href' => url('movies/show', ['id' => (int)$e['id']]),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ---- 旅程(trip_nodes,不是 events)----
|
|
|
|
|
|
// ⚠️ v24(用户 2026-10-02 第 2 条):日历里出的是**行程节点**,不是「这趟旅程」本身。
|
|
|
|
|
|
// 原来把整趟旅程摊成一条跨天全天日程,于是日历上一整周都被"云南之行"占满,
|
|
|
|
|
|
// 真正要看的"几点出发 / 几点到景点"全都看不见 —— 旅程是个容器,节点才是日程。
|
|
|
|
|
|
// 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
if (isset($byKind['trip'])) {
|
|
|
|
|
|
$c = $byKind['trip'][0];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 用户 2026-10-03 第 4 条:不公开的旅程,节点也不上日历。
|
|
|
|
|
|
// COALESCE 兜底:缺 trip_details 行时按"公开"处理(老数据不会凭空消失)。
|
|
|
|
|
|
// ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串,
|
|
|
|
|
|
// 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉,
|
|
|
|
|
|
// 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
|
2026-10-04 20:57:40 +08:00
|
|
|
|
// v24 第 13 批(用户 2026-10-05):旅程重构后,**按 stage 过滤**。
|
|
|
|
|
|
// · plan → 上日程(用**计划**时间)
|
|
|
|
|
|
// · actual → 上日程(时间已经是实际时间,因为"修正"会把实际值写回行程)
|
|
|
|
|
|
// · skipped → **不上日程**(用户:"就权当没有这个日程")
|
|
|
|
|
|
// ⚠️ 不能再按 status<>'cancelled' 过滤 —— 那是旧双轨的口径;
|
|
|
|
|
|
// 现在"未成行"是 stage='skipped',而 status 可能仍是 planned。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$st = Database::get()->prepare(
|
2026-10-03 08:18:05 +08:00
|
|
|
|
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
|
2026-10-04 20:57:40 +08:00
|
|
|
|
n.node_type, n.status AS node_status, n.stage AS node_stage,
|
2026-11-05 16:12:06 +08:00
|
|
|
|
n.travel_mode,
|
|
|
|
|
|
n.from_place, n.to_place,
|
|
|
|
|
|
n.from_lng, n.from_lat, n.to_lng, n.to_lat,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
e.id AS event_id, e.title AS trip_title
|
|
|
|
|
|
FROM trip_nodes n
|
|
|
|
|
|
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
|
2026-10-03 11:30:48 +08:00
|
|
|
|
LEFT JOIN trip_details d ON d.event_id = e.id
|
2026-10-03 08:18:05 +08:00
|
|
|
|
WHERE n.family_id = ? AND n.starts_at IS NOT NULL
|
2026-10-04 20:57:40 +08:00
|
|
|
|
AND n.stage IN ('plan','actual')
|
2026-10-03 11:30:48 +08:00
|
|
|
|
AND COALESCE(d.is_public, 1) = 1
|
2026-10-03 08:18:05 +08:00
|
|
|
|
AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ?
|
|
|
|
|
|
ORDER BY n.starts_at"
|
2026-10-02 09:28:07 +08:00
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$familyId, $to, $from]);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
foreach ($st->fetchAll() as $n) {
|
|
|
|
|
|
$start = substr((string)$n['starts_at'], 0, 10);
|
|
|
|
|
|
$end = substr((string)($n['ends_at'] ?: $n['starts_at']), 0, 10);
|
|
|
|
|
|
// 节点跨天时只在**第一天**显示时间(后面的天是"还在进行",不是"又开始了")
|
|
|
|
|
|
$tm = substr((string)$n['starts_at'], 11, 5);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户 2026-10-03 第 5 条:色块高度按时长画 —— 节点的结束时刻也要带出去
|
|
|
|
|
|
$etm = $n['ends_at'] ? substr((string)$n['ends_at'], 11, 5) : '';
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$first = true;
|
|
|
|
|
|
foreach (self::eachDay($start, $end, $from, $to) as $day) {
|
2026-10-03 15:01:38 +08:00
|
|
|
|
$isLast = ($day === $end);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => (int)$c['id'],
|
|
|
|
|
|
'color' => (string)$c['color'],
|
|
|
|
|
|
'calname' => (string)$c['name'],
|
2026-11-05 16:12:06 +08:00
|
|
|
|
/* 🔴 #67(用户 2026-11-05,两条):
|
|
|
|
|
|
① 「这个图标应该来自里面具体的交通工具,也就是说,如果是火车,
|
|
|
|
|
|
就应该显示火车。」
|
|
|
|
|
|
⇒ 原来只按 `node_type` 取,`typeIcon('move')` 恒为 🚗 ——
|
|
|
|
|
|
「赶路」永远显示汽车,哪怕实际坐的是火车。
|
|
|
|
|
|
改用 `leadIcon()`(出行方式优先、节点类型兜底),
|
|
|
|
|
|
它正是旅行详情页行程卡左侧用的同一个方法。
|
|
|
|
|
|
② 「后面这个对号是什么意思?…我感觉没必要,直接把这个对号去掉。」
|
|
|
|
|
|
⇒ 删掉原来插在标题里的 `' ✓ '`。 */
|
|
|
|
|
|
'title' => Trip::leadIcon((string)$n['node_type'],
|
|
|
|
|
|
(string)($n['travel_mode'] ?? ''),
|
|
|
|
|
|
$familyId)
|
|
|
|
|
|
. ' ' . (string)$n['node_title'],
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'date' => $day,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
|
|
|
|
|
|
'end_time'=> (!$first || $isLast) && $tm !== '00:00' && $etm !== '' ? $etm : '',
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'all_day' => $first && $tm === '00:00',
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'kind' => 'trip',
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'ref_id' => (int)$n['node_id'],
|
2026-11-05 16:12:06 +08:00
|
|
|
|
/* 🔴 #67(用户:「如果行程里面有地址,也就是有出发地和目的地,
|
|
|
|
|
|
应该像行程里显示的那样,把它显示在下面,这样一点就可以导航。」)
|
|
|
|
|
|
⇒ 原来这一段的 SQL **完全没查** from_* / to_*,
|
|
|
|
|
|
日历里只剩一个标题,用户看不出这段是从哪到哪。
|
|
|
|
|
|
带坐标是因为导航要用:没有 lat/lng 只能搜地名,
|
|
|
|
|
|
同名的地点会搜错城市(这个坑在旅程模块已经踩过一次)。 */
|
|
|
|
|
|
'from_place' => (string)($n['from_place'] ?? ''),
|
|
|
|
|
|
'to_place' => (string)($n['to_place'] ?? ''),
|
|
|
|
|
|
'from_lng' => (string)($n['from_lng'] ?? ''),
|
|
|
|
|
|
'from_lat' => (string)($n['from_lat'] ?? ''),
|
|
|
|
|
|
'to_lng' => (string)($n['to_lng'] ?? ''),
|
|
|
|
|
|
'to_lat' => (string)($n['to_lat'] ?? ''),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'href' => url('trips/show', ['id' => (int)$n['event_id']])
|
|
|
|
|
|
. '#node-' . (int)$n['node_id'],
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$first = false;
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 10:46:50 +08:00
|
|
|
|
// ---- 农历(v24 第 10 批 #4)----
|
|
|
|
|
|
// 用户原话:「日程中,增加两个不可删除的日历,一个是农历…」
|
|
|
|
|
|
// 每一天都出一条(标题 = 农历月日),农历节日另标一个名字。
|
|
|
|
|
|
// 它是**派生只读**的:不落 calendar_events,删不掉,也不能往里加日程。
|
|
|
|
|
|
if (isset($byKind['lunar'])) {
|
|
|
|
|
|
$c = $byKind['lunar'][0];
|
|
|
|
|
|
foreach (Holiday::lunarRange($from, $to) as $l) {
|
|
|
|
|
|
$fest = (string)($l['fest'] ?? '');
|
|
|
|
|
|
$title = (string)$l['title'] . ($fest !== '' ? ' · ' . $fest : '');
|
|
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => (int)$c['id'],
|
|
|
|
|
|
'color' => (string)$c['color'],
|
|
|
|
|
|
'calname' => (string)$c['name'],
|
|
|
|
|
|
'title' => $title,
|
|
|
|
|
|
'date' => (string)$l['date'],
|
|
|
|
|
|
'time' => '',
|
|
|
|
|
|
'all_day' => true,
|
|
|
|
|
|
'kind' => 'lunar',
|
|
|
|
|
|
'ref_id' => 0,
|
|
|
|
|
|
// 农历本没有可点进去的详情;给一个月视图锚点即可(点它=看那天)
|
|
|
|
|
|
'href' => url('calendar', ['view' => 'day', 'd' => (string)$l['date']]),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 节假日:放假 + 调休(v24 第 10 批 #4)----
|
|
|
|
|
|
// 用户原话:「一个是法定假日的休息和调休信息,法定假日这个,需要有自动更新功能…
|
|
|
|
|
|
// 不是让你更新,而是让系统,代码自动更新。」
|
|
|
|
|
|
// 自动更新在 App\Holiday 里:内置规则保证"永远有",官方数据一到就覆盖成官方口径。
|
|
|
|
|
|
if (isset($byKind['holiday'])) {
|
|
|
|
|
|
$c = $byKind['holiday'][0];
|
|
|
|
|
|
// [from,to] 可能跨年(12 月末的月视图会看到次年 1 月初),两年都要取
|
|
|
|
|
|
$years = [(int)substr($from, 0, 4), (int)substr($to, 0, 4)];
|
|
|
|
|
|
foreach (array_unique($years) as $y) {
|
|
|
|
|
|
foreach (Holiday::range($y, $from, $to) as $h) {
|
|
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => (int)$c['id'],
|
|
|
|
|
|
'color' => (string)$c['color'],
|
|
|
|
|
|
'calname' => (string)$c['name'],
|
|
|
|
|
|
'title' => (string)$h['title'],
|
|
|
|
|
|
'date' => (string)$h['date'],
|
|
|
|
|
|
'time' => '',
|
|
|
|
|
|
'all_day' => true,
|
|
|
|
|
|
'kind' => 'holiday',
|
|
|
|
|
|
'ref_id' => 0,
|
|
|
|
|
|
'href' => url('calendar', ['view' => 'day', 'd' => (string)$h['date']]),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 19:43:00 +08:00
|
|
|
|
// ---- 传统节日(v24 第 12 批 #4)----
|
|
|
|
|
|
// 用户原话:「增加系统日历本,一个是传统节日,用来存放中国的传统节日,
|
|
|
|
|
|
// 例如中秋、春节等。」
|
|
|
|
|
|
// 全部由农历推导(Holiday::traditionalRange),断网也永远有。
|
|
|
|
|
|
if (isset($byKind['traditional'])) {
|
|
|
|
|
|
$c = $byKind['traditional'][0];
|
|
|
|
|
|
foreach (Holiday::traditionalRange($from, $to) as $f) {
|
|
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => (int)$c['id'],
|
|
|
|
|
|
'color' => (string)$c['color'],
|
|
|
|
|
|
'calname' => (string)$c['name'],
|
|
|
|
|
|
'title' => (string)$f['title'],
|
|
|
|
|
|
'date' => (string)$f['date'],
|
|
|
|
|
|
'time' => '',
|
|
|
|
|
|
'all_day' => true,
|
|
|
|
|
|
'kind' => 'traditional',
|
|
|
|
|
|
'ref_id' => 0,
|
|
|
|
|
|
'href' => url('calendar', ['view' => 'day', 'd' => (string)$f['date']]),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 二十四节气(v24 第 12 批 #4)----
|
|
|
|
|
|
// 用户原话:「再增加一个系统日历本,用来存放24节气。」
|
|
|
|
|
|
if (isset($byKind['solarterm'])) {
|
|
|
|
|
|
$c = $byKind['solarterm'][0];
|
|
|
|
|
|
foreach (Holiday::solarTermRange($from, $to) as $s) {
|
|
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => (int)$c['id'],
|
|
|
|
|
|
'color' => (string)$c['color'],
|
|
|
|
|
|
'calname' => (string)$c['name'],
|
|
|
|
|
|
'title' => (string)$s['title'],
|
|
|
|
|
|
'date' => (string)$s['date'],
|
|
|
|
|
|
'time' => '',
|
|
|
|
|
|
'all_day' => true,
|
|
|
|
|
|
'kind' => 'solarterm',
|
|
|
|
|
|
'ref_id' => 0,
|
|
|
|
|
|
'href' => url('calendar', ['view' => 'day', 'd' => (string)$s['date']]),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ---- 有截止时间的任务 ----
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
"SELECT t.id, t.title, t.due_at, t.status
|
|
|
|
|
|
FROM tasks t
|
|
|
|
|
|
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
|
|
|
|
|
|
WHERE t.family_id = ? AND t.due_at IS NOT NULL AND t.status <> 'done'
|
|
|
|
|
|
AND DATE(t.due_at) BETWEEN ? AND ?
|
|
|
|
|
|
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
|
|
|
|
|
|
ORDER BY t.due_at"
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$personId, $familyId, $from, $to, $personId, $personId]);
|
|
|
|
|
|
foreach ($st->fetchAll() as $t) {
|
|
|
|
|
|
$day = substr((string)$t['due_at'], 0, 10);
|
|
|
|
|
|
$tm = substr((string)$t['due_at'], 11, 5);
|
|
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => 0,
|
|
|
|
|
|
'color' => '#A8574F',
|
|
|
|
|
|
'calname' => '任务',
|
|
|
|
|
|
'title' => '☑ ' . (string)$t['title'],
|
|
|
|
|
|
'date' => $day,
|
|
|
|
|
|
'time' => $tm === '00:00' ? '' : $tm,
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 任务只有"截止时刻"一个点,没有时长 → 不给 end_time,
|
|
|
|
|
|
// 让时间网格按"最短可见块"画(见 build_time_grid)。
|
|
|
|
|
|
'end_time'=> '',
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'all_day' => $tm === '00:00',
|
|
|
|
|
|
'kind' => 'task',
|
|
|
|
|
|
'ref_id' => (int)$t['id'],
|
|
|
|
|
|
'href' => url('tasks/show', ['id' => (int)$t['id']]),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ---- 纪念日(v24 第 13 条)----
|
|
|
|
|
|
// 用户明确要求:纪念日一律作为**全天日程**出现在日历里。
|
|
|
|
|
|
// 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。
|
|
|
|
|
|
// ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。
|
|
|
|
|
|
foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) {
|
2026-10-19 12:51:17 +08:00
|
|
|
|
/* 🔴 v62(用户 2026-10-18 第 2 条):「在列表中、**日历中**,都要根据分类,
|
|
|
|
|
|
显示不同的图标。」
|
|
|
|
|
|
⇒ 日历里的条目是**纯文本**(月格 / 周视图 / 日视图共用一个 title 字符串),
|
|
|
|
|
|
塞不进行内 SVG,所以这里用同一套图标的 emoji 形态(`AnnivIcon::emo`)。
|
|
|
|
|
|
图标 key 由 `Anniversary::occurrences()` 按分类算好带出来 ——
|
|
|
|
|
|
不要在日历里再判一次"这是生日还是忌日"(两处判据迟早分叉)。 */
|
|
|
|
|
|
$annIcon = AnnivIcon::emo((string)($a['icon'] ?? ''));
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$out[] = [
|
|
|
|
|
|
'cal' => 0,
|
|
|
|
|
|
'color' => '#B4633C',
|
|
|
|
|
|
'calname' => '纪念日',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
'title' => $annIcon . ' ' . (string)$a['title'],
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'date' => (string)$a['date'],
|
|
|
|
|
|
'time' => '',
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'end_time'=> '',
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'all_day' => true, // 全天(用户点名要求)
|
|
|
|
|
|
'kind' => 'anniv',
|
|
|
|
|
|
'ref_id' => (int)($a['id'] ?? 0),
|
|
|
|
|
|
'href' => $a['id'] > 0 ? url('days/edit', ['id' => (int)$a['id']]) : url('days'),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
usort($out, static function ($a, $b) {
|
|
|
|
|
|
return strcmp($a['date'], $b['date'])
|
2026-10-21 13:22:37 +08:00
|
|
|
|
?: (self::displayRank($a) - self::displayRank($b))
|
2026-10-02 09:28:07 +08:00
|
|
|
|
?: strcmp((string)$a['time'], (string)$b['time'])
|
|
|
|
|
|
?: strcmp($a['title'], $b['title']);
|
|
|
|
|
|
});
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 13:22:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 同一天里的**展示优先级**(小者靠前)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v70(用户 2026-10-20 第 2 条):「刚导入进去一些日历,新增加了三个日历本,
|
|
|
|
|
|
* 有两个导入的日程,完全看不到,Bowie的课表和工作学习,这两个。
|
|
|
|
|
|
* 日历导入进去了,但是不显示,看看咋回事。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 真根因**不在导入、也不在共享**(数据都在库里、`mine()` 与 `month()` 都取得到):
|
|
|
|
|
|
* 月视图每格只画前 3 条(模板 `$n <= 3`),而原来的排序是
|
|
|
|
|
|
* 「日期 → 时间 → 标题」,**全天条目时间空串排最前** ⇒ 每天固定有三条
|
|
|
|
|
|
* 「农历 / 节假日 / 节气」「纪念日」这类派生条目霸占头三格,
|
|
|
|
|
|
* 用户自己导入的时间型日程一律落到 "+N 条" 里 —— 而那个 "+N 条"
|
|
|
|
|
|
* 只是一颗**没有任何点击行为**的 <span>,点不开,于是"完全看不到"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 让**用户真正关心的东西**(自己/共享进来的日程、任务、观影、旅程)先上,
|
|
|
|
|
|
* 把每天都会自动生成、信息密度最低的派生条目压到最后:
|
|
|
|
|
|
* 0 有时间安排的自己的日程(带时刻的,最需要"今天几点做什么")
|
|
|
|
|
|
* 1 全天日程(生日/请假这类,也是用户自己填的)
|
|
|
|
|
|
* 2 任务 / 观影 / 旅程(来自别的模块,但仍是"实事")
|
|
|
|
|
|
* 3 纪念日(自动推导,但一年一次、有意义)
|
|
|
|
|
|
* 4 农历 / 节假日 / 传统节日 / 节气(**每天都有一条**,压到最后)
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 只在**同一天内**比较(外面那层先按 date 排),不会把不同日子的顺序搞乱。
|
|
|
|
|
|
* ⚠️ 与月份/时间无关,是纯函数 —— 守卫与反向验证可以直接断言这张表。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function displayRank(array $ev): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$kind = (string)($ev['kind'] ?? 'custom');
|
|
|
|
|
|
if ($kind === 'custom') {
|
|
|
|
|
|
return !empty($ev['all_day']) ? 1 : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($kind === 'task' || $kind === 'movie' || $kind === 'trip') {
|
|
|
|
|
|
return 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($kind === 'anniv') {
|
|
|
|
|
|
return 3;
|
|
|
|
|
|
}
|
|
|
|
|
|
// lunar / holiday / traditional / solarterm 以及将来新增的派生本子
|
|
|
|
|
|
return 4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 17:41:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🌟 时间冲突分列(v30 第 4 条)—— 「两个日程撞在一起不再堆叠,要错开画」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「日程中,如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
|
|
|
|
|
|
* 显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
|
|
|
|
|
|
* 并指定「这个显示效果,在 SyncCalendar 这个代码中有算法,你去读一下,借鉴一下」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 算法逐行对照 SyncCalendar/entry/src/main/ets/common/TimelineUtil.ets 的
|
|
|
|
|
|
* build() + groupsOf() 搬过来(那边跑在 ArkTS 上,这里是 PHP,但**判据一字未改**):
|
|
|
|
|
|
* ① 先划分「冲突组」:按开始时间升序,一条与组内**最晚结束**还存在重叠
|
|
|
|
|
|
* (`start < endMax`)就并进这一组 —— 这是"传递性连通",注意它会把
|
|
|
|
|
|
* 「A 7:30-9:30 / B 8:20-9:00 / C 9:15-9:55」并成一组;
|
|
|
|
|
|
* ② 组内**贪心分列**(区间图着色):每条放进"结束时间 <= 它开始时间"的
|
|
|
|
|
|
* **编号最小**的那一列;开不出新列时才加一列。
|
|
|
|
|
|
* ⇒ 列数 = 该组的**最大同时重叠数**,不是组内条数 ——
|
|
|
|
|
|
* 上面那个例子里 B 与 C 并不重叠,可共用一列,所以只需 2 列而不是 3 列。
|
|
|
|
|
|
* 这正是 SyncCalendar 注释里特意点出的"旧算法按传递性重叠分 3 列"的错处。
|
|
|
|
|
|
* ③ 宽度 = 1/列数,左偏移 = 列号/列数 —— 于是 2 条冲突各占一半、3 条各占三分之一。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 每条日程的"结束分钟"若缺失或 <= 开始(跨天日程的第二天、或用户填反了),
|
|
|
|
|
|
* 按 30 分钟的最短可见块参与分列 —— 与 build_time_grid 的口径保持一致,
|
|
|
|
|
|
* 否则"没有结束时间"的那些会被算成零长、永远不参与冲突。
|
|
|
|
|
|
* ⚠️ **只在有时间的事件之间判冲突**:全天日程不进时间网格(它们挂顶上那一行)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array $events 某一天的时间型事件(元素含 time / end_time,单位 'HH:MM')
|
|
|
|
|
|
* @return array 与入参同序的 [left, width](都是 0~1 的比例;无冲突时是 [0,1])
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function conflictLanes(array $events): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$n = count($events);
|
|
|
|
|
|
if ($n <= 1) {
|
|
|
|
|
|
return array_fill(0, $n, [0.0, 1.0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 前缀扫描动作:把每条事件换成一个"动作"(start 记 +1、end 记 -1)----
|
|
|
|
|
|
// 这样"任意一点上有几条重叠"就是同一个坐标上的动作个数 —— 这正是
|
|
|
|
|
|
// SyncCalendar 的贪心分列(laneEnd <= start ⇒ 复用该列)的等价表述,
|
|
|
|
|
|
// 但**完全不需要保留 laneEnd 数组**,也就绕开了浮点比较。
|
|
|
|
|
|
// ⚠️ 不能简单地按"起点同时刻"分组:结束点也要参与,且同时刻的 end 必须先于 start
|
|
|
|
|
|
// 处理(否则"9:00 结束的"与"9:00 开始的"会被算成重叠)。
|
|
|
|
|
|
// ---- ① 归一成 [下标, 开始, 结束](结束缺失/倒挂 → 30 分钟最短块,同上口径)----
|
|
|
|
|
|
$rows = [];
|
|
|
|
|
|
foreach ($events as $i => $e) {
|
|
|
|
|
|
$s = self::minutesOf((string)($e['time'] ?? ''));
|
|
|
|
|
|
if ($s === null) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$en = self::minutesOf((string)($e['end_time'] ?? ''));
|
|
|
|
|
|
if ($en === null || $en <= $s) {
|
|
|
|
|
|
$en = $s + 30;
|
|
|
|
|
|
}
|
|
|
|
|
|
$rows[] = ['i' => $i, 's' => $s, 'e' => min($en, 1440)];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$rows) {
|
|
|
|
|
|
return array_fill(0, $n, [0.0, 1.0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 按开始时间升序(同开始则长的在前 —— 与 SyncCalendar 的稳定排序一致)
|
|
|
|
|
|
usort($rows, static function (array $a, array $b): int {
|
|
|
|
|
|
return $a['s'] <=> $b['s'] ?: $b['e'] <=> $a['e'];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ---- ② 划分冲突组:与组内**最晚结束**仍重叠(start < endMax)就并进本组 ----
|
|
|
|
|
|
$groups = [];
|
|
|
|
|
|
$cur = [];
|
|
|
|
|
|
$endMax = -1;
|
|
|
|
|
|
foreach ($rows as $r) {
|
|
|
|
|
|
if ($cur && $r['s'] >= $endMax) {
|
|
|
|
|
|
$groups[] = $cur;
|
|
|
|
|
|
$cur = [];
|
|
|
|
|
|
$endMax = -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
$cur[] = $r;
|
|
|
|
|
|
$endMax = max($endMax, $r['e']);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($cur) {
|
|
|
|
|
|
$groups[] = $cur;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- ③ 组内贪心分列:每列记"最后结束分钟",放进编号最小的可用列 ----
|
|
|
|
|
|
// ⚠️ 列数是**每组各自**的(组与组之间时间不重叠,可互相复用 0 号列)——
|
|
|
|
|
|
// 所以一个"孤立日程"仍然是整宽,不会被别处的三方冲突连累成 1/3 宽。
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
for ($i = 0; $i < $n; $i++) {
|
|
|
|
|
|
$out[$i] = [0.0, 1.0];
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach ($groups as $g) {
|
|
|
|
|
|
$laneEnd = []; // 每列最后结束分钟
|
|
|
|
|
|
$laneOf = []; // 本组第 k 条 → 列号
|
|
|
|
|
|
foreach ($g as $k => $r) {
|
|
|
|
|
|
$lane = -1;
|
|
|
|
|
|
foreach ($laneEnd as $li => $le) {
|
|
|
|
|
|
if ($le <= $r['s']) { $lane = $li; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($lane < 0) {
|
|
|
|
|
|
$lane = count($laneEnd);
|
|
|
|
|
|
$laneEnd[] = $r['e'];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$laneEnd[$lane] = $r['e'];
|
|
|
|
|
|
}
|
|
|
|
|
|
$laneOf[$k] = $lane;
|
|
|
|
|
|
}
|
|
|
|
|
|
$cols = max(1, count($laneEnd));
|
|
|
|
|
|
foreach ($g as $k => $r) {
|
|
|
|
|
|
$out[$r['i']] = [$laneOf[$k] / $cols, 1 / $cols];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 'HH:MM' → 分钟数;空/不合法返回 null(第 4 条的分列用) */
|
|
|
|
|
|
private static function minutesOf(string $t): ?int
|
|
|
|
|
|
{
|
|
|
|
|
|
if (preg_match('/^(\d{1,2}):(\d{2})/', trim($t), $m)) {
|
|
|
|
|
|
return ((int)$m[1]) * 60 + (int)$m[2];
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/** 把 [start, end] 夹到 [from, to] 里,逐天列出(含端点) */
|
|
|
|
|
|
private static function eachDay(string $start, string $end, string $from, string $to): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$s = max($start, $from);
|
|
|
|
|
|
$e = min($end, $to);
|
|
|
|
|
|
if ($s > $e) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
$cur = $s;
|
|
|
|
|
|
for ($i = 0; $i < 400 && $cur <= $e; $i++) {
|
|
|
|
|
|
$out[] = $cur;
|
|
|
|
|
|
$cur = date('Y-m-d', strtotime($cur . ' +1 day'));
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static function nextPosition(int $ownerId): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM calendars WHERE owner_id = ?');
|
|
|
|
|
|
$st->execute([$ownerId]);
|
|
|
|
|
|
return (int)$st->fetchColumn();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static function textOrNull(string $v): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
$v = trim($v);
|
|
|
|
|
|
return $v !== '' ? $v : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|