['我的日程', '#4A7C82'], 'movie' => ['观影', '#7A4E8C'], 'trip' => ['旅行', '#3F7D5A'], 'lunar' => ['农历', '#8C6D3F'], 'holiday' => ['节假日', '#B0342C'], // v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个 // 系统日历本,用来存放24节气。」)—— 两个都由 Holiday 派生、都不可删。 'traditional' => ['传统节日', '#A6305C'], 'solarterm' => ['二十四节气', '#307AA6'], ]; /** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */ /** * **纯派生、完全不能写入**的日历本:内容按日期算出来(农历/节假日/传统节日/节气)。 * 往这些本子里加日程毫无意义 —— 加了也不会显示(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']; /** 这本日历的内容是不是"自动生成、不能直接往里写日程" */ public static function isReadonlyKind(string $kind): bool { return in_array($kind, self::READONLY_KINDS, true); } /** * 建本时的预设色板(与影院管理同一个交互:取色器 + 点一下就换)。 * * ⚠️ 一律挑**中低饱和**的:月网格里日程条是**白字压在色块上**, * 太浅看不清字,太艳会盖过整个月视图。 */ public const PRESET_CAL_COLORS = [ '#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 天', ]; /** 新建一本 */ 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'), // ⚠️ 别写成 `'kind' => …`:这是个**位置参数**数组(对应 6 个 ? 占位符), // 混一个命名键进去会让 PDO 报 `Invalid parameter number` —— // 而且是**静默的**:整个 seedDefaults 抛异常,日历本一本都建不出来。 isset(self::DEFAULT_KINDS[$kind]) || $kind === 'custom' ? $kind : 'custom', 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'; } // ---------------- 我的日程显示偏好(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 本子 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)); } /** * 按 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; } /** 给某人补建两本默认日历(幂等) */ 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); } } } /** 升级 / 安装时给全家每个账号补一遍(老库也要有这些本) */ 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']); } } /** * 补一本「我的日程」(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'); } // ---------------- 共享 ---------------- 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]); } // ---------------- 日程 ---------------- /** * 新建一条日程。 * * ⚠️ v24(第 5、6 条):起止是**完整的 DATETIME(精确到秒)**,不再分成 * "日期一列 + 时刻一列" —— 分开存就表达不了"今天 22:30 到明天 01:15", * 而且用户填的时候也得分两处。全天日程仍然写 DATETIME,只是时间部分是 00:00:00。 * ⚠️ uid 一定要在**插入时**生成:CalDAV 客户端靠 UID 认日程, * 没有 UID 的日程每同步一次就被当成新日程再建一条(无限重复)。 */ public static function addEvent(int $calendarId, array $d): int { $db = Database::get(); $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 (?,?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([ $calendarId, self::newUid(), mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), self::textOrNull((string)($d['location'] ?? ''), 255), $d['starts_at'], $d['ends_at'] ?: null, !empty($d['all_day']) ? 1 : 0, self::reminderMinutes($d['reminder_minutes'] ?? null), self::statusOf($d['status'] ?? null), !empty($d['transp']) ? 1 : 0, self::textOrNull((string)($d['rrule'] ?? ''), 255), self::textOrNull($d['note'] ?? ''), $d['created_by'] ?: null, ]); return (int)$db->lastInsertId(); } /** * 改一条日程。 * * ⚠️ SEQUENCE 每次 +1(RFC 5545):CalDAV 客户端拿它判断"服务端这版比我新吗"。 * 不加的话,改完日程客户端可能还抱着旧的不放。 */ 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([ mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), self::textOrNull((string)($d['location'] ?? ''), 255), $d['starts_at'], $d['ends_at'] ?: null, !empty($d['all_day']) ? 1 : 0, self::reminderMinutes($d['reminder_minutes'] ?? null), self::statusOf($d['status'] ?? null), !empty($d['transp']) ? 1 : 0, self::textOrNull((string)($d['rrule'] ?? ''), 255), self::textOrNull($d['note'] ?? ''), $id, ]); } public static function deleteEvent(int $id): void { Attachment::removeAll(self::ATTACH, $id); Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]); } /** 一条日程(含所属本子;用来判权限) */ 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 第 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()); } /** * 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。 * * 为什么要独立一条而不是改原日程:原日程是**整个系列**的定义, * 「仅此一次」的改动不能碰它。所以: * ① 在原系列里把这一天标记为跳过(不再出现原样那次); * ② 新建一条只发生这一次的日程,内容 = 用户改后的。 * 这样"改过的那一天"与"其余各次"各自独立,之后怎么改都不会互相干扰。 * * @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); } /** 一条日程的 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 */ 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 = []; $seen = []; $want = (int)$cal['id']; foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) { // 🔴 v31 修的真 bug:`month()` 里**任务**与**纪念日**那两段是 // **不带 kind 判断**的(它们本就不属于任何日历本,`cal` 给的是 0)。 // 于是"取观影本"也会把全家 130 年的生日 + 所有任务一起带出来 —— // 实测线上 /dav/1.ics/ 的 2981 条里,绝大多数是 anniv(生日)。 // ⇒ 导出必须只认**真正属于这一本**的条目。 // ⚠️ 判据是 `cal` 相等,不是 `kind` 白名单:白名单每加一个 // 系统日历本就要改一次,漏改就是"这本里混进别人"。 if ((int)($e['cal'] ?? 0) !== $want) { continue; } $key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date']; if (isset($seen[$key])) { continue; // 跨天日程会逐天出一条,导出时一条就够 } $seen[$key] = true; $day = (string)$e['date']; $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, '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']]); // 🔴 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; } return $st->fetchAll(); } /** * 导入一条 ics 里的 VEVENT。 * * ⚠️ 判重用 UID + 日历本:同一本日历里 UID 已存在就**更新**而不是再插一条 —— * 否则同一个 .ics 导入两次,日历里就是两份一模一样的日程(用户最常见的操作 * "导一次没看清,再导一次"会立刻翻车)。 * * @return string 'added' | 'updated' */ public static function importEvent(int $calendarId, array $v, int $createdBy): string { $db = Database::get(); $uid = (string)($v['uid'] ?? ''); if ($uid !== '') { $st = $db->prepare('SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ?'); $st->execute([$calendarId, $uid]); $old = (int)$st->fetchColumn(); 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, reminder_minutes, status, transp, rrule, note, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' )->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, ]); return 'added'; } /** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */ /** * 提醒值 → 存的字符串(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; } /** * v32 第 3 条:**到点的提醒**(浏览器通知用)。 * * 用户原话:「还有日历中日程到期提醒时,比如一个日程,设置了10点开始, * 提前十分钟提醒,则需要在9:50时浏览器中发提醒通知。」 * * @param string $from 窗口起点(含),'Y-m-d H:i:s' * @param string $to 窗口终点(含) * @return array * `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; } /** * 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。 * ⚠️ 白名单:只认 `self::REMINDERS` 里那几个档位。手搓请求塞 999999 * 不能变成"提前 999999 分钟提醒"(那会导出一个客户端算不明白的 VALARM)。 * * @param mixed $v 数组 / "5,15" / "5" / null * @return int[] */ public static function reminderList($v): array { if ($v === null || $v === '' || $v === false) { return []; } 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); } private static function statusOf($v): string { $v = (string)$v; return in_array($v, ['confirmed', 'tentative', 'cancelled'], true) ? $v : 'confirmed'; } /** * 🔴 v43(用户第 6 条):日程状态的**中文说法** —— 条目详情浮层里要显示"状态"。 * 与 `templates/calendar/_event_fields.php` 那个