Files
laoyang fc81347e8d fix(notes): 调过宽度的图片保存后变孤儿(#18)
用户报「笔记功能,上传图片失败」。实际不是上传失败,是上传成功后
保存那一步把图片弄丢了。

根因:图片块 content 有两种形态
  - `photos/xxx.jpg`      —— 宽度 100% 时,前端不写后缀
  - `photos/xxx.jpg|50`   —— 宽度调到 20~99% 时,serialize() 加上宽度

但 Note::claimUploads() 与 Note::files() 直接拿 content 去匹配
attachments.key(`WHERE key IN (...)` / `isset($used[$key])`),
带后缀的那个**永远匹配不到**,于是:

  1. 草稿附件(target_id=0)认领失败 ⇒ 图片变孤儿,笔记里图没了
  2. files() 排除不掉已用作图片块的附件 ⇒ 同一张图既当图片块、
     又在底部附件区重复出现

默认 100% 不写后缀,所以表现为"有时好有时坏",
极易被误判成上传环节的问题。

修法:新增 Note::imageKey() 统一剥 `|宽度` 后缀,
claimUploads() 与 files() 都先过它。

刻意修在**服务端**、而不是"前端别写后缀":后者会把宽度功能废掉
—— 渲染时要按 content 里的宽度出图(见 templates/notes/form.php
的 $imgParts 与 templates/notes/index.php:98,那两处本来就剥了,
所以只有服务端这两处漏了,属于不对称实现)。

updateBlock() 走同一条 claimUploads(),自动被覆盖。

验证(本地 PHP 8.5 + MariaDB,真实 UI 操作,非手搓 DOM):
- 主判据 _tooling/_b35_width_falsify.py:修复前 100%→1712 成功 /
  50%→target_id=0;修复后两条路径都认领成功,7 通过 0 失败,
  且 content 仍带 `|50`(宽度功能没被牺牲)
- 附件不重复 _tooling/_b35_dup_attach.py:阅读态底部附件 0 条、
  图片块 1 张、img 61px vs figure 990px(宽度真的生效),5 通过 0 失败
- 判据有牙齿:把 claimUploads() 注入回旧形态后立刻 2 条 FAIL,
  恢复后回到 7 通过 0 失败

工单 #18
2026-10-28 16:03:36 +08:00

923 lines
41 KiB
PHP
Raw Permalink 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') {
// 🔴 工单 #18:content 可能带 `|宽度`,必须剥掉再比;
// 否则"调过宽度的图"排除不掉,会既当图片块又在附件区重复出现。
$k = self::imageKey((string)$b['content']);
if ($k !== '') {
$used[$k] = 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;
}
/**
* 从图片块的 content 里取出**真正的附件 key**(剥掉 `|宽度` 后缀)。
*
* 🔴 工单 #18(用户 2026-10-28「笔记功能,上传图片失败」):图片块 content 有两种形态
* —— `photos/xxx.jpg` 与 `photos/xxx.jpg|50`(前端 `serialize()` 在宽度不是 100 时写后缀)。
* **凡是拿 content 当 key 用的地方都必须先过这里**,否则带后缀的那个永远匹配不到。
*
* ⚠️ 曾经的 bug:`claimUploads()` 与 `files()` 直接拿 content 匹配 `attachments.key`,
* 于是"把图片调成 50% 再保存" ⇒ 草稿附件(target_id=0)认领不到 ⇒ 图片变孤儿,
* 用户看到的就是**图片上传失败 / 保存后图没了**。
* 默认 100% 不写后缀,所以表现为"有时好有时坏",极易被误判成上传问题。
*
* ⚠️ 修在**服务端**而不是"前端别写后缀":后者会把宽度功能废掉
* (渲染时要按 content 里的宽度出图,见 templates/notes/form.php 的 $imgParts)。
*
* @param string $content 图片块 content,形如 `abc` 或 `abc|50`
* @return string 纯 key(可能为空串)
*/
public static function imageKey(string $content): string
{
$c = trim($content);
if ($c === '') {
return '';
}
$p = explode('|', $c, 2);
return trim($p[0]);
}
/**
* 把本次上传的图片块附件从"草稿态"认领到这条笔记上。
*
* 上传发生在写笔记的过程中(点"加图片"就传了),那时笔记还不存在,
* 所以附件先挂在 `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') {
// 🔴 工单 #18:必须先剥 `|宽度` 后缀,否则带后缀的 key 永远匹配不到
$k = self::imageKey((string)$b['content']);
if ($k !== '') {
$keys[] = $k;
}
}
}
$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]);
}
/**
* 🔴 v52(用户 2026-10-15 第 4 条):**只改一个块**。
*
* 用户原话:「笔记这个功能模块,在详情页面,应该具备**分段编辑**功能,就是说,
* 我在一个笔记页面,可以对其中一个段落单独编辑,其他的内容不影响,
* 而不用跳转到编辑页面,因为当页面中的分块很多的时候,跳转到编辑页面
* 难以定位到想要编辑的那一个内容块。在鼠标划过内容块的时候,这个内容块
* 右侧出现一个编辑按钮,点击后当前的内容块变成编辑框,编辑完成后,
* 点击保存,页面动态更新。」
*
* 为什么**不**复用 `update()`:
* `update()` 是"整组替换"(`writeBlocks` 先 DELETE 再 INSERT)——
* 那会**换掉每个块的 id**,前端拿着的 `data-block` 立刻失效,
* 而且并发下"他改第 2 段、我改第 3 段"会互相覆盖。
* 这里按**主键**改一行,其它块一个字都不动 —— 正是用户要的"其他内容不影响"。
*
* ⚠️ 权限与 `update()` 同一把尺:先 `find()`(带家庭 + 可见性校验)再 `canWrite()`。
* **不能**只看 `person_id = 我` —— 共享给"可写"的人也该能分段改,
* 与整条编辑的权限保持一致(否则同一件事两个入口两种结果)。
*
* @return array|null 成功返回更新后的块(含 html,供前端就地换内容);失败 null
*/
public static function updateBlock(int $familyId, int $noteId, int $blockId,
int $personId, string $kind, string $content): ?array
{
$note = self::find($familyId, $noteId, $personId);
if (!$note || !self::canWrite($note, $personId)) {
return null;
}
// 归一:与 cleanBlocks 同一条路(list → Markdown、图片 key 去空白、文本去首尾换行)
$clean = self::cleanBlocks([['kind' => $kind, 'content' => $content]]);
if (!$clean) {
return null;
}
$b = $clean[0];
$db = Database::get();
// ⚠️ `AND note_id = ?` 不能省:块 id 是全局自增的,少了它就能改**别人笔记**里的块
$st = $db->prepare(
'UPDATE note_blocks SET kind = ?, content = ? WHERE id = ? AND note_id = ?'
);
$st->execute([$b['kind'], $b['content'], $blockId, $noteId]);
if ($st->rowCount() === 0) {
// 没这一行(或内容一字未改 —— MySQL 的 rowCount 在"值相同"时也是 0,
// 所以这里不当作错误,照常把当前值查回去给前端)
$chk = $db->prepare('SELECT id FROM note_blocks WHERE id = ? AND note_id = ?');
$chk->execute([$blockId, $noteId]);
if (!$chk->fetchColumn()) {
return null;
}
}
// 笔记的 updated_at 要跟着动(列表按它倒序,改了一段却不刷新时间会让人以为没改成)
$db->prepare('UPDATE notes SET updated_at = NOW() WHERE id = ?')->execute([$noteId]);
// 图片块:把本次上传的草稿认领过来(与整条保存同一套,见 claimUploads)
self::claimUploads($familyId, $noteId, $clean);
return [
'id' => $blockId,
'kind' => $b['kind'],
'content' => $b['content'],
'html' => self::blockHtml($b['kind'], $b['content']),
];
}
/**
* 一条笔记的**文字摘要**(列表卡片上露个开头,不必渲染全部块)。
* 图片块显示成 `[图片]`,清单块显示成 `· 首项`。
*/
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;
}
}