原文`、`### 划线` 之后的裸行 * · 想法 —— `>> 我的想法`、`### 笔记` / `### 想法` 之后的裸行 * * ⚠️ 只做**纯文本**:不解析 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'); } }