2026-10-07 18:22:55 +08:00
|
|
|
|
<?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
|
|
|
|
|
|
{
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
|
2026-10-07 18:22:55 +08:00
|
|
|
|
public const ATTACH = 'note';
|
|
|
|
|
|
|
2026-10-08 10:54:43 +08:00
|
|
|
|
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
|
|
|
|
|
|
* 把段落做的完善一点就可以了。」
|
|
|
|
|
|
* ⇒ 收成 `text` / `image` 两种。
|
|
|
|
|
|
* ⚠️ 老库里可能还有 `kind = 'list'` 的行 —— **不要**把它们当非法值删掉,
|
|
|
|
|
|
* 见 `cleanBlocks()` 里对历史 list 块的**转写**(转成 Markdown 列表文本)。
|
|
|
|
|
|
* 白名单只决定"新写入时允许什么",不决定"老数据怎么活"。 */
|
|
|
|
|
|
public const BLOCK_KINDS = ['text', 'image'];
|
2026-10-07 18:22:55 +08:00
|
|
|
|
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v28 第 12 条:从 60 提到 200 —— 现在正文支持 Markdown,
|
|
|
|
|
|
* 用户会把它当"一页文档"来写(原来更多是"随手记几句话")。
|
|
|
|
|
|
* ⚠️ 上限仍然要有:它是防"误贴一整本书"的护栏,不是体验设计。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const MAX_BLOCKS = 200;
|
2026-10-07 18:22:55 +08:00
|
|
|
|
public const MAX_TEXT = 20000;
|
|
|
|
|
|
|
|
|
|
|
|
/** 列表页默认显示多少条(超过给"加载更多") */
|
|
|
|
|
|
public const LIST_LIMIT = 30;
|
|
|
|
|
|
|
2026-10-08 10:54:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* **可见性的唯一判据**(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'];
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
// =====================================================================
|
|
|
|
|
|
// 读
|
|
|
|
|
|
// =====================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 我能看到的笔记 = 我自己的 + 别人共享给全家的。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @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 {
|
2026-10-08 10:54:43 +08:00
|
|
|
|
/* 🔴 v29 用户第 9 条:可见性判据换成**逐人共享**:
|
|
|
|
|
|
「我写的」**或**「有一条指向我的共享记录」。
|
|
|
|
|
|
⚠️ 这是全站唯一的可见性定义(列表 / 计数 / 详情 / 标签云都读它)——
|
|
|
|
|
|
v27 那个 `is_shared = 1`(共享给全家)已不再是判据,
|
|
|
|
|
|
升级时它被展开成逐人记录(见 Schema 的迁移)。
|
|
|
|
|
|
⚠️ 用 EXISTS 而不是 JOIN `note_shares`:JOIN 会让"共享给 3 个人"的
|
|
|
|
|
|
笔记在列表里出现 3 次(每条共享一行)。EXISTS 只判"有没有"。 */
|
|
|
|
|
|
$vis = self::VIS_COND;
|
2026-10-07 18:22:55 +08:00
|
|
|
|
$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
|
2026-10-08 10:54:43 +08:00
|
|
|
|
WHERE n.family_id = ? AND ' . $vis;
|
|
|
|
|
|
// ⚠️ VIS_COND 里有**两个** `?`(可见性判据里 person_id 出现两次),
|
|
|
|
|
|
// 所以 $viewer 要绑两遍。漏一个会让后面的关键词绑定整体错位。
|
|
|
|
|
|
$bind = [$viewer, $familyId, $viewer, $viewer];
|
2026-10-07 18:22:55 +08:00
|
|
|
|
|
|
|
|
|
|
$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
|
2026-10-08 10:54:43 +08:00
|
|
|
|
WHERE n.family_id = ? AND ' . self::VIS_COND;
|
|
|
|
|
|
$bind = [$familyId, $viewer, $viewer];
|
2026-10-07 18:22:55 +08:00
|
|
|
|
$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) {
|
2026-10-08 10:54:43 +08:00
|
|
|
|
$b['html'] = self::blockHtml((string)$b['kind'], (string)$b['content']);
|
2026-10-07 18:22:55 +08:00
|
|
|
|
$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] ?? [];
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/* v28 第 12 条:列表卡片要用摘要(定尺寸网格里放不下全文)。
|
|
|
|
|
|
摘要按 Markdown 去记号后取前 N 字 —— 卡片上不渲染 HTML,
|
|
|
|
|
|
这样列表页再长也不会因为 Markdown 变慢。 */
|
|
|
|
|
|
$r['excerpt'] = self::excerptOf($r);
|
2026-10-07 18:22:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
unset($r);
|
|
|
|
|
|
return $rows;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从一条笔记的块里生成**纯文本摘要**(列表卡片用)。
|
|
|
|
|
|
* 图片块计作 [图片],清单块取前几行,段落走 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
/** 单条(带权限判定)。看不到返回 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
|
2026-10-08 10:54:43 +08:00
|
|
|
|
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]);
|
2026-10-07 18:22:55 +08:00
|
|
|
|
$r = $st->fetch();
|
|
|
|
|
|
if (!$r) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
$r['blocks'] = self::blocks($id);
|
|
|
|
|
|
$r['tags'] = self::tags($id);
|
2026-10-08 02:20:19 +08:00
|
|
|
|
$r['files'] = self::files($id); // v28:底部附件
|
2026-10-07 18:22:55 +08:00
|
|
|
|
$r['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0;
|
|
|
|
|
|
return $r;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一条笔记的**底部附件**(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') {
|
2026-10-28 16:03:36 +08:00
|
|
|
|
// 🔴 工单 #18:content 可能带 `|宽度`,必须剥掉再比;
|
|
|
|
|
|
// 否则"调过宽度的图"排除不掉,会既当图片块又在附件区重复出现。
|
|
|
|
|
|
$k = self::imageKey((string)$b['content']);
|
|
|
|
|
|
if ($k !== '') {
|
|
|
|
|
|
$used[$k] = true;
|
|
|
|
|
|
}
|
2026-10-08 02:20:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$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
|
|
|
|
|
|
* (两处各算一次,迟早有一处忘了转义或忘了渲染)。
|
|
|
|
|
|
*/
|
2026-10-07 18:22:55 +08:00
|
|
|
|
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]);
|
2026-10-08 02:20:19 +08:00
|
|
|
|
$rows = $st->fetchAll();
|
|
|
|
|
|
foreach ($rows as &$r) {
|
2026-10-08 10:54:43 +08:00
|
|
|
|
$r['html'] = self::blockHtml((string)$r['kind'], (string)$r['content']);
|
2026-10-08 02:20:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
unset($r);
|
|
|
|
|
|
return $rows;
|
2026-10-07 18:22:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 某条笔记的标签 */
|
|
|
|
|
|
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
|
2026-10-08 10:54:43 +08:00
|
|
|
|
WHERE n.family_id = ? AND ' . self::VIS_COND . '
|
2026-10-07 18:22:55 +08:00
|
|
|
|
GROUP BY t.tag ORDER BY n DESC, t.tag ASC'
|
|
|
|
|
|
);
|
2026-10-08 10:54:43 +08:00
|
|
|
|
$st->execute([$familyId, $viewer, $viewer]);
|
2026-10-07 18:22:55 +08:00
|
|
|
|
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();
|
2026-10-08 10:54:43 +08:00
|
|
|
|
// ⚠️ v29:新建时**一律不共享**(`is_shared` 恒 0)——
|
|
|
|
|
|
// 共享改成"保存完在详情页选人 + 权限"(与资源同一个交互),
|
|
|
|
|
|
// 新建表单里不再有那个勾选框。共享表记录由 setShare 单独维护。
|
2026-10-07 18:22:55 +08:00
|
|
|
|
$db->prepare(
|
|
|
|
|
|
'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at)
|
2026-10-08 10:54:43 +08:00
|
|
|
|
VALUES (?,?,?,0,NOW(),NOW())'
|
2026-10-07 18:22:55 +08:00
|
|
|
|
)->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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-08 10:54:43 +08:00
|
|
|
|
* 改一条笔记。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v29 用户第 9 条:改成**有 write 权限就能改** —— 用户要的是
|
|
|
|
|
|
* 「和资源类似…选择是只读还是可以读写」,那就得让"可读写"真的能写。
|
|
|
|
|
|
* v27 的"仅作者可改"(共享出去只给别人看)是当时只有"共享给全家"这一个
|
|
|
|
|
|
* 开关时的简化;现在有了逐人权限,就该按权限来。
|
|
|
|
|
|
* ⚠️ 删除**仍然只限作者**(见 destroy):删是不可逆的,
|
|
|
|
|
|
* "可读写"给的是编辑权,不是销毁权。这条差异要显式写在按钮上。
|
|
|
|
|
|
* @return bool 是否改到了(改不到 = 看不到 / 没有写权限)
|
2026-10-07 18:22:55 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public static function update(int $familyId, int $id, int $personId, array $d): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
2026-10-08 10:54:43 +08:00
|
|
|
|
// 先按**可见性**取出来(含 family 校验),再用 permOf 判能不能改
|
|
|
|
|
|
$note = self::find($familyId, $id, $personId);
|
|
|
|
|
|
if (!$note || !self::canWrite($note, $personId)) {
|
2026-10-07 18:22:55 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
$title = trim((string)($d['title'] ?? ''));
|
|
|
|
|
|
$blocks = self::cleanBlocks($d['blocks'] ?? []);
|
|
|
|
|
|
// 改完标题正文全空 = 用户把内容清光了。**不删笔记**(用户可能只是暂时清空),
|
|
|
|
|
|
// 但也不让它变成一条列表里点开什么都没有的白卡片 —— 给个占位标题。
|
|
|
|
|
|
if ($title === '' && !$blocks) {
|
|
|
|
|
|
$title = '(空笔记)';
|
|
|
|
|
|
}
|
2026-10-08 10:54:43 +08:00
|
|
|
|
// ⚠️ 不再写 `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]);
|
2026-10-07 18:22:55 +08:00
|
|
|
|
// 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值,
|
|
|
|
|
|
// 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。
|
|
|
|
|
|
self::writeBlocks($id, $blocks);
|
|
|
|
|
|
self::replaceTags($id, $d['tags'] ?? []);
|
|
|
|
|
|
self::claimUploads($familyId, $id, $blocks);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 16:03:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从图片块的 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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 把本次上传的图片块附件从"草稿态"认领到这条笔记上。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 上传发生在写笔记的过程中(点"加图片"就传了),那时笔记还不存在,
|
|
|
|
|
|
* 所以附件先挂在 `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) {
|
2026-10-28 16:03:36 +08:00
|
|
|
|
if ($b['kind'] === 'image') {
|
|
|
|
|
|
// 🔴 工单 #18:必须先剥 `|宽度` 后缀,否则带后缀的 key 永远匹配不到
|
|
|
|
|
|
$k = self::imageKey((string)$b['content']);
|
|
|
|
|
|
if ($k !== '') {
|
|
|
|
|
|
$keys[] = $k;
|
|
|
|
|
|
}
|
2026-10-07 18:22:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 10:54:43 +08:00
|
|
|
|
// =====================================================================
|
|
|
|
|
|
// 共享(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' ? '可读写' : '只读');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
/** 删除(仅作者本人)。块/标签/附件靠外键 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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 23:04:25 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 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']),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一条笔记的**文字摘要**(列表卡片上露个开头,不必渲染全部块)。
|
|
|
|
|
|
* 图片块显示成 `[图片]`,清单块显示成 `· 首项`。
|
|
|
|
|
|
*/
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =====================================================================
|
|
|
|
|
|
// 内部:块 / 标签的清洗与写入
|
|
|
|
|
|
// =====================================================================
|
|
|
|
|
|
|
2026-10-08 10:54:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一个块的渲染结果(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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 清洗前端提交的块:
|
2026-10-08 10:54:43 +08:00
|
|
|
|
* · kind 归一(只认白名单里的两种,其它一律当 text)
|
|
|
|
|
|
* · **历史 list 块转成 Markdown 列表文本**(见下面的说明)
|
2026-10-07 18:22:55 +08:00
|
|
|
|
* · 内容裁剪(去首尾空白;图片块 content = 附件 key)
|
|
|
|
|
|
* · 丢掉**空块**(用户加了块又没填,不该留一堆空段)
|
|
|
|
|
|
* · 上限 MAX_BLOCKS
|
|
|
|
|
|
*
|
2026-10-08 10:54:43 +08:00
|
|
|
|
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
|
|
|
|
|
|
* 把段落做的完善一点就可以了。」
|
|
|
|
|
|
* ⇒ 块类型收成 `text` / `image` 两种。
|
|
|
|
|
|
* ⚠️ **但绝不丢历史数据**:老库里已经有 `kind = 'list'` 的块。
|
|
|
|
|
|
* 直接不再认它 → 那些块的正文会在用户下次保存时**静默消失**
|
|
|
|
|
|
* (最糟的一种数据丢失:用户什么都没做,内容自己没了)。
|
|
|
|
|
|
* 所以这里把 list 块**转写**成 Markdown 的无序列表(每个非空行前加 `- `),
|
|
|
|
|
|
* 并归一成 text 块 —— 渲染出来长得一模一样(`- x` 就是列表),
|
|
|
|
|
|
* 而用户以后可以在它上面用 Markdown 继续写。
|
|
|
|
|
|
* ⚠️ 列表的**层级**也保留:原来"行首缩进"表示层级,转写时按
|
|
|
|
|
|
* 缩进深度换算成 Markdown 的缩进(Markdown 同样是缩进表层级)。
|
|
|
|
|
|
*
|
2026-10-07 18:22:55 +08:00
|
|
|
|
* @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'] ?? '');
|
2026-10-08 10:54:43 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 历史 list 块 → Markdown 无序列表(v29,见上面的说明)----
|
|
|
|
|
|
if ($kind === 'list') {
|
|
|
|
|
|
$content = self::listToMarkdown($content);
|
|
|
|
|
|
$kind = 'text';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
// 图片块 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|