Files
FamilyBook/app/BookNoteImport.php
laoyang 7138314dc7 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 与线上逐一核对一致。
2026-10-05 10:58:17 +08:00

253 lines
9.9 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
}
}