Files
FamilyBook/app/Book.php
T
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

639 lines
23 KiB
PHP
Raw 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 条):像 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;
}
}