Files
FamilyBook/app/Note.php
T
laoyang ed122c034b v52:旅程 CalDAV 结束时间 / 笔记(标题·段落下加段加图·逐段就地编辑)/ 名册自动匹配与设置页 / 模块设置整合表
用户 2026-10-15 七条。

① 旅程行程经 CalDAV 同步后「结束时间 = 开始时间」
   **真根因**(app/Calendar.php 的 exportRows()):对**派生条目**(观影/旅程)写死
     'ends_at' => null,而 Ics::vevent() 遇到空结束会退化成「DTEND = DTSTART」
     ⇒ 手机上每条行程的结束时间都等于开始。
   **附带第二个 bug**:分组键含日期(kind-ref_id-date),而 month() 把**跨天条目逐天**
     铺出来 ⇒ 跨天节点被拆成 N 条(UID 相同),resources() 按 UID 去重后**只留第一天**,
     而第一天那条没有结束时刻 ⇒ 又回到 DTEND=DTSTART。
   修法:键改成 `kind-ref_id`,先收集同节点的各天,再用**首末天**拼一条真实跨度
     (末天那条才带 end_time)。实测单日 DTEND=12:30、跨天 DTEND=第3天11:00,
     每节点恰好 1 条 VEVENT。
   ⚠️ 副作用(已处理):v31 DAV 去重的反向验证原来靠「跨天节点逐天出、UID 全同」造重复,
     本修把那条素材**修没了** ⇒ 给夹具换成「同一份 .ics 导两次」(两条 UID 相同的
     自定义日程),守卫仍能被证伪(见 _fb_dav_diag.py 与 pitfalls 的同族条目)。

② 笔记详情页标题是"看笔记"而不是笔记名
   阅读态 h1 改用笔记自己的 $noteTitle;**编辑态仍用「改笔记/写笔记」**
   (编辑时标题就在下面第一个输入框,两处重名看着像重复)。浏览器 <title> 同步。

③ 加段落/加图片要在**输入框下面**
   note_editor.js 的 makeBlock() 里每块下方加一排 .note-block-add(+段落/+图片),
   点了插在**这一块后面**;顶部那组**保留**(用户明确"上面和下面都保留")。
   ⚠️ 挂在 body 里而不是 wrap 上 —— .note-block 是 display:flex 横排,
      挂 wrap 会变成"输入框右边的第三列",不是"下面"。
   ⚠️ 用 data-add-below(不是 data-add):顶部是"追加到末尾"、块内是"插在本块后"。

④ 笔记详情页「逐段就地编辑」(分块多时不用跳编辑页找那一块)
   模板给每块包 .note-read-item[data-block][data-kind][data-raw] + 右上角「编辑」按钮;
   新增 assets/js/note_inline.js(按需加载):划过出现 → 点编辑就地变 textarea →
   保存 POST notes/block → 只换这一块。
   服务端 Note::updateBlock():**按块主键改一行**(不是整组替换)——
   ⚠️ 整组替换会换掉每个块的 id(前端手里的 data-block 当场失效)+ 并发互相覆盖;
   ⚠️ SQL 必须带 `AND note_id = ?`(块 id 全局自增,少了它就能改别人笔记里的块)。
   权限与整条编辑同一把尺(find() + canWrite())。

⑤ 删掉「从已有家里人中挑选」,改成「新增/导入时自动匹配」
   新增 Person::matchExisting():手机号(有 phone_hash,一条 SQL 精确命中)→
   身份证(无哈希,整表解密比)→ 姓名(**先记下不立刻返回**,等精确命中看完再退到它)。
   people/create:校验通过后先 matchExisting,命中就在**同一张表单里**出确认条
   (#personConfirm)。⚠️ **不做成单独确认页**:单独页要把整张表(含工作经历/补充联系
   方式这些**数组字段**)原样回传,漏一类就静默丢数据;同表单里字段天然全在。
   ⚠️ 判"是不是第一次提交"用 array_key_exists('confirm_existing')(0=不是、1=是、无=首次)。
   导入那条路的缺口(本轮补):ContactImporter 命中已有的人时只补字段、**不挂进导入者的
   名册** ⇒ "文件里有他、系统里也有他,但导完我的名册里看不到他"。已在命中分支加
   PersonRoster::ensure(..., $meId, ...)。
   顺手加 PersonRoster::has()(避免调用方又踩"visibleIds 是值列表不是集合"那个坑)。

⑥ 名册页只剩「添加成员 + 设置」;分组搬到名册设置
   templates/people/_settings_menu.php(新):☰ 下拉两行 = 导入联系人 / 名册分组。
   templates/people/settings.php(新,路由 people/settings):require 从个人设置**整块搬出来**
   的 templates/_person_groups.php。
   ⚠️ 是**真搬**不是复制(两份一模一样的分组界面,改一处忘一处是必然的);
   ⚠️ 片段变量一律加 pg 前缀 —— 模板片段与宿主 require 是**共享作用域**,
      用 $groups 这种通名会覆盖宿主同名变量(本轮真踩到,已改)。
   ⚠️ 表单 action 仍指 settings/groups/*(一个字段没改)。

⑦ 个人设置:模块设置整合成一张表
   原来是**三张卡三个表单**(首页模块 / 导航模块 / 主题色),同一串模块名出现两遍。
   现在一张表五列:顺序 | 模块 | 导航 | 首页 | 颜色,一次保存。
   新增 settings/modules_save 路由:**底层存储一个没改**(仍是 nav_modules /
   home_modules / module_colors),只是把三个 save 依次调一遍 ⇒ 老数据天然兼容。
   ⚠️ 权限**没有**因为"合并"被放宽:颜色仍是家庭一份、只有管理员能改
      (非管理员那列是只读色块;提交时服务端静默忽略 c[])。
   窄屏:表格 min-width:560px + 外层 overflow-x:auto(375px 实测横向溢出 0)。

验证(新增 5 套件 + 更新若干旧断言):
 · _fb_v52_ics.py(3)/ _fb_v52_note.py(19)/ _fb_v52_roster.py(12)/ _fb_v52_mods.py(13)
 · _b52_falsify.py(**12 注入 / 31 断言全抓**)
 · _fb_import_test.py 补 ⑯(命中的人也要回到导入者名册);_fb_dav_diag.py 换去重素材
 · e2e 1516/0;_fb_batch_e.py / _fb_e2e.py / familybook_deploy.sh 里随 #6/#7 搬家或改形的
   旧断言(分组在个人设置、模块行 li→tr、show_present、RRULE 前缀)意图一字不改
 · 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第52批形态");线上 16 文件 md5 逐字节一致

⚠️ 工程纪律(已写进 pitfalls.md / 工作日志):
 · 探针自起的 PHP 服务**一律随机空闲端口**(写死 + "连上就复用" 会静默测到别的目录/库);
 · **同一工具链目录 + 同一库名不支持两个会话并发** —— 本轮最后一个多小时的"诡异全红"
   全是**另一个会话**在同时跑 _fb_e2e.py、共享 fb_test 与 _fb_site 造成的;
 · 并发下**连 git stash 都不能用**(会把别人的半成品一起藏起来),基线对照用 git worktree;
 · 修好业务可能让"守着它的反向验证"空转(素材被修没了)⇒ 换一处等价素材,别删守卫;
 · 断言盯**形态**而非具体数值(分栏那次写死 50% 在别的批次数据上假红)。
2026-10-15 23:04:25 +08:00

886 lines
39 KiB
PHP
Raw Blame History

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