v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)

用户原话四条,逐条落地:

1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
   → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
     活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
     节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
     (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)

2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
     旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
   → 活动**不再存**这三样,全部**读时算出来**:
     · Trip::derived()      最早开始 / 最晚结束 / 城市去重
     · Trip::cities()       按最早出现时间排(先后顺序本身是信息,不是字母序)
     · Trip::attachDerived() 列表页批量补(避免 N+1)
     · Trip::activitySpan()  没有旅程时说「还没有旅程」,不用「日期待补」
     create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
     列表排序/搜索、首页「最近去了」改走子查询。

3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
   「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
   第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
   于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
   → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
     app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
     三处(头像 / 题图 / 图书封面)全挂上。

4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
   → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
     不说清用户会满页找"怎么没地方填时间")。
     备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。

顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
  php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
  于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。

验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
This commit is contained in:
2026-10-05 10:58:17 +08:00
parent 89b956205d
commit 7138314dc7
30 changed files with 4177 additions and 290 deletions
+638
View File
@@ -0,0 +1,638 @@
<?php
/**
* 读书(v24 第 14 批 用户第 5 条):像 calibre 那样管自己的书。
*
* 用户原话:「增加读书模块,就像 calibre 一样管理自己的图书,可以导入图书,
* 可以上传图书,可以解析各种格式的图书,可以点开图书进行查看、阅读,
* 可以标记为读完,有统计功能,有笔记功能,可以导入微信读书等
* 各种读书软件的读书笔记等功能,在导航条上进行展示。」
*
* 三张表(见 Schema v20):
* books 书本身(元数据 + 文件 + 读到哪 + 读完没读完)
* book_chapters 解析出来的正文
* book_notes 笔记 / 划线 / 书评(**按人**记)
*
* ⚠️ 与观影/旅程不同,读书**不走统一事件原语**:
* 一次观影、一趟旅行都是"某一天发生的事"(有时间、有人、要上日历),
* 而一本书是"一段持续几个月的状态"(哪天读到第几章才勉强算事件)。
* 硬塞进 events 会让日历上挂一堆"在读《活着》"的日常噪音。
* 真要上日历的那天是"读完的那天"—— 将来要做的话,也是从 finished_at 派生。
*
* 状态机(wish → reading → done,另有 paused 搁置):
* · 标成 done 时记 finished_at;退回非 done 时**清空**它(不然统计口径会漂:
* "读完的书"里混着一本其实还在读的)。
*/
declare(strict_types=1);
namespace App;
final class Book
{
/** 状态:想读 / 在读 / 读完 / 搁置 */
public const STATUS = [
'wish' => '想读',
'reading' => '在读',
'done' => '读完',
'paused' => '搁置',
];
/** 笔记种类 */
public const NOTE_KINDS = [
'quote' => '划线',
'note' => '笔记',
'review' => '书评',
];
/** 笔记来源 */
public const NOTE_SOURCES = [
'manual' => '本站',
'weread' => '微信读书',
'douban' => '豆瓣',
'other' => '其它',
];
/** 上传图书允许的格式(解析能力见 BookParser) */
public const FILE_EXT = ['epub', 'txt', 'pdf', 'mobi', 'azw3', 'azw', 'doc', 'docx'];
// ---------------- 读 ----------------
/**
* 书架。
*
* @param array $f 过滤:status / category / q(书名作者标签)/ owner(谁的书)
*/
public static function all(int $familyId, array $f = []): array
{
$sql = 'SELECT b.*, p.name AS owner_name, p.avatar AS owner_avatar,
p.avatar_color AS owner_color,
(SELECT COUNT(*) FROM book_notes n WHERE n.book_id = b.id) AS note_count
FROM books b
LEFT JOIN persons p ON p.id = b.owner_id
WHERE b.family_id = ?';
$args = [$familyId];
$st = (string)($f['status'] ?? '');
if ($st !== '' && isset(self::STATUS[$st])) {
$sql .= ' AND b.status = ?';
$args[] = $st;
}
$cat = trim((string)($f['category'] ?? ''));
if ($cat !== '') {
$sql .= ' AND b.category = ?';
$args[] = $cat;
}
$owner = (int)($f['owner'] ?? 0);
if ($owner > 0) {
$sql .= ' AND b.owner_id = ?';
$args[] = $owner;
}
$q = trim((string)($f['q'] ?? ''));
if ($q !== '') {
$like = '%' . $q . '%';
$sql .= ' AND (b.title LIKE ? OR b.author LIKE ? OR b.subtitle LIKE ?
OR b.publisher LIKE ? OR b.tags LIKE ? OR b.isbn LIKE ?)';
array_push($args, $like, $like, $like, $like, $like, $like);
}
// 在读的排最前(那是"手头正在看什么"),其次是最近更新过的
$sql .= ' ORDER BY FIELD(b.status, "reading", "wish", "paused", "done"), b.updated_at DESC, b.id DESC';
$st2 = Database::get()->prepare($sql);
$st2->execute($args);
return $st2->fetchAll();
}
/**
* 各状态的数量(书架页的筛选条用;'_all' 是总数)。
*
* ⚠️ 与筛选条件**无关**:这里要的是"这个状态一共有几本",
* 不是"当前筛选下有几本"(否则点进"在读"后,其它状态的数字全变 0,
* 筛选条就成了自我实现的死路 —— 再也点不回去)。
*/
public static function statusCounts(int $familyId): array
{
$out = ['_all' => 0];
foreach (self::STATUS as $k => $v) {
$out[$k] = 0;
}
$st = Database::get()->prepare(
'SELECT status, COUNT(*) AS n FROM books WHERE family_id = ? GROUP BY status'
);
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
$out['_all'] += (int)$r['n'];
$k = (string)$r['status'];
if (isset($out[$k])) {
$out[$k] = (int)$r['n'];
}
}
return $out;
}
/** 用过的分类(筛选用) */
public static function categories(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT category AS name, COUNT(*) AS n FROM books
WHERE family_id = ? AND category IS NOT NULL AND category <> ""
GROUP BY category ORDER BY n DESC, category'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare(
'SELECT b.*, p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color
FROM books b LEFT JOIN persons p ON p.id = b.owner_id
WHERE b.family_id = ? AND b.id = ?'
);
$st->execute([$familyId, $id]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 章节目录(**不取正文** —— 一本书的正文有几 MB,列表页取它就是自杀)。
*/
public static function chapters(int $bookId): array
{
$st = Database::get()->prepare(
'SELECT id, seq, title, words FROM book_chapters WHERE book_id = ? ORDER BY seq'
);
$st->execute([$bookId]);
return $st->fetchAll();
}
/** 一章(含正文) */
public static function chapter(int $bookId, int $seq): ?array
{
$st = Database::get()->prepare(
'SELECT * FROM book_chapters WHERE book_id = ? AND seq = ?'
);
$st->execute([$bookId, $seq]);
$r = $st->fetch();
return $r ?: null;
}
/** 整本正文(导出/统计字数用;量很大,只在必要时调) */
public static function chapterCount(int $bookId): int
{
$st = Database::get()->prepare('SELECT COUNT(*) FROM book_chapters WHERE book_id = ?');
$st->execute([$bookId]);
return (int)$st->fetchColumn();
}
// ---------------- 写 ----------------
/** @return int 新书 id */
public static function create(int $familyId, ?int $creatorId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO books (family_id, owner_id, creator_id, title, subtitle, author, translator,
publisher, published, isbn, language, category, tags, summary,
cover_key, file_key, file_name, file_format, file_size,
pages, words, status, rating, started_at, finished_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$familyId,
self::ownerId($familyId, (int)($d['owner_id'] ?? 0)),
$creatorId ?: null,
self::title($d['title'] ?? ''),
self::txt($d['subtitle'] ?? ''),
self::txt($d['author'] ?? ''),
self::txt($d['translator'] ?? ''),
self::txt($d['publisher'] ?? ''),
self::txt($d['published'] ?? ''),
self::txt($d['isbn'] ?? ''),
self::txt($d['language'] ?? ''),
self::txt($d['category'] ?? ''),
self::txt($d['tags'] ?? ''),
self::longText($d['summary'] ?? ''),
self::key($d['cover_key'] ?? ''),
self::key($d['file_key'] ?? ''),
self::txt($d['file_name'] ?? ''),
self::fmt($d['file_format'] ?? ''),
self::intOrNull($d['file_size'] ?? 0),
self::intOrNull($d['pages'] ?? 0),
self::intOrNull($d['words'] ?? 0),
self::status($d['status'] ?? ''),
self::rating($d['rating'] ?? null),
self::dateOrNull($d['started_at'] ?? ''),
self::dateOrNull($d['finished_at'] ?? ''),
]);
return (int)$db->lastInsertId();
}
public static function update(int $familyId, int $id, array $d): void
{
$old = self::find($familyId, $id);
if (!$old) {
return;
}
$status = self::status($d['status'] ?? '');
// ⚠️ 状态与"读完那天"必须一起维护:
// · 变成 done 且还没记过 → 记今天;
// · 从 done 退回来 → **清空**(留着会让"今年读完几本"一直虚高)。
$fin = self::dateOrNull($d['finished_at'] ?? '') ?? ((string)$old['finished_at'] ?: null);
if ($status === 'done' && $fin === null) {
$fin = date('Y-m-d');
} elseif ($status !== 'done') {
$fin = null;
}
$db = Database::get();
$db->prepare(
'UPDATE books SET owner_id=?, title=?, subtitle=?, author=?, translator=?, publisher=?,
published=?, isbn=?, language=?, category=?, tags=?, summary=?, cover_key=?,
file_key=?, file_name=?, file_format=?, file_size=?, pages=?, words=?,
status=?, rating=?, started_at=?, finished_at=?
WHERE id=? AND family_id=?'
)->execute([
self::ownerId($familyId, (int)($d['owner_id'] ?? 0)),
self::title($d['title'] ?? ''),
self::txt($d['subtitle'] ?? ''),
self::txt($d['author'] ?? ''),
self::txt($d['translator'] ?? ''),
self::txt($d['publisher'] ?? ''),
self::txt($d['published'] ?? ''),
self::txt($d['isbn'] ?? ''),
self::txt($d['language'] ?? ''),
self::txt($d['category'] ?? ''),
self::txt($d['tags'] ?? ''),
self::longText($d['summary'] ?? ''),
self::key($d['cover_key'] ?? ''),
self::key($d['file_key'] ?? ''),
self::txt($d['file_name'] ?? ''),
self::fmt($d['file_format'] ?? ''),
self::intOrNull($d['file_size'] ?? 0),
self::intOrNull($d['pages'] ?? 0),
self::intOrNull($d['words'] ?? 0),
$status,
self::rating($d['rating'] ?? null),
self::dateOrNull($d['started_at'] ?? ''),
$fin,
$id, $familyId,
]);
}
/**
* 改状态(列表上的一键:想读 → 在读 → 读完)。
*
* @return string 改完之后的状态
*/
public static function setStatus(int $familyId, int $id, string $status): string
{
$old = self::find($familyId, $id);
if (!$old) {
return '';
}
$status = self::status($status);
$db = Database::get();
if ($status === 'done') {
$db->prepare(
'UPDATE books SET status=?, finished_at=COALESCE(finished_at, ?),
started_at=COALESCE(started_at, ?) WHERE id=? AND family_id=?'
)->execute([$status, date('Y-m-d'), date('Y-m-d'), $id, $familyId]);
} elseif ($status === 'reading') {
$db->prepare(
'UPDATE books SET status=?, finished_at=NULL, started_at=COALESCE(started_at, ?)
WHERE id=? AND family_id=?'
)->execute([$status, date('Y-m-d'), $id, $familyId]);
} else {
$db->prepare('UPDATE books SET status=?, finished_at=NULL WHERE id=? AND family_id=?')
->execute([$status, $id, $familyId]);
}
return $status;
}
/** 读到第几章(阅读器翻页时记) */
public static function setProgress(int $familyId, int $id, int $seq): void
{
Database::get()->prepare(
'UPDATE books SET progress = ? WHERE id = ? AND family_id = ?'
)->execute([max(0, $seq), $id, $familyId]);
}
public static function destroy(int $familyId, int $id): void
{
$b = self::find($familyId, $id);
if (!$b) {
return;
}
// 文件与封面是自己存的,数据库级联删不掉存储对象
if (!empty($b['file_key'])) {
\App\Storage\Storage::delete($familyId, (string)$b['file_key']);
}
if (!empty($b['cover_key'])) {
\App\Storage\Storage::delete($familyId, (string)$b['cover_key']);
}
Database::get()->prepare('DELETE FROM books WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
/** 整组替换章节(重新解析 / 重新上传文件时用) */
public static function replaceChapters(int $bookId, array $chapters): int
{
$db = Database::get();
$db->prepare('DELETE FROM book_chapters WHERE book_id = ?')->execute([$bookId]);
$st = $db->prepare(
'INSERT INTO book_chapters (book_id, seq, title, content, words) VALUES (?,?,?,?,?)'
);
$seq = 0;
$words = 0;
foreach ($chapters as $c) {
$seq++;
$body = (string)($c['content'] ?? '');
$w = self::countWords($body);
$words += $w;
$st->execute([$bookId, $seq, self::txt($c['title'] ?? ''), $body, $w]);
}
$db->prepare('UPDATE books SET chapter_count = ?, words = ? WHERE id = ?')
->execute([$seq, $words > 0 ? $words : null, $bookId]);
return $seq;
}
// ---------------- 笔记 ----------------
/**
* 一本书的笔记(按时间正序,**全部人**的都给)。
*
* ⚠️ 昵称必须按**看的人**($viewerId)取,不能写成 `nk.viewer_id = n.person_id`
* —— 那是"这人对自己的称呼",页面上会冒出一个跟谁都不对应的名字。
* (nicknames 是 (viewer_id, target_id) 二元组,缺了 viewer 就取错人。)
*
* 笔记不按人过滤:笔记是"读的人留下的字",家庭里没必要互相藏。
*
* @param int $viewerId 谁在看(决定"我怎么称呼这条笔记的作者")
*/
public static function notes(int $bookId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT n.*, p.name AS person_name, p.avatar, p.avatar_color,
nk.nickname AS my_nickname
FROM book_notes n
LEFT JOIN persons p ON p.id = n.person_id
LEFT JOIN nicknames nk ON nk.target_id = n.person_id AND nk.viewer_id = ?
WHERE n.book_id = ? ORDER BY n.created_at, n.id'
);
$st->execute([$viewerId, $bookId]);
return $st->fetchAll();
}
/** @return int 笔记 id */
public static function addNote(int $familyId, int $bookId, ?int $personId, array $d): int
{
$content = trim((string)($d['content'] ?? ''));
if ($content === '') {
return 0;
}
$db = Database::get();
$db->prepare(
'INSERT INTO book_notes (book_id, person_id, kind, chapter, loc, content, source)
VALUES (?,?,?,?,?,?,?)'
)->execute([
$bookId,
$personId > 0 ? $personId : null,
isset(self::NOTE_KINDS[(string)($d['kind'] ?? '')]) ? (string)$d['kind'] : 'note',
self::txt($d['chapter'] ?? ''),
self::txt($d['loc'] ?? ''),
mb_substr($content, 0, 8000, 'UTF-8'),
self::source($d['source'] ?? ''),
]);
return (int)$db->lastInsertId();
}
public static function deleteNote(int $familyId, int $noteId, int $personId): void
{
// 只能删自己写的(导入的无主笔记谁都能删 —— 不然它永远留在那儿)
$st = Database::get()->prepare(
'SELECT n.id FROM book_notes n JOIN books b ON b.id = n.book_id
WHERE n.id = ? AND b.family_id = ? AND (n.person_id IS NULL OR n.person_id = ?)'
);
$st->execute([$noteId, $familyId, $personId]);
if ($st->fetchColumn() === false) {
return;
}
Database::get()->prepare('DELETE FROM book_notes WHERE id = ?')->execute([$noteId]);
}
// ---------------- 统计 ----------------
/**
* 书架的整体数字。
*
* ⚠️ 读完的书按 **finished_at 的年份**分桶(不是 created_at):
* "今年读完了几本"问的是读完的时间,加进书架的时间没有意义
* (很多人是年底一次性把看过的书补进去的)。
*/
public static function stats(int $familyId): array
{
$db = Database::get();
$byStatus = [];
foreach (self::STATUS as $k => $v) {
$byStatus[$k] = 0;
}
$st = $db->prepare('SELECT status, COUNT(*) AS n FROM books WHERE family_id = ? GROUP BY status');
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
$byStatus[(string)$r['status']] = (int)$r['n'];
}
$one = static function (string $sql) use ($db, $familyId): int {
$s = $db->prepare($sql);
$s->execute([$familyId]);
return (int)$s->fetchColumn();
};
$out = [
'total' => array_sum($byStatus),
'status' => $byStatus,
'pages' => $one('SELECT COALESCE(SUM(pages),0) FROM books WHERE family_id = ? AND status = "done"'),
'words' => $one('SELECT COALESCE(SUM(words),0) FROM books WHERE family_id = ? AND status = "done"'),
'rated' => $one('SELECT COUNT(*) FROM books WHERE family_id = ? AND rating IS NOT NULL'),
'avgRate' => 0.0,
'notes' => $one('SELECT COUNT(*) FROM book_notes n JOIN books b ON b.id = n.book_id
WHERE b.family_id = ?'),
];
$sum = $one('SELECT COALESCE(SUM(rating),0) FROM books WHERE family_id = ? AND rating IS NOT NULL');
$out['avgRate'] = $out['rated'] > 0 ? round($sum / $out['rated'], 1) : 0.0;
// 按年读完(近 8 年)
$st = $db->prepare(
'SELECT YEAR(finished_at) AS y, COUNT(*) AS n FROM books
WHERE family_id = ? AND status = "done" AND finished_at IS NOT NULL
GROUP BY YEAR(finished_at) ORDER BY y'
);
$st->execute([$familyId]);
$out['byYear'] = $st->fetchAll();
// 评分分布(0-10)
$dist = array_fill(0, 11, 0);
$st = $db->prepare(
'SELECT rating, COUNT(*) AS n FROM books
WHERE family_id = ? AND rating IS NOT NULL GROUP BY rating'
);
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
$dist[max(0, min(10, (int)$r['rating']))] = (int)$r['n'];
}
$out['ratingDist'] = $dist;
// 读得最多的作者 / 分类
$st = $db->prepare(
'SELECT author AS name, COUNT(*) AS n FROM books
WHERE family_id = ? AND author IS NOT NULL AND author <> ""
GROUP BY author ORDER BY n DESC, author LIMIT 8'
);
$st->execute([$familyId]);
$out['topAuthors'] = $st->fetchAll();
$st = $db->prepare(
'SELECT category AS name, COUNT(*) AS n FROM books
WHERE family_id = ? AND category IS NOT NULL AND category <> ""
GROUP BY category ORDER BY n DESC, category LIMIT 8'
);
$st->execute([$familyId]);
$out['topCategories'] = $st->fetchAll();
return $out;
}
// ---------------- 展示 ----------------
public static function statusWord(string $k): string
{
return self::STATUS[$k] ?? '想读';
}
public static function normalizeStatus(string $v): string
{
return self::status($v);
}
public static function kindWord(string $k): string
{
return self::NOTE_KINDS[$k] ?? '笔记';
}
public static function sourceWord(string $k): string
{
return self::NOTE_SOURCES[$k] ?? (string)$k;
}
/** 文件大小的"人话" */
public static function sizeWord($bytes): string
{
$b = (int)$bytes;
if ($b <= 0) {
return '';
}
if ($b < 1024) {
return $b . ' B';
}
if ($b < 1024 * 1024) {
return round($b / 1024, 1) . ' KB';
}
return round($b / 1024 / 1024, 1) . ' MB';
}
/** 字数的"人话"(按中文习惯说"万字") */
public static function wordsWord($words): string
{
$w = (int)$words;
if ($w <= 0) {
return '';
}
if ($w < 10000) {
return number_format($w) . ' 字';
}
return round($w / 10000, 1) . ' 万字';
}
/** 中英混排的字数(中文按字、西文按词)—— 与 BookParser 同一口径 */
public static function countWords(string $s): int
{
$s = trim($s);
if ($s === '') {
return 0;
}
$cn = preg_match_all('/[\x{4e00}-\x{9fff}]/u', $s);
$noCn = preg_replace('/[\x{4e00}-\x{9fff}]/u', ' ', $s);
$en = preg_match_all('/[A-Za-z0-9]+/u', $noCn);
return (int)$cn + (int)$en;
}
// ---------------- 内部 ----------------
private static function ownerId(int $familyId, int $id): ?int
{
if ($id <= 0) {
return null;
}
$st = Database::get()->prepare('SELECT id FROM persons WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
return $st->fetchColumn() === false ? null : $id;
}
private static function status(string $v): string
{
return isset(self::STATUS[$v]) ? $v : 'wish';
}
private static function source(string $v): string
{
$v = trim($v);
return $v !== '' ? mb_substr($v, 0, 40, 'UTF-8') : 'manual';
}
private static function title(string $v): string
{
return mb_substr(trim($v), 0, 300, 'UTF-8');
}
private static function txt(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
private static function longText(string $v): ?string
{
$v = trim($v);
return $v !== '' ? mb_substr($v, 0, 20000, 'UTF-8') : null;
}
private static function key(string $v): ?string
{
$v = ltrim(trim($v), '/');
if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) {
return null;
}
return $v;
}
private static function fmt(string $v): ?string
{
$v = strtolower(trim($v));
$v = preg_replace('/[^a-z0-9]/', '', $v) ?? '';
return $v !== '' ? mb_substr($v, 0, 20, 'UTF-8') : null;
}
private static function rating($v): ?int
{
if ($v === null || $v === '') {
return null;
}
$n = (int)$v;
return ($n >= 0 && $n <= 10) ? $n : null;
}
private static function intOrNull($v): ?int
{
$n = (int)$v;
return $n > 0 ? $n : null;
}
private static function dateOrNull(string $v): ?string
{
$v = trim($v);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null;
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
/**
* 读书笔记导入(v24 第 14 批 用户第 5 条:「可以导入微信读书等各种读书软件的读书笔记」)。
*
* 为什么自己写解析而不是要求用户填 CSV:
* 各家读书 App 的导出都是**给人看的文本**(微信读书复制出来就是一大段 Markdown),
* 没有两家格式一样,也没有稳定的机器格式。让人先去"整理成 CSV"等于没做这个功能。
* 所以这里做的是**宽容解析**:认得出书名的分行、认得出划线/想法的标记,
* 认不出的部分按上下文归类 —— 宁可归类得不完美,也不要丢字。
*
* 认得的标记(都来自真机复制出来的样本):
* · 书名 —— `## 活着` / `# 活着` / `《活着》` / `活着(余华)`
* · 作者 —— 书名行里的括号、或 `作者:余华`
* · 章节 —— `◆ 第一章 xxx`
* · 划线 —— `> 原文`、`### 划线` 之后的裸行
* · 想法 —— `>> 我的想法`、`### 笔记` / `### 想法` 之后的裸行
*
* ⚠️ 只做**纯文本**:不解析 HTML(微信读书导出还有 HTML 版,但那种各家模板不同,
* 而且要防注入 —— 用户贴进来的东西一律当纯文本存,页面展示时转义)。
*/
declare(strict_types=1);
namespace App;
final class BookNoteImport
{
/** 解析一大段文本 → 若干本书 + 各自的笔记 */
public static function parse(string $raw): array
{
$raw = str_replace(["\r\n", "\r"], "\n", trim($raw));
$lines = explode("\n", $raw);
$books = [];
$cur = -1; // 当前书在 $books 里的下标
$mode = 'quote'; // 当前归类:quote(划线)/ note(想法)
$chapter = '';
foreach ($lines as $ln) {
$t = trim($ln);
if ($t === '') {
continue;
}
// ---- 书名:Markdown 标题 / 《》 / 单独一行且很短 ----
$heading = self::headingText($t);
$isSection = $heading !== null && self::isSectionWord($heading);
if ($heading !== null && !$isSection) {
[$title, $author] = self::splitTitleAuthor($heading);
if ($title !== '') {
$books[] = ['title' => $title, 'author' => $author, 'notes' => []];
$cur = count($books) - 1;
$mode = 'quote';
$chapter = '';
continue;
}
}
if (preg_match('/^《(.+?)》/u', $t, $m)) {
[$title, $author] = self::splitTitleAuthor(trim($m[1]) . mb_substr($t, mb_strlen($m[0], 'UTF-8'), null, 'UTF-8'));
$books[] = ['title' => $title, 'author' => $author, 'notes' => []];
$cur = count($books) - 1;
$mode = 'quote';
$chapter = '';
continue;
}
// ---- 分节标记:划线 / 笔记 / 想法 ----
if ($isSection) {
$mode = self::modeOfWord($heading);
continue;
}
// ---- 作者行 ----
if ($cur >= 0 && preg_match('/^(作者|作者名)\s*[::]\s*(.+)$/u', $t, $m)) {
if ((string)$books[$cur]['author'] === '') {
$books[$cur]['author'] = trim($m[2]);
}
continue;
}
// ---- 章节:◆ / ● 开头 ----
if (preg_match('/^[◆●]\s*(.+)$/u', $t, $m)) {
$chapter = mb_substr(trim($m[1]), 0, 200, 'UTF-8');
continue;
}
// ---- 时间行(2023-05-12 / 2023年5月12日)→ 丢掉,它不是笔记内容 ----
if (preg_match('/^\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}/u', $t)
&& mb_strlen($t, 'UTF-8') <= 30) {
continue;
}
// ---- 想法:`>>` 开头 ----
if (strpos($t, '>>') === 0) {
$c = trim(mb_substr($t, 2, null, 'UTF-8'));
if ($c !== '') {
self::push($books, $cur, 'note', $chapter, $c);
}
continue;
}
// ---- 划线:`>` 开头 ----
if (strpos($t, '>') === 0) {
$c = trim(mb_substr($t, 1, null, 'UTF-8'));
if ($c !== '') {
self::push($books, $cur, 'quote', $chapter, $c);
}
continue;
}
// ---- 裸行:按当前归类 ----
self::push($books, $cur, $mode, $chapter, $t);
}
// 一条笔记都没归到任何书(用户贴了一段没有书名的话)→ 归到"未命名的一本"
return array_values(array_filter($books, static function (array $b): bool {
return count($b['notes']) > 0 || $b['title'] !== '';
}));
}
/** 把解析结果落库:能匹配到已有书就挂上去,匹配不到就新建一本 */
public static function import(int $familyId, int $personId, array $books, string $source): array
{
$created = 0;
$added = 0;
$skipped = 0;
$names = [];
foreach ($books as $b) {
$title = trim((string)($b['title'] ?? ''));
if ($title === '') {
continue;
}
$notes = $b['notes'] ?? [];
if (!$notes) {
$skipped++;
continue;
}
$bookId = self::matchBook($familyId, $title);
if ($bookId === 0) {
$bookId = Book::create($familyId, $personId, [
'title' => $title,
'author' => (string)($b['author'] ?? ''),
'status' => 'done', // 有笔记 = 至少读过;标成"读完"比"想读"贴近事实
'owner_id' => $personId,
]);
$created++;
}
foreach ($notes as $n) {
$id = Book::addNote($familyId, $bookId, $personId, [
'kind' => (string)($n['kind'] ?? 'note'),
'chapter' => (string)($n['chapter'] ?? ''),
'content' => (string)($n['content'] ?? ''),
'source' => $source,
]);
if ($id > 0) {
$added++;
}
}
$names[] = $title;
}
return ['created' => $created, 'added' => $added, 'skipped' => $skipped, 'titles' => $names];
}
/** 按书名找已有的一本(去空格、去大小写;找不到返回 0) */
public static function matchBook(int $familyId, string $title): int
{
$key = self::key($title);
if ($key === '') {
return 0;
}
$st = Database::get()->prepare('SELECT id, title FROM books WHERE family_id = ?');
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
if (self::key((string)$r['title']) === $key) {
return (int)$r['id'];
}
}
return 0;
}
public static function sourceOf(?string $raw): string
{
$s = strtolower((string)$raw);
if (strpos($s, 'weread') !== false || strpos($s, '微信读书') !== false) {
return 'weread';
}
if (strpos($s, 'douban') !== false || strpos($s, '豆瓣') !== false) {
return 'douban';
}
return 'other';
}
// ---------------- 内部 ----------------
/**
* @param int $cur **按引用传**:第一次遇到"没有书名就先来了一段笔记"时,
* 这里会建一本「未命名的一本」,而外面的 $cur 必须跟着指向它 ——
* 否则后面每一段都会各自新建一本(实测:一段话变出 7 本书)。
*/
private static function push(array &$books, int &$cur, string $kind, string $chapter, string $content): void
{
if ($cur < 0) {
// 第一段文字之前没有书名 → 先建一本"未命名",用户导入后可以改
$books[] = ['title' => '未命名的一本', 'author' => '', 'notes' => []];
$cur = count($books) - 1;
}
$books[$cur]['notes'][] = [
'kind' => $kind,
'chapter' => $chapter,
'content' => mb_substr($content, 0, 8000, 'UTF-8'),
];
}
/** `# xxx` / `## xxx` → xxx;不是标题返回 null */
private static function headingText(string $t): ?string
{
if (!preg_match('/^#{1,6}\s*(.+)$/u', $t, $m)) {
return null;
}
return trim($m[1]);
}
/** 这个标题是"划线/笔记/想法"这类**分节**标记,还是书名 */
private static function isSectionWord(?string $h): bool
{
$h = (string)$h;
return (bool)preg_match('/^(划线|笔记|想法|感想|书评|摘录|原文|批注)/u', $h);
}
private static function modeOfWord(?string $h): string
{
$h = (string)$h;
if (preg_match('/^(笔记|想法|感想|书评|批注)/u', $h)) {
return 'note';
}
return 'quote';
}
/** 「活着(余华)」/「活着 - 刘慈欣」→ [书名, 作者] */
private static function splitTitleAuthor(string $s): array
{
$s = trim($s);
$author = '';
if (preg_match('/^(.*?)[((【]([^))】]+)[))】]\s*$/u', $s, $m)) {
$s = trim($m[1]);
$author = trim($m[2]);
} elseif (preg_match('/^(.*?)\s+-\s+(.+)$/u', $s, $m)) {
$s = trim($m[1]);
$author = trim($m[2]);
}
$s = trim(preg_replace('/^[《](.+?)[》]/u', '$1', $s) ?? $s);
return [mb_substr($s, 0, 300, 'UTF-8'), mb_substr($author, 0, 200, 'UTF-8')];
}
private static function key(string $s): string
{
$s = preg_replace('/\s+/u', '', $s) ?? $s;
return mb_strtolower(trim($s), 'UTF-8');
}
}
+545
View File
@@ -0,0 +1,545 @@
<?php
/**
* 图书解析(v24 第 14 批 用户第 5 条):上传一本书 → 读出元数据与正文。
*
* 用户原话:「可以上传图书,可以解析各种格式的图书」。
*
* 能力边界(**页面上必须如实写出来**,别让人以为传什么都能读):
* · **EPUB** —— 完整解析:元数据(书名/作者/出版社/语言/ISBN)+ 封面 + 按 spine 顺序
* 拆出每一章的正文。这是电子书的事实标准,优先做好。
* · **TXT** —— 解析:自动认编码(UTF-8 / GBK / BIG5),按"第X章"切章;
* 切不出来就按 5000 字一段(否则一本 50 万字的书会变成一整页)。
* · **PDF** —— **不提取正文**(服务端没有 PDF 解析扩展,硬写会错得很自信)。
* 文件照存,阅读页用浏览器内嵌的 PDF 阅读器看(每家浏览器都有)。
* · **MOBI / AZW3 / 其它** —— 不解析正文(亚马逊的私有格式,解析要一本书的成本)。
* 文件照存、元数据照手填,仍然能进书架、能记笔记、能统计。
*
* ⚠️ 解析**只做纯文本**:不保留排版、图片、脚注。
* 这是"能读"与"原样还原"之间的取舍 —— 家庭书架要的是"随时翻两页",
* 不是排版引擎(那是一个独立项目的量)。
*
* @return array{ok:bool,error?:string,meta:array,chapters:array,cover?:array}
* cover = ['tmp'=>临时文件路径, 'mime'=>…](有封面才有;调用方负责上传与删临时文件)
*/
declare(strict_types=1);
namespace App;
final class BookParser
{
/** 单章上限(字符):超过就截断,防止一本巨书把库撑爆 */
private const CHAPTER_MAX = 200000;
/** 一本书最多解析多少章 */
private const CHAPTER_LIMIT = 600;
/**
* @param string $path 临时文件路径
* @param string $ext 扩展名(小写)
* @param string $name 原始文件名(用来兜底猜书名/作者)
*/
public static function parse(string $path, string $ext, string $name = ''): array
{
if (!is_file($path)) {
return ['ok' => false, 'error' => '文件读不到', 'meta' => [], 'chapters' => []];
}
$ext = strtolower($ext);
if ($ext === '' && $name !== '') {
$ext = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
}
switch ($ext) {
case 'epub':
return self::parseEpub($path);
case 'txt':
case 'text':
return self::parseTxt($path, $name);
case 'pdf':
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
'chapters' => [], 'note' => 'PDF 只存文件(服务端不提取正文),阅读时用浏览器自带的 PDF 阅读器看。'];
default:
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
'chapters' => [], 'note' => self::formatWord($ext) . ' 正文解析不了,文件已经存好了,元数据请手填。'];
}
}
// ---------------- EPUB ----------------
/**
* EPUB = 一个 zip 包:META-INF/container.xml 指路 → .opf 里放元数据与清单 →
* spine 决定章节顺序 → 每一章是一个 XHTML 文件。
*
* ⚠️ 路径要**相对 opf 所在目录**拼(很多书的 OEBPS/ 与 opf 不在一起),
* 直接用 opf 的路径当基准去拼会 404(ZipArchive::getFromName 返回 false)。
*/
private static function parseEpub(string $path): array
{
if (!class_exists('ZipArchive')) {
return ['ok' => true, 'error' => '', 'meta' => [], 'chapters' => [],
'note' => '服务器没有 zip 扩展,EPUB 的正文解析不了;文件已保存,元数据请手填。'];
}
$zip = new \ZipArchive();
$rc = $zip->open($path);
if ($rc !== true) {
return ['ok' => false, 'error' => '这个 EPUB 打不开(错误码 ' . (int)$rc . ')',
'meta' => [], 'chapters' => []];
}
// ① 找 OPF(书的"目录清单")
$opfPath = '';
$container = $zip->getFromName('META-INF/container.xml');
if ($container !== false && $container !== '') {
if (preg_match('/full-path\s*=\s*["\']([^"\']+)["\']/i', $container, $m)) {
$opfPath = trim($m[1]);
}
}
if ($opfPath === '') {
// 有些书没写 container.xml(不合规范但确实存在)→ 自己找第一个 .opf
for ($i = 0; $i < $zip->numFiles; $i++) {
$n = (string)$zip->getNameIndex($i);
if (substr(strtolower($n), -4) === '.opf') {
$opfPath = $n;
break;
}
}
}
if ($opfPath === '') {
$zip->close();
return ['ok' => false, 'error' => '这个 EPUB 里找不到目录文件(.opf),可能已损坏',
'meta' => [], 'chapters' => []];
}
$opfDir = self::dirOf($opfPath);
$opf = (string)$zip->getFromName($opfPath);
$zip->close();
if ($opf === '') {
return ['ok' => false, 'error' => '这个 EPUB 的目录文件是空的', 'meta' => [], 'chapters' => []];
}
$meta = self::opfMeta($opf);
// ② 清单:id → 文件
$manifest = self::opfManifest($opf);
// ③ 顺序:spine 里的 idref
$spine = self::opfSpine($opf, $manifest);
// ④ 封面:metadata 里的 <meta name="cover" content="id">,或 manifest 里 properties 带 cover-image
$cover = self::epubCover($path, $opf, $opfDir, $manifest);
$zip2 = new \ZipArchive();
if ($zip2->open($path) !== true) {
return ['ok' => true, 'meta' => $meta, 'chapters' => [], 'cover' => $cover,
'note' => '元数据读到了,正文没读出来(文件打不开第二次)。'];
}
$chapters = [];
$seq = 0;
foreach ($spine as $href) {
if (count($chapters) >= self::CHAPTER_LIMIT) {
break;
}
$full = self::joinPath($opfDir, $href);
$raw = $zip2->getFromName($full);
// ⚠️ 有些书的 spine 里写了 HTML 实体或大小写不一致 → 退一步按名字再试
if ($raw === false) {
$raw = $zip2->getFromName(ltrim($href, '/'));
}
if ($raw === false || $raw === '') {
continue;
}
[$title, $body] = self::xhtmlToText($raw);
if (trim($body) === '') {
continue;
}
$seq++;
$chapters[] = [
'seq' => $seq,
'title' => $title !== '' ? $title : ('第 ' . $seq . ' 章'),
'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8'),
];
}
$zip2->close();
return ['ok' => true, 'error' => '', 'meta' => $meta, 'chapters' => $chapters, 'cover' => $cover];
}
/** OPF 的 metadata:书名 / 作者 / 出版社 / 语言 / ISBN / 简介 */
private static function opfMeta(string $opf): array
{
$get = static function (string $tag) use ($opf): string {
if (preg_match_all('#<dc:' . $tag . '[^>]*>(.*?)</dc:' . $tag . '>#is', $opf, $m)) {
foreach ($m[1] as $v) {
$v = trim(html_entity_decode(strip_tags($v), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($v !== '') {
return $v;
}
}
}
return '';
};
$isbn = '';
if (preg_match_all('#<dc:identifier[^>]*>(.*?)</dc:identifier>#is', $opf, $mm)) {
foreach ($mm[0] as $i => $full) {
$v = trim(strip_tags($mm[1][$i]));
// opf:scheme="ISBN" 或 urn:isbn:xxxx
if (stripos($full, 'ISBN') !== false || stripos($v, 'isbn') === 0) {
$isbn = preg_replace('/^urn:isbn:/i', '', $v) ?? '';
break;
}
if ($isbn === '' && preg_match('/^(97[89])?\d{9}[\dXx]$/', $v)) {
$isbn = $v;
}
}
}
$date = $get('date');
return [
'title' => $get('title'),
'subtitle' => '',
'author' => $get('creator'),
'publisher' => $get('publisher'),
'language' => $get('language'),
'isbn' => $isbn,
'published' => $date !== '' ? substr($date, 0, 10) : '',
'summary' => $get('description'),
];
}
/** @return array<string,array{href:string,type:string,props:string}> id => 项 */
private static function opfManifest(string $opf): array
{
$out = [];
if (!preg_match_all('#<item\s+([^>]*?)/?>#is', $opf, $m)) {
return $out;
}
foreach ($m[1] as $attr) {
$a = self::attrs($attr);
$id = (string)($a['id'] ?? '');
if ($id === '') {
continue;
}
$out[$id] = [
'href' => html_entity_decode((string)($a['href'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'type' => strtolower((string)($a['media-type'] ?? '')),
'props' => strtolower((string)($a['properties'] ?? '')),
];
}
return $out;
}
/** @return string[] 按阅读顺序排的正文文件(相对 opf 目录) */
private static function opfSpine(string $opf, array $manifest): array
{
$ids = [];
if (preg_match('#<spine\b[^>]*>(.*?)</spine>#is', $opf, $m)) {
if (preg_match_all('#<itemref\s+([^>]*?)/?>#is', $m[1], $mm)) {
foreach ($mm[1] as $attr) {
$a = self::attrs($attr);
$id = (string)($a['idref'] ?? '');
if ($id !== '') {
$ids[] = $id;
}
}
}
}
$out = [];
foreach ($ids as $id) {
$it = $manifest[$id] ?? null;
if (!$it) {
continue;
}
$t = (string)$it['type'];
// 只收正文档(xhtml / html / xml);图片、css、ncx 跳过
if ($t !== '' && strpos($t, 'html') === false && strpos($t, 'xml') === false) {
continue;
}
$out[] = (string)$it['href'];
}
// spine 为空(不合规范)→ 退一步:按 manifest 里所有 html 收
if (!$out) {
foreach ($manifest as $it) {
$t = (string)$it['type'];
if (strpos($t, 'html') !== false) {
$out[] = (string)$it['href'];
}
}
}
return $out;
}
/** 封面:找出图并存到临时文件(调用方再交给 Upload) */
private static function epubCover(string $path, string $opf, string $opfDir, array $manifest): ?array
{
$href = '';
if (preg_match('#<meta\s+[^>]*name\s*=\s*["\']cover["\'][^>]*content\s*=\s*["\']([^"\']+)["\']#is', $opf, $m)) {
$id = trim($m[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
if ($href === '') {
if (preg_match('#<meta\s+[^>]*content\s*=\s*["\']([^"\']+)["\'][^>]*name\s*=\s*["\']cover["\']#is', $opf, $m2)) {
$id = trim($m2[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
}
if ($href === '') {
foreach ($manifest as $it) {
if (strpos((string)$it['props'], 'cover-image') !== false) {
$href = (string)$it['href'];
break;
}
}
}
if ($href === '') {
foreach ($manifest as $it) {
if (strpos((string)$it['type'], 'image/') === 0
&& stripos((string)$it['href'], 'cover') !== false) {
$href = (string)$it['href'];
break;
}
}
}
if ($href === '') {
return null;
}
if (!class_exists('ZipArchive')) {
return null;
}
$zip = new \ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$full = self::joinPath($opfDir, $href);
$bin = $zip->getFromName($full);
if ($bin === false) {
$bin = $zip->getFromName(ltrim($href, '/'));
}
$zip->close();
if ($bin === false || $bin === '') {
return null;
}
$tmp = tempnam(sys_get_temp_dir(), 'fbcov');
if ($tmp === false || @file_put_contents($tmp, $bin) === false) {
return null;
}
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
if (strpos($mime, 'image/') !== 0) {
@unlink($tmp);
return null;
}
return ['tmp' => $tmp, 'mime' => $mime];
}
/**
* XHTML → 纯文本(保留段落换行)。
*
* ⚠️ 不是"去标签"就完事:
* ① 先砍掉 script / style / head(不然 CSS 与 JS 会混进正文);
* ② 块级标签换成换行(<p><div><br><h*>),否则整章粘成一团;
* ③ 实体还原(&nbsp; &amp; …)之后再 strip_tags;
* ④ 连续空行压成一个 —— 电子书的源文件里空行多到能占一半篇幅。
*/
private static function xhtmlToText(string $html): array
{
$title = '';
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
$title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
$body = $html;
if (preg_match('#<body\b[^>]*>(.*)</body>#is', $body, $m2)) {
$body = $m2[1];
if ($title === '' && preg_match('#<h[1-4][^>]*>(.*?)</h[1-4]>#is', $body, $m3)) {
$title = trim(html_entity_decode(strip_tags($m3[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
}
$body = preg_replace('#<(script|style|noscript)\b[^>]*>.*?</\1>#is', '', $body) ?? $body;
$body = preg_replace('#<br\s*/?>#i', "\n", $body) ?? $body;
$body = preg_replace('#</(p|div|h[1-6]|li|tr|blockquote)>#i', "\n", $body) ?? $body;
$body = html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = str_replace(["\r\n", "\r", "\xC2\xA0"], ["\n", "\n", ' '], $body);
$body = preg_replace('/[ \t]+\n/', "\n", $body) ?? $body;
$body = preg_replace('/\n{3,}/', "\n\n", $body) ?? $body;
$body = preg_replace('/^\n+|\n+$/', '', $body) ?? $body;
return [mb_substr($title, 0, 300, 'UTF-8'), $body];
}
// ---------------- TXT ----------------
private static function parseTxt(string $path, string $name): array
{
$raw = (string)@file_get_contents($path);
if ($raw === '') {
return ['ok' => false, 'error' => '这个文本文件是空的', 'meta' => [], 'chapters' => []];
}
$text = self::toUtf8($raw);
$chapters = self::splitChapters($text);
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name), 'chapters' => $chapters];
}
/**
* 编码兜底:UTF-8 → GB18030(中文电子书最常见的老编码)→ BIG5 → Latin1。
* ⚠️ 不认编码就硬读,中文会整页乱码 —— 那是"看起来解析了、其实全是问号"。
*/
private static function toUtf8(string $raw): string
{
if (substr($raw, 0, 3) === "\xEF\xBB\xBF") {
return substr($raw, 3);
}
if (mb_check_encoding($raw, 'UTF-8')) {
return $raw;
}
foreach (['GB18030', 'BIG-5', 'ISO-8859-1'] as $enc) {
$s = @iconv($enc, 'UTF-8//IGNORE', $raw);
if ($s !== false && $s !== '') {
return $s;
}
}
return $raw;
}
/**
* 切章:认"第X章/第X节/序/前言/后记/Chapter N"这类行;
* 认不出来就按 5000 字一段(不然一本长篇会变成一整页,浏览器直接卡死)。
*/
private static function splitChapters(string $text): array
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
$lines = explode("\n", $text);
$re = '/^\s*(第\s*[0-9零一二三四五六七八九十百千万]+\s*[章节回部篇卷集]\s*'
. '|chapter\s+[0-9ivx]+\b'
. '|序章|序言|前言|自序|后记|附录|尾声|楔子)/iu';
$chapters = [];
$curTitle = '';
$buf = '';
$flush = static function () use (&$chapters, &$curTitle, &$buf): void {
$body = trim($buf);
if ($body !== '') {
$chapters[] = ['title' => $curTitle, 'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8')];
}
$buf = '';
};
foreach ($lines as $ln) {
$t = trim($ln);
if ($t !== '' && mb_strlen($t, 'UTF-8') <= 60 && preg_match($re, $t)) {
$flush();
$curTitle = mb_substr($t, 0, 200, 'UTF-8');
continue;
}
$buf .= $ln . "\n";
}
$flush();
if (count($chapters) <= 1) {
// 没切出来 → 按字数均分
$chapters = [];
$n = mb_strlen($text, 'UTF-8');
$size = 5000;
$parts = (int)max(1, ceil($n / $size));
for ($i = 0; $i < $parts; $i++) {
$seg = mb_substr($text, $i * $size, $size, 'UTF-8');
if (trim($seg) === '') {
continue;
}
$chapters[] = ['title' => '第 ' . ($i + 1) . ' 段', 'content' => $seg];
}
}
$out = [];
$seq = 0;
foreach ($chapters as $c) {
if (count($out) >= self::CHAPTER_LIMIT) {
break;
}
$seq++;
$out[] = ['seq' => $seq, 'title' => (string)$c['title'], 'content' => (string)$c['content']];
}
return $out;
}
// ---------------- 文件名猜书名 / 作者 ----------------
/**
* 从 "三体(全三册)- 刘慈欣.epub" 这类文件名里猜。
* ⚠️ 只是**兜底**:猜错了用户一眼能改,比空着强(空着要一个字一个字敲)。
*/
private static function guessFromName(string $name): array
{
$base = pathinfo((string)$name, PATHINFO_FILENAME);
$base = trim(preg_replace('/[\[\((【].*?[\]\))】]/u', '', $base) ?? $base);
$author = '';
$title = $base;
foreach ([' - ', '—', '-', '_'] as $sep) {
if (strpos($base, $sep) !== false) {
$parts = explode($sep, $base);
$title = trim((string)array_pop($parts));
$author = trim(implode(' ', $parts));
break;
}
}
// "作者 书名" 反过来也常见:名字很短的那一段更像作者
if ($author === '' && preg_match('/^(.+?)\s{1}(.+)$/u', $base, $m)) {
$author = trim($m[1]);
$title = trim($m[2]);
}
$title = trim(preg_replace('/\.(epub|txt|pdf|mobi|azw3?)$/i', '', $title) ?? $title);
return [
'title' => mb_substr($title, 0, 300, 'UTF-8'),
'subtitle' => '',
'author' => mb_substr($author, 0, 200, 'UTF-8'),
'publisher' => '',
'language' => '',
'isbn' => '',
'published' => '',
'summary' => '',
];
}
public static function formatWord(string $ext): string
{
$map = ['epub' => 'EPUB', 'txt' => 'TXT', 'pdf' => 'PDF', 'mobi' => 'MOBI',
'azw' => 'AZW', 'azw3' => 'AZW3', 'doc' => 'DOC', 'docx' => 'DOCX'];
return $map[strtolower($ext)] ?? strtoupper($ext);
}
/** 这个文件能不能直接放进浏览器内嵌阅读(PDF 就是) */
public static function inlinable(?string $fmt): bool
{
return strtolower((string)$fmt) === 'pdf';
}
// ---------------- 小工具 ----------------
/** @return array<string,string> */
private static function attrs(string $s): array
{
$out = [];
if (preg_match_all('/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(["\'])(.*?)\2/s', $s, $m, PREG_SET_ORDER)) {
foreach ($m as $x) {
$out[strtolower($x[1])] = $x[3];
}
}
return $out;
}
private static function dirOf(string $p): string
{
$p = str_replace('\\', '/', $p);
$i = strrpos($p, '/');
return $i === false ? '' : substr($p, 0, $i + 1);
}
/** 把 opf 目录与 href 拼成 zip 内的完整路径(顺手去掉 ../ 与锚点) */
private static function joinPath(string $dir, string $href): string
{
$href = preg_replace('/#.*$/', '', $href) ?? $href;
$href = str_replace('\\', '/', trim(urldecode($href)));
$full = $dir . $href;
// 消掉 a/b/../c
$parts = [];
foreach (explode('/', str_replace('\\', '/', $full)) as $seg) {
if ($seg === '' || $seg === '.') {
continue;
}
if ($seg === '..') {
array_pop($parts);
continue;
}
$parts[] = $seg;
}
return implode('/', $parts);
}
}
+36 -11
View File
@@ -327,11 +327,18 @@ final class Home
);
$st->execute([$familyId]);
$tripN = (int)$st->fetchColumn();
// ⚠️ 用户第 15 批 第 2 条:活动自己没有时间了 —— "最近去过哪"要按
// **各段旅程里最晚的那个时间**排,不能再看 e.starts_at(那列已经不用了,
// 恒为 NULL,等于每次取到的都是"最后插入的那条",看起来像随机)。
$st = Database::get()->prepare(
"SELECT e.title FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1
ORDER BY e.starts_at DESC, e.id DESC LIMIT 1"
ORDER BY GREATEST(
COALESCE((SELECT MAX(n.starts_at) FROM trip_nodes n
WHERE n.event_id = e.id AND n.stage IN ('plan','actual')), '1000-01-01'),
'1000-01-01') DESC,
e.id DESC LIMIT 1"
);
$st->execute([$familyId]);
$lastTrip = (string)($st->fetchColumn() ?: '');
@@ -340,8 +347,8 @@ final class Home
'icon' => self::icon('trips'),
'color' => Module::color('trips', $familyId),
'href' => url('trips'),
'big' => $tripN . ' 趟',
'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有旅程记录'],
'big' => $tripN . ' 次',
'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有活动记录'],
];
// ---- 日程 ----
@@ -372,15 +379,16 @@ final class Home
];
// ---- 任务 ----
$board = Task::board($familyId, $me);
$doing = count($board['doing']);
$todo = count($board['todo']);
// ⚠️ v24 第 14 批 用户第 2 条:**按状态算,不看板算**。
// 看板是"这件事属于哪摊事",状态是"做到哪一步";首页关心的是后者,
// 所以这里用 Task::byStatus()(跨看板)—— 用 board() 只会数到默认看板里的。
$doing = count(Task::byStatus($familyId, $me, 'doing'));
$todo = count(Task::byStatus($familyId, $me, 'todo'));
$late = 0;
foreach ([$board['todo'], $board['doing']] as $col) {
foreach ($col as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$late++;
}
foreach (array_merge(Task::byStatus($familyId, $me, 'doing'),
Task::byStatus($familyId, $me, 'todo')) as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$late++;
}
}
$cards['tasks'] = [
@@ -421,6 +429,21 @@ final class Home
'lines' => [$hasBook ? count($books) . ' 本账(净资产)' : '先建一本家庭账'],
];
// ---- 读书(v24 第 14 批 用户第 5 条)----
$bs = Book::stats($familyId);
$reading = (int)($bs['status']['reading'] ?? 0);
$doneN = (int)($bs['status']['done'] ?? 0);
$cards['books'] = [
'name' => '读书',
'icon' => self::icon('books'),
'color' => Module::color('books', $familyId),
'href' => url('books'),
'big' => $bs['total'] > 0 ? ($reading > 0 ? $reading . ' 本在读' : $doneN . ' 本读过') : '空书架',
'lines' => [$bs['total'] > 0
? ($doneN . ' 本读完 · ' . (int)$bs['notes'] . ' 条笔记')
: '传一本进来,或先记下想读的书'],
];
return $cards;
}
@@ -437,6 +460,8 @@ final class Home
'tasks' => '<rect x="2.4" y="2.4" width="13.2" height="13.2" rx="2"/><path d="M5.6 9.2l2.4 2.4 4.4-4.8"/>',
'days' => '<path d="M9 2.2l2 4.2 4.6.6-3.4 3.2.8 4.6L9 12.6l-4 2.2.8-4.6L2.4 7l4.6-.6z"/>',
'ledger' => '<rect x="2.4" y="3" width="13.2" height="12" rx="2"/><path d="M5.6 6.6h6.8M5.6 9.4h6.8M5.6 12.2h4"/>',
// 读书:一本摊开的书(两页 + 书脊)
'books' => '<path d="M2.2 4.2A1.6 1.6 0 0 1 3.8 2.6h3.4c.9 0 1.8.4 1.8 1.3v10.2c0-.9-.9-1.3-1.8-1.3H3.8a1.6 1.6 0 0 1-1.6-1.6z"/><path d="M15.8 4.2a1.6 1.6 0 0 0-1.6-1.6h-3.4c-.9 0-1.8.4-1.8 1.3v10.2c0-.9.9-1.3 1.8-1.3h3.4a1.6 1.6 0 0 0 1.6-1.6z"/>',
];
$d = $paths[$key] ?? '';
if ($d === '') {
+1 -1
View File
@@ -29,7 +29,7 @@ final class HomeMods
* 比默认导航条短一些:首页是"概览",把九个模块都铺上去就成了一面墙。
* 选的是"每天想看的那几个"。
*/
public const DEFAULT_HOME = ['tasks', 'calendar', 'days', 'movies', 'trips', 'ledger'];
public const DEFAULT_HOME = ['tasks', 'calendar', 'days', 'movies', 'trips', 'ledger', 'books'];
/** 这个人首页上要显示哪些模块(按顺序) */
public static function forPerson(int $personId): array
+22 -3
View File
@@ -68,9 +68,28 @@ final class ImageSlot
'dir' => 'photos',
'hint' => '现场照片不裁剪,等比缩到最长边 1600。',
],
// 题图按 16:9 —— 宽屏横幅。需要时把它启用即可:
// 'cover' => ['label' => '题图', 'ratio' => '16:9', 'w' => 1920, 'h' => 1080,
// 'dir' => 'covers', 'hint' => '宽屏横幅,显示在详情页顶部。'],
// v24 第 14 批(用户第 5 条):**书的封面**。与海报一样是竖版(常见 2:3),
// 单独一个槽位而不是复用 poster —— 以后想给书封面换个尺寸/目录,
// 改这一行就行,不会连观影海报一起改了。
'bookcover' => [
'label' => '封面',
'ratio' => '2:3',
'w' => 600,
'h' => 900,
'dir' => 'bookcovers',
'hint' => '书的封面是竖版,按 2:3 裁。拖动图片挪位置,滚轮缩放。',
],
// v24 第 14 批(用户第 3 条):旅程活动的**题图**,16:9 宽屏横幅,可裁剪。
// ⚠️ 与头像同一套机制:比例写在这一处,前端裁剪框与服务端最终裁切都读它,
// 前端**不许**再自己写一个数字('16:9' 用 parseFloat 会得到 16 → 裁成 16:1)。
'cover' => [
'label' => '题图',
'ratio' => '16:9',
'w' => 1600,
'h' => 900,
'dir' => 'covers',
'hint' => '宽屏横幅,按 16:9 裁。拖动图片挪位置,滚轮缩放。',
],
];
public static function exists(string $key): bool
+4 -1
View File
@@ -45,6 +45,8 @@ final class Module
'days' => ['纪念日', '#B4633C', 'days'],
// v24 第 15 条:财务(账本 + 账目 + 共享)
'ledger' => ['财务', '#5E7A3C', 'ledger'],
// v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计)
'books' => ['读书', '#6B5B95', 'books'],
];
/**
@@ -54,7 +56,8 @@ final class Module
* "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来),
* 只是不再占顶栏的位置。
*/
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks', 'days', 'ledger'];
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources',
'tasks', 'days', 'ledger', 'books'];
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
+85 -1
View File
@@ -80,7 +80,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '19';
public const VERSION = '20';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -646,6 +646,90 @@ CREATE TABLE IF NOT EXISTS `regions` (
PRIMARY KEY (`code`),
KEY `idx_region_parent` (`parent_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='全国行政区划(省/市/区县三级):地址结构化与导入时对齐用'
SQL,
// ---- 读书(版本 20,用户 2026-10-05 第 14 批第 5 条)----
// 像 calibre 那样管自己的书:书架 + 上传/导入 + 在线读 + 读完 + 笔记 + 统计。
//
// 三张表,各管一件事,**不要**合并:
// · books —— 书本身(元数据 + 文件 + 读到哪 + 读完没读完)
// · book_chapters—— 解析出来的正文(一本书几十上百段,单独一张表,
// 否则列表页 SELECT * 会把整本书一起捞出来)
// · book_notes —— 笔记 / 划线 / 感想,**按人**记(同一个家里两个人读
// 同一本书,各自的笔记不能混)
<<<'SQL'
CREATE TABLE IF NOT EXISTS `books` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED DEFAULT NULL COMMENT '谁在读(NULL = 全家共用一本)',
`creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁把它加进书架的',
`title` VARCHAR(300) NOT NULL,
`subtitle` VARCHAR(300) DEFAULT NULL,
`author` VARCHAR(200) DEFAULT NULL,
`translator` VARCHAR(200) DEFAULT NULL,
`publisher` VARCHAR(120) DEFAULT NULL,
`published` VARCHAR(40) DEFAULT NULL COMMENT '出版时间(自由文本:2012 / 2012-05 / 2012年5月)',
`isbn` VARCHAR(40) DEFAULT NULL,
`language` VARCHAR(40) DEFAULT NULL,
`category` VARCHAR(80) DEFAULT NULL COMMENT '分类(自由文本,家庭自己约定叫法)',
`tags` VARCHAR(300) DEFAULT NULL COMMENT '标签,逗号分隔',
`summary` TEXT DEFAULT NULL COMMENT '内容简介',
`cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面(走 media.php 代理,与头像同一套)',
`file_key` VARCHAR(255) DEFAULT NULL COMMENT '原书文件(books 目录下的相对键,如 books/xxx.epub)',
`file_name` VARCHAR(255) DEFAULT NULL COMMENT '上传时的文件名(下载时用)',
`file_format` VARCHAR(20) DEFAULT NULL COMMENT 'epub / txt / pdf / mobi / azw3 …',
`file_size` INT UNSIGNED DEFAULT NULL,
`pages` INT UNSIGNED DEFAULT NULL COMMENT '页数(有就填,用于统计)',
`words` INT UNSIGNED DEFAULT NULL COMMENT '字数(解析时估算)',
`chapter_count` INT NOT NULL DEFAULT 0 COMMENT '解析出多少章(0 = 没解析过)',
`status` ENUM('wish','reading','done','paused') NOT NULL DEFAULT 'wish'
COMMENT '想读 / 在读 / 读完 / 搁置',
`rating` TINYINT DEFAULT NULL COMMENT '评分 0-10(NULL = 还没打分)',
`progress` INT NOT NULL DEFAULT 0 COMMENT '读到第几章(0 = 还没开始)',
`started_at` DATE DEFAULT NULL,
`finished_at` DATE DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_book_family` (`family_id`, `status`, `updated_at`),
KEY `idx_book_owner` (`family_id`, `owner_id`),
CONSTRAINT `fk_book_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_book_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_book_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书架:一本书一行(元数据 + 文件 + 读到哪 + 读完没读完)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `book_chapters` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`book_id` INT UNSIGNED NOT NULL,
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几章(从 1 开始)',
`title` VARCHAR(300) DEFAULT NULL,
`content` MEDIUMTEXT DEFAULT NULL COMMENT '正文(已去标签的纯文本,段落之间换行)',
`words` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_bc_book` (`book_id`, `seq`),
CONSTRAINT `fk_bc_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书的正文(解析出来的章节)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `book_notes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`book_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁的笔记(导入时可能不知道是谁 → NULL)',
`kind` ENUM('quote','note','review') NOT NULL DEFAULT 'note'
COMMENT 'quote=划线/原文摘录,note=想法/笔记,review=书评',
`chapter` VARCHAR(200) DEFAULT NULL COMMENT '在哪一章(导入时带得上的话)',
`loc` VARCHAR(80) DEFAULT NULL COMMENT '位置(页数 / 百分比 / 微信读书的定位串)',
`content` TEXT NOT NULL,
`source` VARCHAR(40) NOT NULL DEFAULT 'manual' COMMENT 'manual / weread / douban / …(哪来的)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_bn_book` (`book_id`, `created_at`),
KEY `idx_bn_person` (`person_id`),
CONSTRAINT `fk_bn_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='读书笔记 / 划线 / 书评(按人记)'
SQL,
];
}
+49
View File
@@ -346,6 +346,49 @@ final class Task
return $out;
}
/**
* 我能看到的、处于某个状态的**父任务**(**跨看板**)。
*
* v24 第 14 批 用户第 2 条原话:「正在进行的状态的任务,应该在首页中显示。
* 也就是说,首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关。」
*
* ⚠️ 与 board() 的分工:board() 是"某一块看板里有什么"(列 = 看板,按 board_id 过滤);
* 这个是"处于某个状态的所有事"(**不看 board_id**)——
* 一个人在"家务""装修"两个看板上各有一件进行中的事,首页就该看到两件。
* 之前首页直接调 board()(它只取默认看板),于是别的看板上的事**永远不会出现在首页**。
*
* @param string $status todo / doing / done
*/
public static function byStatus(int $familyId, int $personId, string $status): array
{
$status = self::status($status);
$st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick,
c.name AS category_name,
b.name AS board_name,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done
FROM tasks t
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
LEFT JOIN task_categories c ON c.id = t.category_id
LEFT JOIN task_boards b ON b.id = t.board_id
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY COALESCE(t.due_at, ?) ASC, t.position, t.id'
);
$st->execute([$personId, $personId, $familyId, $status, $personId, $personId, '9999-12-31 00:00:00']);
$rows = $st->fetchAll();
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
foreach ($rows as &$r) {
$r['children'] = $children[(int)$r['id']] ?? [];
}
unset($r);
return $rows;
}
/** @return array<int,array> 父任务 id => 子任务列表 */
public static function childrenFor(array $parentIds, int $viewerId = 0): array
{
@@ -778,6 +821,12 @@ final class Task
return self::STATUS[$k] ?? '待办';
}
/** 把任意输入归一化成合法状态(路由里写提示文案用:不能拿用户传的原文去查词) */
public static function normalizeStatus(string $v): string
{
return self::status($v);
}
public static function repeatWord(?string $k): string
{
$k = (string)$k;
+291 -19
View File
@@ -85,7 +85,159 @@ final class Trip
return in_array($key, self::STAGE_ON_CALENDAR, true);
}
/** 全部旅程(按出发时间倒序) */
// ==================== 活动的时间与目的地:**从旅程派生** ====================
//
// 用户原话(2026-10-05 第 15 批 第 2 条):
// 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,
// 和最晚的旅程结束时间,计算出来的。目的地也不需要,因为一次活动,
// 可能去好几个城市,所以去的城市,也是根据旅程,得到的。」
//
// ⚠️ 这三样**不再是活动的字段**(表单里删了),一律读时算出来:
// · 开始 = 最早一段旅程的 starts_at
// · 结束 = 最晚一段旅程的 ends_at(缺 ends_at 就退回它的 starts_at)
// · 城市 = 各段旅程 place 的去重集合
// 理由与"参与人是合集"完全同一条:同一份数据存两处,迟早对不上。
// 自己填的时间和行程里的时间一旦打架,看的人不知道该信哪个。
//
// ⚠️ 老数据里 events.starts_at / location 可能是有值的 —— **保留不动**
// (万一将来要回退),但页面一律不读它们,只读派生值。
// 迁移成本为 0,而"删列"是不可逆的。
/**
* 一条活动的派生时间与城市(单条详情用)。
*
* @return array{starts_at:?string, ends_at:?string, cities:string[], city:string}
* city 是给"一行文字"用的拼法(如「青岛 · 威海 → 济南」)。
*/
public static function derived(int $eventId): array
{
$st = Database::get()->prepare(
"SELECT MIN(n.starts_at) AS start_at,
MAX(COALESCE(n.ends_at, n.starts_at)) AS end_at
FROM trip_nodes n
WHERE n.event_id = ? AND n.stage IN ('plan','actual')"
);
$st->execute([$eventId]);
$r = $st->fetch() ?: [];
$cities = self::cities($eventId);
return [
'starts_at' => self::dtOrNull((string)($r['start_at'] ?? '')),
'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')),
'cities' => $cities,
'city' => self::citiesWord($cities),
];
}
/**
* 一条活动去过的城市(各段旅程 place 去重,按**最早出现的时间**排)。
*
* ⚠️ 顺序按时间排而不是按字母/插入顺序:
* "青岛 · 威海" 让人一眼看出先后,排成字母序就完全没有这层信息了。
* ⚠️ skipped(未成行)的**不算去过** —— 计划去但没去成的地方不该出现在
* "去过哪几个城市"里(这与"不上日程、不进往年今日"是同一条口径)。
*/
public static function cities(int $eventId): array
{
// ⚠️ ORDER BY 里**不能**写聚合别名(first_at):MySQL/MariaDB 会报
// `Reference 'first_at' not supported (reference to group function)` ——
// 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。
// 写成 `ORDER BY MIN(n.starts_at), n.place` 就没问题(聚合函数本身可用)。
$st = Database::get()->prepare(
"SELECT n.place, MIN(n.starts_at) AS first_at
FROM trip_nodes n
WHERE n.event_id = ? AND n.stage IN ('plan','actual')
AND n.place IS NOT NULL AND n.place <> ''
GROUP BY n.place
ORDER BY MIN(n.starts_at), n.place"
);
$st->execute([$eventId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$p = trim((string)$r['place']);
if ($p !== '' && !in_array($p, $out, true)) {
$out[] = mb_substr($p, 0, 60, 'UTF-8');
}
}
return $out;
}
/**
* 城市列表 → 一行字。
*
* 用「 · 」分隔而不是顿号:城市名里本来就可能含顿号或逗号
* ("海拉尔、满洲里"这种写法真有人用),混在一起会分不清有几个城市。
*/
public static function citiesWord(array $cities): string
{
return implode(' · ', $cities);
}
/**
* 给**一批**活动补上派生时间与城市(列表页用;N+1 会让几十趟活动明显变慢)。
*
* ⚠️ 与 attachAttendees 同一思路:一次查询按 event_id 分桶,再挂回每行。
*/
public static function attachDerived(array &$rows): void
{
$ids = [];
foreach ($rows as $r) {
$id = (int)($r['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}
$span = [];
$city = [];
foreach ($ids as $id) {
$span[$id] = ['starts_at' => null, 'ends_at' => null];
$city[$id] = [];
}
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT event_id, MIN(starts_at) AS start_at,
MAX(COALESCE(ends_at, starts_at)) AS end_at
FROM trip_nodes
WHERE event_id IN ($in) AND stage IN ('plan','actual')
GROUP BY event_id"
);
$st->execute($ids);
foreach ($st->fetchAll() as $r) {
$id = (int)$r['event_id'];
$span[$id] = [
'starts_at' => self::dtOrNull((string)($r['start_at'] ?? '')),
'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')),
];
}
// ⚠️ 同 cities():ORDER BY 只写聚合函数,不写别名
$st2 = Database::get()->prepare(
"SELECT event_id, place, MIN(starts_at) AS first_at
FROM trip_nodes
WHERE event_id IN ($in) AND stage IN ('plan','actual')
AND place IS NOT NULL AND place <> ''
GROUP BY event_id, place
ORDER BY event_id, MIN(starts_at), place"
);
$st2->execute($ids);
foreach ($st2->fetchAll() as $r) {
$id = (int)$r['event_id'];
$p = mb_substr(trim((string)$r['place']), 0, 60, 'UTF-8');
if ($id > 0 && $p !== '' && !in_array($p, $city[$id], true)) {
$city[$id][] = $p;
}
}
}
foreach ($rows as &$r) {
$id = (int)$r['id'];
$r['starts_at'] = $span[$id]['starts_at'] ?? null;
$r['ends_at'] = $span[$id]['ends_at'] ?? null;
$r['cities'] = $city[$id] ?? [];
$r['city'] = self::citiesWord($r['cities']);
}
unset($r);
}
/** 全部旅程(按**派生**的出发时间倒序 —— 活动自己没有时间字段了) */
public static function all(int $familyId, string $keyword = ''): array
{
// ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。
@@ -93,9 +245,24 @@ final class Trip
// 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程
// 从列表里消失("行程丢了"比"多显示一条"严重得多),
// COALESCE(...,1) 表示"没有这行就算公开"。
// ⚠️ 参与人 = **所有行程参与人的合集**(v24 第 14 批 用户第 4 条):
// 只在活动上登记、每段行程都没填成员的人也要算进来(否则老数据会"人没了")。
// ⚠️ e.starts_at / e.ends_at / e.location 这三列**仍然 SELECT 出来**,
// 但马上会被 attachDerived() 的派生值覆盖(用户第 15 批 第 2 条)。
// 留着 select 是为了:① 不 SELECT 的话 PHP 侧少键,若哪天有代码先读再做
// 兜底,会出现 Undefined index;② 出问题时肉眼能对着看"老值是什么"。
$sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note,
d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count,
/* ⚠️ 不能写成 SELECT COUNT(*) FROM (… WHERE a.event_id = e.id
UNION …) u:派生表里的 e.id 是**外层**列,MySQL 8 之前
不支持(要 LATERAL),MariaDB 也一样 —— 直接报列不存在。
所以拆成两段相加:活动上登记的 + 行程里**没重复**的那些。 */
((SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id)
+ (SELECT COUNT(DISTINCT m.person_id) FROM trip_node_members m
JOIN trip_nodes n2 ON n2.id = m.node_id
WHERE n2.event_id = e.id
AND m.person_id NOT IN (SELECT a2.person_id FROM event_attendees a2
WHERE a2.event_id = e.id))) AS attendee_count,
(SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count
FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id
@@ -103,21 +270,32 @@ final class Trip
$bind = [$familyId, 'trip'];
$kw = trim($keyword);
if ($kw !== '') {
$sql .= ' AND (e.title LIKE ? OR e.location LIKE ? OR e.note LIKE ?)';
// ⚠️ 搜索里的时间/城市也是**派生**的:
// 活动自己的 location 已经不用了(用户第 2 条),
// 搜城市必须搜到"在行程里填过的地方",否则搜"青岛"搜不到那趟青岛之行。
$sql .= ' AND (e.title LIKE ? OR e.note LIKE ?
OR EXISTS (SELECT 1 FROM trip_nodes n3
WHERE n3.event_id = e.id AND n3.place LIKE ?))';
$like = '%' . $kw . '%';
array_push($bind, $like, $like, $like);
}
$sql .= ' ORDER BY e.starts_at DESC, e.id DESC';
// 排序也走派生时间:子查询里没有行程时得排到最后(NULL)
$sql .= ' ORDER BY (SELECT MIN(n4.starts_at) FROM trip_nodes n4
WHERE n4.event_id = e.id AND n4.stage IN ("plan","actual")) DESC,
e.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($bind);
return $st->fetchAll();
$rows = $st->fetchAll();
// 派生时间与城市:一次查询批量补(见 attachDerived 的说明)
self::attachDerived($rows);
return $rows;
}
/**
* 给一批旅程补上「同行的人」。
* 给一批旅程补上「同行的人」= **所有行程参与人的合集**(用户第 4 条)。
*
* ⚠️ 必须一次性补(N+1 会让列表在几十趟旅程时明显变慢,观影就是这么踩的)。
* 一次查询拿全部 event_attendees,再按 event_id 分桶挂回去。
* 一次查询拿全部(活动登记 ∪ 各段行程成员),再按 event_id 分桶挂回去。
*/
public static function attachAttendees(array &$rows, int $viewerId = 0): void
{
@@ -134,14 +312,23 @@ final class Trip
}
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
// 合集:**活动上登记的人** ∪ **每段行程的参与人**(用户第 4 条)。
// UNION 自带去重,所以同一个人既在活动上、又在三段行程里也只出现一次。
$st = Database::get()->prepare(
'SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM event_attendees a JOIN persons p ON p.id = a.person_id
'SELECT u.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM (
SELECT a.event_id, a.person_id FROM event_attendees a
WHERE a.event_id IN (' . $in . ')
UNION
SELECT nd.event_id, m.person_id FROM trip_node_members m
JOIN trip_nodes nd ON nd.id = m.node_id
WHERE nd.event_id IN (' . $in . ')
) u
JOIN persons p ON p.id = u.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE a.event_id IN (' . $in . ')
ORDER BY a.position, p.id'
ORDER BY u.event_id, p.id'
);
$st->execute(array_merge([$viewerId], $ids));
$st->execute(array_merge($ids, $ids, [$viewerId]));
foreach ($st->fetchAll() as $a) {
$byEvent[(int)$a['event_id']][] = $a;
}
@@ -167,11 +354,72 @@ final class Trip
if (!$row) {
return null;
}
$row['attendees'] = self::attendees($id, $viewerId);
// 参与人 = 所有行程参与人的**合集**(用户第 4 条),不是只在活动上登记的那几个
$row['attendees'] = self::tripPeople($id, $viewerId);
$row['photos'] = \App\Attachment::list('trip', $id);
// 时间与城市 = 从各段旅程派生(用户第 15 批 第 2 条)。
// ⚠️ **覆盖**掉 events 上的老值:老数据可能自己填过起止与目的地,
// 但页面必须以行程为准 —— 否则会出现"活动写着 8/1–8/5,行程只有 8/3 一段"
// 这种自相矛盾的显示,而看的人无从判断该信哪个。
$d = self::derived($id);
$row['starts_at'] = $d['starts_at'];
$row['ends_at'] = $d['ends_at'];
$row['cities'] = $d['cities'];
$row['city'] = $d['city'];
$row['location'] = $d['city']; // 模板里还有读 location 的地方,一并给派生值
return $row;
}
/**
* 一趟旅程的参与人 = **所有行程(节点)参与人的合集**(v24 第 14 批 用户第 4 条)。
*
* 用户原话:「一个活动的参与人,应该是这个活动下的所有旅行(程)的参与人的合集才对。」
*
* 为什么不在保存行程时"顺手同步"到活动上:
* 那就是同一份名单存两处,改行程要记得改活动、删行程要记得减人 ——
* 任何一处漏了就对不上。合求是**读的时候算出来的**,永远不会漂移。
*
* ⚠️ 取的是**并集**(活动上单独登记的人也保留):
* 老数据里很多人只在活动上登记过、每段行程没填成员,
* 只按行程算会让这些人"凭空消失"。
*
* @return array 去重后的人(带「我叫他什么」与主色)
*/
public static function tripPeople(int $eventId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT DISTINCT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM (
SELECT a.person_id FROM event_attendees a WHERE a.event_id = ?
UNION
SELECT m.person_id FROM trip_node_members m
JOIN trip_nodes nd ON nd.id = m.node_id
WHERE nd.event_id = ?
) u
JOIN persons p ON p.id = u.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
ORDER BY p.id'
);
$st->execute([$eventId, $eventId, $viewerId]);
return $st->fetchAll();
}
/** 同上,但只给一个**人数**(列表页用,避免为每行再拉一堆人) */
public static function tripPeopleCount(int $eventId): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM (
SELECT a.person_id FROM event_attendees a WHERE a.event_id = ?
UNION
SELECT m.person_id FROM trip_node_members m
JOIN trip_nodes nd ON nd.id = m.node_id
WHERE nd.event_id = ?
) u'
);
$st->execute([$eventId, $eventId]);
return (int)$st->fetchColumn();
}
/** 同行的人(带「我叫他什么」与主色,页面按人画标签) */
public static function attendees(int $eventId, int $viewerId = 0): array
{
@@ -190,13 +438,16 @@ final class Trip
public static function create(int $familyId, ?int $creatorId, array $d): int
{
$db = Database::get();
// ⚠️ 用户第 15 批 第 2 条:活动**不存**起止时间与目的地 ——
// 它们从各段旅程派生(见 derived())。这里显式写 NULL,
// 不接表单传来的值(表单里也没有这些字段了)。
// events.starts_at / ends_at / location 保持可空,只是为了不动表结构。
$db->prepare(
"INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
VALUES (?, ?, 'trip', ?, ?, ?, ?, ?, ?)"
VALUES (?, ?, 'trip', ?, NULL, NULL, NULL, ?, ?)"
)->execute([
$familyId, $creatorId, (string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
self::textOrNull($d['note'] ?? ''),
$d['source_ref'] ?? null,
]);
$id = (int)$db->lastInsertId();
@@ -208,13 +459,15 @@ final class Trip
public static function update(int $familyId, int $id, array $d): void
{
$db = Database::get();
// ⚠️ 与 create 同理:**不碰** starts_at / ends_at / location。
// 连"置 NULL"都不做 —— 老数据里可能存过值,编辑一次活动不该把它抹掉
// (页面反正不读它们;真出问题时还能拿老值对着看)。
$db->prepare(
"UPDATE events SET title=?, starts_at=?, ends_at=?, location=?, note=?
"UPDATE events SET title=?, note=?
WHERE id=? AND family_id=? AND kind='trip'"
)->execute([
(string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
self::textOrNull($d['note'] ?? ''),
$id, $familyId,
]);
self::saveDetails($id, $d);
@@ -552,6 +805,25 @@ final class Trip
// ---------------- 展示 ----------------
/** 起止日期的人话展示(与 Movie::formatWatch 同款口径) */
/**
* 活动的时间文案(用户第 15 批 第 2 条:时间是从旅程算出来的)。
*
* ⚠️ 空的时候**不能**沿用 formatSpan 的「日期待补」:那是给"一段没填时间的行程"
* 用的短语,放在活动上会让人以为"这里本来该由我填、我忘了填"。
* 活动的时间本来就该由旅程决定 —— 没有旅程时要说清楚是**去加一段旅程**。
*/
public static function activitySpan(?string $start, ?string $end): string
{
if ($start === null || $start === '') {
return '还没有旅程';
}
$s = date('Y年n月j日', (int)strtotime($start));
if ($end === null || $end === '') {
return $s . ' 起';
}
return $s . ' – ' . date('Y年n月j日', (int)strtotime($end));
}
public static function formatSpan(?string $start, ?string $end): string
{
if ($start === null || $start === '') {