用户原话四条,逐条落地:
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 与线上逐一核对一致。
253 lines
9.9 KiB
PHP
253 lines
9.9 KiB
PHP
<?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');
|
||
}
|
||
}
|