Files
FamilyBook/app/Book.php
T

639 lines
23 KiB
PHP
Raw Normal View History

<?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;
}
}