Files
FamilyBook/app/Note.php
T
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00

621 lines
25 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) */
public const BLOCK_KINDS = ['text', 'image', 'list'];
/**
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
*
* 🔴 v28 第 12 条:从 60 提到 200 —— 现在正文支持 Markdown,
* 用户会把它当"一页文档"来写(原来更多是"随手记几句话")。
* ⚠️ 上限仍然要有:它是防"误贴一整本书"的护栏,不是体验设计。
*/
public const MAX_BLOCKS = 200;
public const MAX_TEXT = 20000;
/** 列表页默认显示多少条(超过给"加载更多") */
public const LIST_LIMIT = 30;
// =====================================================================
// 读
// =====================================================================
/**
* 我能看到的笔记 = 我自己的 + 别人共享给全家的。
*
* @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 {
$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 (n.person_id = ? OR n.is_shared = 1)';
$bind = [$viewer, $familyId, $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 (n.person_id = ? OR n.is_shared = 1)';
$bind = [$familyId, $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) {
// v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本)
$b['html'] = (string)$b['kind'] === 'text'
? Markdown::render((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 (n.person_id = ? OR n.is_shared = 1)'
);
$st->execute([$id, $familyId, $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) {
$kind = (string)$r['kind'];
// 只有段落支持 Markdown(清单走自己的渲染、图片走 media)
$r['html'] = $kind === 'text'
? Markdown::render((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 (n.person_id = ? OR n.is_shared = 1)
GROUP BY t.tag ORDER BY n DESC, t.tag ASC'
);
$st->execute([$familyId, $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();
$db->prepare(
'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at)
VALUES (?,?,?,?,NOW(),NOW())'
)->execute([
$familyId, $personId,
mb_substr($title, 0, 200, 'UTF-8'),
!empty($d['is_shared']) ? 1 : 0,
]);
$id = (int)$db->lastInsertId();
self::writeBlocks($id, $blocks);
self::replaceTags($id, $d['tags'] ?? []);
// 把这次上传的"草稿附件"(target_id=0)认领到新笔记上
self::claimUploads($familyId, $id, $blocks);
return $id;
}
/**
* 改一条笔记。**仅作者本人可改**(共享出去只给别人看,不给别人改)。
* @return bool 是否改到了(改不到 = 不是我的/不存在)
*/
public static function update(int $familyId, int $id, int $personId, array $d): bool
{
$db = Database::get();
$st = $db->prepare('SELECT id FROM notes WHERE id = ? AND family_id = ? AND person_id = ?');
$st->execute([$id, $familyId, $personId]);
if (!$st->fetchColumn()) {
return false;
}
$title = trim((string)($d['title'] ?? ''));
$blocks = self::cleanBlocks($d['blocks'] ?? []);
// 改完标题正文全空 = 用户把内容清光了。**不删笔记**(用户可能只是暂时清空),
// 但也不让它变成一条列表里点开什么都没有的白卡片 —— 给个占位标题。
if ($title === '' && !$blocks) {
$title = '(空笔记)';
}
$db->prepare(
'UPDATE notes SET title = ?, is_shared = ?, updated_at = NOW() WHERE id = ?'
)->execute([mb_substr($title, 0, 200, 'UTF-8'), !empty($d['is_shared']) ? 1 : 0, $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]);
}
/** 删除(仅作者本人)。块/标签/附件靠外键 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;
}
// =====================================================================
// 内部:块 / 标签的清洗与写入
// =====================================================================
/**
* 清洗前端提交的块:
* · kind 归一(只认白名单里的三种,其它一律当 text)
* · 内容裁剪(去首尾空白;图片块 content = 附件 key)
* · 丢掉**空块**(用户加了块又没填,不该留一堆空段)
* · 上限 MAX_BLOCKS
*
* @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'] ?? '');
// 图片块 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;
}
}