每行带 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 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,由模板直接出 ) */ 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 */ 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 去重后的标签(最多 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; } }