Files
FamilyBook/app/Note.php
T
laoyang 37ba86321b v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享
前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:

1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
   `if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
   (实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
   有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
   `<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
   (项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
   已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .person),
   浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。
   `php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。
   另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。
4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」:
   新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON);
   渲染路径改走 notebooksCached()/readStatsCached()(永不联网);
   同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。
5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine),
   表单带 back 域 → 保存后回原来那一项。
   🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾,
   把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。
6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。
7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块),
   两者共用同一份命令表。
8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改);
   清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。
9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构);
   可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。
2026-10-08 10:54:43 +08:00

824 lines
35 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 第 27 批,用户 2026-10-07)。
*
* 用户原话:「增加个人笔记模块,参考思源和 obsidian,采用分块方式,支持图文混排,
* 注意,要轻量级,别太复杂,兼顾笔记和标签功能,哦,再参考一下 flomo」
*
* 三处参考各取一点,但**坚决不做重的东西**:
*
* · **flomo** —— 「一行一条、随手记、按标签回看」的轻量劲儿:
* 写的时候只有标题 + 正文 + 标签;列表是**时间轴卡片流**;标签一点即筛。
* · **Obsidian** —— 「双链、Markdown、本地真实文件夹」全部**不做**
* (那是编辑器与知识图谱的领域,一个家庭应用扛不动,也不是用户要的)。
* 只取它「一个条目里图文混排」的直觉。
* · **思源** —— 取「**块**」这个粒度:一条笔记由若干块组成,每块可以是
* 段落 / 图片 / 清单,可增删、可上下移。但**不做**块引用、块 ID 双链、
* 数据库视图这些重家伙。
*
* 于是落到数据模型上,一共三张表(都很薄):
*
* · `notes` —— 一条笔记 = 一张卡片(标题 + 可见性 + 归属人)
* · `note_blocks`—— 卡内的一块(kind: text / image / list,内容 + 排序)
* · `note_tags` —— 笔记 ↔ 标签(单层标签,一条可挂多个;**标签词本身不建表**,
* 用字符串直接存 —— 见 addTags() 的说明)
*
* 权限(用户拍板):
* 默认**私有**(只有作者自己看得到);单条可勾「共享给全家」,共享后在列表里
* 带一个标记。不做只读/读写分级 —— 那是资源/任务那类"协作物件"才需要的,
* 笔记是"我写的东西",别人只该看,不该改。
*
* ⚠️ 与既有模块的分工(别混):
* · `person_notes` —— **我对某个人的**私人备注(挂在名册那个人身上),不是这里;
* · `book_notes` —— 读书笔记(挂在书上);
* · `notes` —— **我自己的**笔记,与"某个人""某本书"无关。
*/
declare(strict_types=1);
namespace App;
final class Note
{
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
public const ATTACH = 'note';
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text)
*
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
* 把段落做的完善一点就可以了。」
* ⇒ 收成 `text` / `image` 两种。
* ⚠️ 老库里可能还有 `kind = 'list'` 的行 —— **不要**把它们当非法值删掉,
* 见 `cleanBlocks()` 里对历史 list 块的**转写**(转成 Markdown 列表文本)。
* 白名单只决定"新写入时允许什么",不决定"老数据怎么活"。 */
public const BLOCK_KINDS = ['text', 'image'];
/**
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
*
* 🔴 v28 第 12 条:从 60 提到 200 —— 现在正文支持 Markdown,
* 用户会把它当"一页文档"来写(原来更多是"随手记几句话")。
* ⚠️ 上限仍然要有:它是防"误贴一整本书"的护栏,不是体验设计。
*/
public const MAX_BLOCKS = 200;
public const MAX_TEXT = 20000;
/** 列表页默认显示多少条(超过给"加载更多") */
public const LIST_LIMIT = 30;
/**
* **可见性的唯一判据**(v29 第 9 条)。
*
* 「这条笔记是我写的」**或**「有一条指向我的共享记录」。
* ⚠️ 全站(列表 / 计数 / 详情 / 标签云)都拼这**同一个**串 ——
* 各写各的迟早出现"列表里看得到、点进去 404"这种鬼故事。
* ⚠️ 绑定参数顺序:调用方在它**前面**绑 `$viewer`(对应 n.person_id)。
* ⚠️ 用 EXISTS 而不是 JOIN:共享给多个人时 JOIN 会让同一条笔记出现多次。
*/
public const VIS_COND =
'(n.person_id = ? OR EXISTS (SELECT 1 FROM note_shares s
WHERE s.note_id = n.id AND s.person_id = ?))';
/** 共享权限的白名单(与 resources 同一套语义) */
public const PERMS = ['read', 'write'];
// =====================================================================
// 读
// =====================================================================
/**
* 我能看到的笔记 = 我自己的 + 别人共享给全家的。
*
* @param int $viewer 看的人
* @param string $keyword 标题/正文/标签里搜
* @param string $tag 只看这个标签('' = 不限)
* @param int $limit 0 = 不限
* @param int $offset 分页偏移
* @return array<int,array> 每行带 blocks(已按 position 排好)与 tags
*/
public static function all(
int $familyId,
int $viewer,
string $keyword = '',
string $tag = '',
int $limit = 0,
int $offset = 0
): array {
/* 🔴 v29 用户第 9 条:可见性判据换成**逐人共享**:
「我写的」**或**「有一条指向我的共享记录」。
⚠️ 这是全站唯一的可见性定义(列表 / 计数 / 详情 / 标签云都读它)——
v27 那个 `is_shared = 1`(共享给全家)已不再是判据,
升级时它被展开成逐人记录(见 Schema 的迁移)。
⚠️ 用 EXISTS 而不是 JOIN `note_shares`:JOIN 会让"共享给 3 个人"的
笔记在列表里出现 3 次(每条共享一行)。EXISTS 只判"有没有"。 */
$vis = self::VIS_COND;
$sql = 'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color,
CASE WHEN n.person_id = ? THEN 1 ELSE 0 END AS is_mine
FROM notes n
JOIN persons p ON p.id = n.person_id
WHERE n.family_id = ? AND ' . $vis;
// ⚠️ VIS_COND 里有**两个** `?`(可见性判据里 person_id 出现两次),
// 所以 $viewer 要绑两遍。漏一个会让后面的关键词绑定整体错位。
$bind = [$viewer, $familyId, $viewer, $viewer];
$kw = trim($keyword);
if ($kw !== '') {
// 正文在块里 —— 用 EXISTS 子查询搜块,避免 JOIN 出重复行
$sql .= ' AND (n.title LIKE ?
OR EXISTS (SELECT 1 FROM note_blocks b
WHERE b.note_id = n.id AND b.content LIKE ?)
OR EXISTS (SELECT 1 FROM note_tags t
WHERE t.note_id = n.id AND t.tag LIKE ?))';
$like = '%' . $kw . '%';
array_push($bind, $like, $like, $like);
}
$tg = trim($tag);
if ($tg !== '') {
$sql .= ' AND EXISTS (SELECT 1 FROM note_tags t WHERE t.note_id = n.id AND t.tag = ?)';
$bind[] = $tg;
}
// ⚠️ 按「更新时间」倒序:笔记是"最近记的/最近改的"才要优先看(同 flomo 的时间流)。
$sql .= ' ORDER BY n.updated_at DESC, n.id DESC';
if ($limit > 0) {
$sql .= ' LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset;
}
$st = Database::get()->prepare($sql);
$st->execute($bind);
$rows = $st->fetchAll();
if (!$rows) {
return [];
}
return self::attachChildren($rows);
}
/** 与 all() 同一套可见性条件,只为列表页说清"一共几条" */
public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int
{
$sql = 'SELECT COUNT(*) FROM notes n
WHERE n.family_id = ? AND ' . self::VIS_COND;
$bind = [$familyId, $viewer, $viewer];
$kw = trim($keyword);
if ($kw !== '') {
$sql .= ' AND (n.title LIKE ?
OR EXISTS (SELECT 1 FROM note_blocks b
WHERE b.note_id = n.id AND b.content LIKE ?)
OR EXISTS (SELECT 1 FROM note_tags t
WHERE t.note_id = n.id AND t.tag LIKE ?))';
$like = '%' . $kw . '%';
array_push($bind, $like, $like, $like);
}
$tg = trim($tag);
if ($tg !== '') {
$sql .= ' AND EXISTS (SELECT 1 FROM note_tags t WHERE t.note_id = n.id AND t.tag = ?)';
$bind[] = $tg;
}
$st = Database::get()->prepare($sql);
$st->execute($bind);
return (int)$st->fetchColumn();
}
/**
* 一次把 blocks / tags 挂到结果集上。
*
* ⚠️ 为什么不用 JOIN 一把梭:一条笔记有多块多标签,JOIN 出来是笛卡尔积,
* 还要在 PHP 里去重。分两次 IN 查询(块、标签各一次)反而是**两条 SQL
* 解决整页**,也不会有 N+1。
*/
private static function attachChildren(array $rows): array
{
$ids = array_map(static fn($r): int => (int)$r['id'], $rows);
$in = implode(',', array_map('intval', $ids));
$blocks = [];
$st = Database::get()->query(
"SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
);
foreach ($st->fetchAll() as $b) {
$b['html'] = self::blockHtml((string)$b['kind'], (string)$b['content']);
$blocks[(int)$b['note_id']][] = $b;
}
$tags = [];
$st = Database::get()->query(
"SELECT note_id, tag FROM note_tags WHERE note_id IN ($in) ORDER BY note_id, id"
);
foreach ($st->fetchAll() as $t) {
$tags[(int)$t['note_id']][] = (string)$t['tag'];
}
foreach ($rows as &$r) {
$id = (int)$r['id'];
$r['blocks'] = $blocks[$id] ?? [];
$r['tags'] = $tags[$id] ?? [];
/* v28 第 12 条:列表卡片要用摘要(定尺寸网格里放不下全文)。
摘要按 Markdown 去记号后取前 N 字 —— 卡片上不渲染 HTML,
这样列表页再长也不会因为 Markdown 变慢。 */
$r['excerpt'] = self::excerptOf($r);
}
unset($r);
return $rows;
}
/**
* 从一条笔记的块里生成**纯文本摘要**(列表卡片用)。
* 图片块计作 [图片],清单块取前几行,段落走 Markdown 去记号。
*/
private static function excerptOf(array $note, int $len = 110): string
{
$buf = [];
foreach ((array)($note['blocks'] ?? []) as $b) {
$kind = (string)$b['kind'];
$c = (string)$b['content'];
if ($kind === 'image') {
$buf[] = '[图片]';
} elseif ($kind === 'list') {
$buf[] = Markdown::excerpt($c, 60);
} else {
$buf[] = Markdown::excerpt($c, 200);
}
// 凑够长度就不再往下拼(长笔记没必要全量处理一遍)
if (mb_strlen(implode(' ', $buf), 'UTF-8') >= $len) {
break;
}
}
$txt = trim((string)preg_replace('/\s+/u', ' ', implode(' ', $buf)));
if ($txt === '') {
return '';
}
if (mb_strlen($txt, 'UTF-8') > $len) {
$txt = mb_substr($txt, 0, $len, 'UTF-8') . '…';
}
return $txt;
}
/** 单条(带权限判定)。看不到返回 null。 */
public static function find(int $familyId, int $id, int $viewer): ?array
{
$st = Database::get()->prepare(
'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color
FROM notes n JOIN persons p ON p.id = n.person_id
WHERE n.id = ? AND n.family_id = ? AND ' . self::VIS_COND);
// ⚠️ VIS_COND 两个 `?`,$viewer 绑两遍(顺序:id / family / person_id / person_id)
$st->execute([$id, $familyId, $viewer, $viewer]);
$r = $st->fetch();
if (!$r) {
return null;
}
$r['blocks'] = self::blocks($id);
$r['tags'] = self::tags($id);
$r['files'] = self::files($id); // v28:底部附件
$r['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0;
return $r;
}
/**
* 一条笔记的**底部附件**(v28 第 12 条:「再加上附件上传功能,
* 附件,都放在笔记最下面就行了。」)。
*
* ⚠️ 与**图片块**(kind=image)不是一回事,别混:
* · 图片块 = 插在正文中间、参与图文混排的图(用户第 12 条要的"图片和正文混排");
* · 底部附件 = 挂在笔记末尾的任意文件(PDF / 压缩包 / 表格…)。
* 两者共用 `attachments` 表(target_type='note'),靠这个顺序体现区别:
* 图片块在上传时就由 `claimUploads` 认领(target_id=真实笔记 id),
* 底部附件则由 `attach_add` 路由显式挂上来 —— **都是同一张表的行**,
* 所以删笔记时外键级联一把全部带走,不会留孤儿。
*
* 怎么区分两者(同一个 target_id 下):图片块的那些**已经作为 image 块的内容**
* 出现在 note_blocks 里;渲染底部附件时把它们排掉即可。
*
* @return array<int,array> attachments 行(按 position)
*/
public static function files(int $noteId): array
{
if ($noteId <= 0) {
return [];
}
// 先取出"用作图片块的那些 key",避免它们在底部附件里重复出现
$used = [];
foreach (self::blocks($noteId) as $b) {
if ((string)$b['kind'] === 'image') {
$used[(string)$b['content']] = true;
}
}
$out = [];
foreach (Attachment::list(self::ATTACH, $noteId) as $a) {
if (isset($used[(string)$a['key']])) {
continue;
}
$out[] = $a;
}
return $out;
}
/**
* 某条笔记的块(按位置)
*
* ⚠️ 这里给每个 text 块算好 `html`(Markdown 渲染结果)——**在模型层算一次**,
* 列表页与详情页共用,不必各自再调一遍 Markdown::render
* (两处各算一次,迟早有一处忘了转义或忘了渲染)。
*/
public static function blocks(int $noteId): array
{
$st = Database::get()->prepare(
'SELECT * FROM note_blocks WHERE note_id = ? ORDER BY position, id'
);
$st->execute([$noteId]);
$rows = $st->fetchAll();
foreach ($rows as &$r) {
$r['html'] = self::blockHtml((string)$r['kind'], (string)$r['content']);
}
unset($r);
return $rows;
}
/** 某条笔记的标签 */
public static function tags(int $noteId): array
{
$st = Database::get()->prepare('SELECT tag FROM note_tags WHERE note_id = ? ORDER BY id');
$st->execute([$noteId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[] = (string)$r['tag'];
}
return $out;
}
/**
* 我用过的全部标签 + 各自多少条(给侧边标签云用)。
*
* ⚠️ 统计范围 = **我能看到的**笔记(不带 tag 过滤),否则会出现
* "标签上写着 8 条,点进去只有 3 条"这种自相矛盾。
*/
public static function tagCloud(int $familyId, int $viewer): array
{
$st = Database::get()->prepare(
'SELECT t.tag, COUNT(DISTINCT n.id) AS n
FROM note_tags t JOIN notes n ON n.id = t.note_id
WHERE n.family_id = ? AND ' . self::VIS_COND . '
GROUP BY t.tag ORDER BY n DESC, t.tag ASC'
);
$st->execute([$familyId, $viewer, $viewer]);
return $st->fetchAll();
}
/** 写新笔记时给个标签联想:所有我用过的标签词(按人气) */
public static function tagSuggest(int $familyId, int $viewer, int $limit = 40): array
{
$rows = self::tagCloud($familyId, $viewer);
$out = [];
foreach ($rows as $r) {
$out[] = (string)$r['tag'];
if (count($out) >= $limit) {
break;
}
}
return $out;
}
// =====================================================================
// 写
// =====================================================================
/**
* 新建一条笔记。
*
* @param array $d title / blocks([{kind,content}]) / tags([string]) / is_shared
* @return int 新笔记 id(0 = 标题与正文都空,没建)
*/
public static function create(int $familyId, int $personId, array $d): int
{
$title = trim((string)($d['title'] ?? ''));
$blocks = self::cleanBlocks($d['blocks'] ?? []);
// 标题和正文都空 = 用户什么都没写 → 不建空壳(否则列表里会冒出白卡片)
if ($title === '' && !$blocks) {
return 0;
}
$db = Database::get();
// ⚠️ v29:新建时**一律不共享**(`is_shared` 恒 0)——
// 共享改成"保存完在详情页选人 + 权限"(与资源同一个交互),
// 新建表单里不再有那个勾选框。共享表记录由 setShare 单独维护。
$db->prepare(
'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at)
VALUES (?,?,?,0,NOW(),NOW())'
)->execute([
$familyId, $personId,
mb_substr($title, 0, 200, 'UTF-8'),
]);
$id = (int)$db->lastInsertId();
self::writeBlocks($id, $blocks);
self::replaceTags($id, $d['tags'] ?? []);
// 把这次上传的"草稿附件"(target_id=0)认领到新笔记上
self::claimUploads($familyId, $id, $blocks);
return $id;
}
/**
* 改一条笔记。
*
* 🔴 v29 用户第 9 条:改成**有 write 权限就能改** —— 用户要的是
* 「和资源类似…选择是只读还是可以读写」,那就得让"可读写"真的能写。
* v27 的"仅作者可改"(共享出去只给别人看)是当时只有"共享给全家"这一个
* 开关时的简化;现在有了逐人权限,就该按权限来。
* ⚠️ 删除**仍然只限作者**(见 destroy):删是不可逆的,
* "可读写"给的是编辑权,不是销毁权。这条差异要显式写在按钮上。
* @return bool 是否改到了(改不到 = 看不到 / 没有写权限)
*/
public static function update(int $familyId, int $id, int $personId, array $d): bool
{
$db = Database::get();
// 先按**可见性**取出来(含 family 校验),再用 permOf 判能不能改
$note = self::find($familyId, $id, $personId);
if (!$note || !self::canWrite($note, $personId)) {
return false;
}
$title = trim((string)($d['title'] ?? ''));
$blocks = self::cleanBlocks($d['blocks'] ?? []);
// 改完标题正文全空 = 用户把内容清光了。**不删笔记**(用户可能只是暂时清空),
// 但也不让它变成一条列表里点开什么都没有的白卡片 —— 给个占位标题。
if ($title === '' && !$blocks) {
$title = '(空笔记)';
}
// ⚠️ 不再写 `is_shared`:它的语义已被 note_shares 取代(逐人 + 权限)。
// 老列留着不动(升级迁移时读过它一次),但**不再由这里改** ——
// 否则会出现"列里写着 1、共享表里却没有记录"这种自相矛盾的状态。
$db->prepare('UPDATE notes SET title = ?, updated_at = NOW() WHERE id = ?')
->execute([mb_substr($title, 0, 200, 'UTF-8'), $id]);
// 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值,
// 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。
self::writeBlocks($id, $blocks);
self::replaceTags($id, $d['tags'] ?? []);
self::claimUploads($familyId, $id, $blocks);
return true;
}
/**
* 把本次上传的图片块附件从"草稿态"认领到这条笔记上。
*
* 上传发生在写笔记的过程中(点"加图片"就传了),那时笔记还不存在,
* 所以附件先挂在 `target_type='note', target_id=0`。这里按图片块的 key
* 把它们改挂到真实 note_id 上。
*
* ⚠️ 用**本次提交里出现的 key** 来认领,而不是"把 target_id=0 的全部认领" ——
* 后者会把别人(或本人在别处还没保存)的草稿一起抢过来。
* ⚠️ 认领完顺手清掉**本家庭**剩下的孤儿草稿(target_id=0 且超过 1 天):
* 用户点了加图又放弃保存,就会留下一张谁也看不到的图。
* 按家庭 + 时间双重限定,不会误删"此刻正在编辑中"的那些。
*/
private static function claimUploads(int $familyId, int $noteId, array $blocks): void
{
$keys = [];
foreach ($blocks as $b) {
if ($b['kind'] === 'image' && $b['content'] !== '') {
$keys[] = $b['content'];
}
}
$db = Database::get();
if ($keys) {
$in = implode(',', array_fill(0, count($keys), '?'));
$args = $keys;
$args[] = $familyId;
$db->prepare(
"UPDATE attachments SET target_id = ?
WHERE target_type = 'note' AND target_id = 0
AND `key` IN ($in) AND family_id = ?"
)->execute(array_merge([$noteId], $args));
}
// 清孤儿草稿(只在认领时顺手做,不必每次请求都扫)
$db->prepare(
"DELETE FROM attachments
WHERE target_type = 'note' AND target_id = 0 AND family_id = ?
AND created_at < DATE_SUB(NOW(), INTERVAL 1 DAY)"
)->execute([$familyId]);
}
// =====================================================================
// 共享(v29 用户第 9 条)—— 与 App\Resource 同一套语义
// =====================================================================
/** 这条笔记共享给了谁(带权限与对方的名字/头像) */
public static function shares(int $noteId): array
{
$st = Database::get()->prepare(
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
FROM note_shares s JOIN persons p ON p.id = s.person_id
WHERE s.note_id = ? ORDER BY p.id'
);
$st->execute([$noteId]);
return $st->fetchAll();
}
/** 一条笔记共享给了几个人的映射(列表页一次性取,避免每条一次查询) */
public static function shareCounts(array $noteIds): array
{
$ids = array_values(array_filter(array_map('intval', $noteIds)));
if (!$ids) {
return [];
}
$in = implode(',', $ids);
$out = [];
foreach (Database::get()->query(
"SELECT note_id, COUNT(*) AS n FROM note_shares
WHERE note_id IN ($in) GROUP BY note_id"
)->fetchAll() as $r) {
$out[(int)$r['note_id']] = (int)$r['n'];
}
return $out;
}
/**
* 设共享。
*
* ⚠️ 作者本人**不进**共享表 —— 他本来就全能,
* 写进去只会让"共享给谁"的列表里冒出自己(同 Resource::setShare)。
*/
public static function setShare(int $noteId, int $ownerId, int $personId, string $perm): void
{
if ($personId <= 0 || $personId === $ownerId) {
self::dropShare($noteId, $personId);
return;
}
Database::get()->prepare(
'INSERT INTO note_shares (note_id, person_id, perm) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
)->execute([$noteId, $personId, in_array($perm, self::PERMS, true) ? $perm : 'read']);
}
public static function dropShare(int $noteId, int $personId): void
{
Database::get()->prepare('DELETE FROM note_shares WHERE note_id = ? AND person_id = ?')
->execute([$noteId, $personId]);
}
/**
* 我对某条笔记的权限:'owner' / 'write' / 'read' / ''(看不到)。
*
* ⚠️ 这是**唯一**的权限判定(页面按钮、服务端写入校验都读它)——
* 两处各写一套的话,迟早出现"按钮没显示但接口能改"(或者反过来)。
*/
public static function permOf(array $note, int $viewer): string
{
if ((int)$note['person_id'] === $viewer) {
return 'owner';
}
$st = Database::get()->prepare(
'SELECT perm FROM note_shares WHERE note_id = ? AND person_id = ?'
);
$st->execute([(int)$note['id'], $viewer]);
$p = (string)($st->fetchColumn() ?: '');
return in_array($p, self::PERMS, true) ? $p : '';
}
/** 能改吗(作者或 write 权限)。删除**只限作者** —— 见 destroy()。 */
public static function canWrite(array $note, int $viewer): bool
{
$p = self::permOf($note, $viewer);
return $p === 'owner' || $p === 'write';
}
public static function permWord(string $perm): string
{
return $perm === 'owner' ? '本人' : ($perm === 'write' ? '可读写' : '只读');
}
/** 删除(仅作者本人)。块/标签/附件靠外键 ON DELETE CASCADE 一起走。 */
public static function destroy(int $familyId, int $id, int $personId): void
{
Database::get()->prepare(
'DELETE FROM notes WHERE id = ? AND family_id = ? AND person_id = ?'
)->execute([$id, $familyId, $personId]);
}
/**
* 一条笔记的**文字摘要**(列表卡片上露个开头,不必渲染全部块)。
* 图片块显示成 `[图片]`,清单块显示成 `· 首项`。
*/
public static function excerpt(array $note, int $len = 120): string
{
$buf = [];
foreach ((array)($note['blocks'] ?? []) as $b) {
$kind = (string)$b['kind'];
$c = trim((string)$b['content']);
if ($kind === 'image') {
$buf[] = '[图片]';
} elseif ($kind === 'list') {
foreach (preg_split('/\R/u', $c) ?: [] as $line) {
$line = trim($line);
if ($line !== '') {
$buf[] = '· ' . $line;
}
}
} elseif ($c !== '') {
$buf[] = $c;
}
}
$txt = preg_replace('/\s+/u', ' ', implode(' ', $buf)) ?? '';
$txt = trim($txt);
if (mb_strlen($txt, 'UTF-8') > $len) {
$txt = mb_substr($txt, 0, $len, 'UTF-8') . '…';
}
return $txt;
}
// =====================================================================
// 内部:块 / 标签的清洗与写入
// =====================================================================
/**
* 一个块的渲染结果(HTML)。**全站唯一**的块渲染入口。
*
* 🔴 v29 第 8 条:清单块并入 Markdown 之后,这里要**同时认老数据**:
* 库里可能还躺着 `kind = 'list'` 的块(v27/v28 写的),
* 用户没编辑过它们 → 仍是 list → 必须照样渲染出列表,不能变成空白。
* ⚠️ 走同一套 `listToMarkdown` 转换(与 cleanBlocks 共用),
* 这样"老块在页面上"与"老库保存一次之后的文本"长得完全一样。
*
* @return string 图片块返回 ''(图片不用 HTML,由模板直接出 <img>)
*/
public static function blockHtml(string $kind, string $content): string
{
if ($kind === 'image') {
return '';
}
if ($kind === 'list') {
// 历史清单块 → 转成 Markdown 列表再渲染(数据不丢,样子不变)
return Markdown::render(self::listToMarkdown($content));
}
return Markdown::render($content);
}
/** 历史清单块 → Markdown 文本的**对外**入口(模板把老块放进编辑器时用)。
*
* ⚠️ 为什么要暴露它:编辑态要把"库里的块"变成"编辑器里的文本",
* 而 list 块的转换逻辑只该有一份(`listToMarkdown`)。
* 模板直接调它,比在模板里再写一遍正则可靠得多。
* @param string $content 清单块的原始内容(每行一项,缩进表层级)
*/
public static function blockHtmlOut(string $content): string
{
return self::listToMarkdown($content);
}
/**
* 历史清单块 → Markdown 无序列表文本(v29 收掉清单块时用)。
*
* 约定(v27 定下的):每行一项,**行首缩进表示层级**。
* 这里按缩进深度换算成 Markdown 的缩进(同样表层级),最多 3 层 ——
* 更深的层级在 20px 的行高里已经看不出来了,硬转反而让源码难读。
*/
private static function listToMarkdown(string $content): string
{
$lines = [];
foreach (preg_split('/\R/u', $content) ?: [] as $line) {
$raw = rtrim($line, " \t");
if (trim($raw) === '') {
continue;
}
$indent = strlen($raw) - strlen(ltrim($raw, " \t"));
$depth = min(3, (int)floor($indent / 2));
$lines[] = str_repeat(' ', $depth) . '- ' . trim($raw);
}
return implode("\n", $lines);
}
/**
* 清洗前端提交的块:
* · kind 归一(只认白名单里的两种,其它一律当 text)
* · **历史 list 块转成 Markdown 列表文本**(见下面的说明)
* · 内容裁剪(去首尾空白;图片块 content = 附件 key)
* · 丢掉**空块**(用户加了块又没填,不该留一堆空段)
* · 上限 MAX_BLOCKS
*
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
* 把段落做的完善一点就可以了。」
* ⇒ 块类型收成 `text` / `image` 两种。
* ⚠️ **但绝不丢历史数据**:老库里已经有 `kind = 'list'` 的块。
* 直接不再认它 → 那些块的正文会在用户下次保存时**静默消失**
* (最糟的一种数据丢失:用户什么都没做,内容自己没了)。
* 所以这里把 list 块**转写**成 Markdown 的无序列表(每个非空行前加 `- `),
* 并归一成 text 块 —— 渲染出来长得一模一样(`- x` 就是列表),
* 而用户以后可以在它上面用 Markdown 继续写。
* ⚠️ 列表的**层级**也保留:原来"行首缩进"表示层级,转写时按
* 缩进深度换算成 Markdown 的缩进(Markdown 同样是缩进表层级)。
*
* @return array<int,array{kind:string,content:string}>
*/
private static function cleanBlocks($raw): array
{
if (!is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $b) {
if (!is_array($b)) {
continue;
}
$kind = (string)($b['kind'] ?? 'text');
if (!in_array($kind, self::BLOCK_KINDS, true)) {
$kind = 'text';
}
$content = (string)($b['content'] ?? '');
// ---- 历史 list 块 → Markdown 无序列表(v29,见上面的说明)----
if ($kind === 'list') {
$content = self::listToMarkdown($content);
$kind = 'text';
}
// 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查)
$content = $kind === 'image'
? trim(preg_replace('/\s+/', '', $content) ?? '')
: rtrim(ltrim($content, "\r\n\t "), "\r\n\t ");
if ($content === '') {
continue; // 空块丢掉
}
if ($kind !== 'image') {
$content = mb_substr($content, 0, self::MAX_TEXT, 'UTF-8');
}
$out[] = ['kind' => $kind, 'content' => $content];
if (count($out) >= self::MAX_BLOCKS) {
break;
}
}
return $out;
}
private static function writeBlocks(int $noteId, array $blocks): void
{
$db = Database::get();
$db->prepare('DELETE FROM note_blocks WHERE note_id = ?')->execute([$noteId]);
if (!$blocks) {
return;
}
$st = $db->prepare(
'INSERT INTO note_blocks (note_id, kind, content, position) VALUES (?,?,?,?)'
);
$pos = 0;
foreach ($blocks as $b) {
$st->execute([$noteId, $b['kind'], $b['content'], $pos]);
$pos += 10;
}
}
/**
* 标签归一:`#读书` / `# 读书` / `读书 ` 都算「读书」;去重;单层。
*
* ⚠️ 为什么**不建 tags 表**(只有一个 note_tags 存字符串):
* 本模块的标签是"我随手打的词",不是需要改名/合并/配色的实体。
* 建一张标签表就得处理"改名后历史笔记怎么办""两个标签合并"——
* 那是重型知识库(思源那类)才值得的复杂度,与"轻量级"直接冲突。
* 用字符串存,标签云靠 GROUP BY 现算,几十上百条笔记毫无压力。
*/
private static function replaceTags(int $noteId, $raw): void
{
$db = Database::get();
$db->prepare('DELETE FROM note_tags WHERE note_id = ?')->execute([$noteId]);
$tags = self::cleanTags($raw);
if (!$tags) {
return;
}
$st = $db->prepare('INSERT INTO note_tags (note_id, tag) VALUES (?,?)');
foreach ($tags as $t) {
$st->execute([$noteId, $t]);
}
}
/**
* @param string|array $raw 数组,或一段带 `#标签` 的文本
* @return array<int,string> 去重后的标签(最多 12 个,每个 ≤ 20 字)
*/
public static function cleanTags($raw): array
{
$list = [];
if (is_array($raw)) {
$list = $raw;
} elseif (is_string($raw)) {
// 支持"从正文里抓 #标签"(flomo 那种随手写法)
$txt = $raw;
if (preg_match_all('/(?:^|\s)#([^\s#]{1,20})/u', $txt, $m)) {
$list = $m[1];
} else {
$list = preg_split('/[\s,,]+/u', $txt) ?: [];
}
}
$out = [];
foreach ($list as $t) {
$t = trim((string)$t);
$t = ltrim($t, '#');
$t = trim($t);
// 空白与 `#` 都不允许出现在标签内部(它俩是分隔符)
$t = (string)preg_replace('/[\s#]+/u', '', $t);
if ($t === '') {
continue;
}
$t = mb_substr($t, 0, 20, 'UTF-8');
if (!in_array($t, $out, true)) {
$out[] = $t;
}
if (count($out) >= 12) {
break;
}
}
return $out;
}
}