v27 个人笔记模块:分块(段落/图片/清单)+ 单层多标签 + 默认私有可共享
参考 flomo(时间流卡片 + 标签即筛)、Obsidian(图文混排的直觉)、
思源(「块」的粒度),但刻意只取轻的那一层:
- 分块只做 text / image / list 三种,可增删、可上下移;不做块引用 / 块 ID 双链
- 标签单层、多对多,只存字符串(**不建标签表**)——改名/合并这类重型能力一律不做
- 不做富文本工具栏、不做 Markdown 预览、不做拖拽排序(只有 ↑↓ 按钮)
- 可见性默认**私有**,单条可勾「共享给全家」;共享出去只给别人看,不给别人改
落地:
- 新增 app/Note.php(块清洗、标签归一、可见性、认领草稿附件)
- 新增 3 张表 notes / note_blocks / note_tags;Schema VERSION 26→27
- 🔴 attachments.target_type 是 ENUM:加 'note' 必须同时改建表串 +
新增 widenAttachTargetEnum()(老库不做这一步,传笔记图片会被严格模式拒掉)
- 路由 5 条:notes / notes/form / notes/save / notes/destroy / notes/upload
- 模板 templates/notes/index.php(列表)+ form.php(分块编辑)
- JS assets/js/note_editor.js(按需加载,同 person_picker 那套)
- 编辑器状态放 DOM 而非 JS 数组:移动块时用户已输入的内容一字不丢
This commit is contained in:
+4
-1
@@ -51,6 +51,9 @@ final class Module
|
||||
// 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以
|
||||
// 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。
|
||||
'nav' => ['导航', '#3E7A9E', 'nav'],
|
||||
// v27 用户 2026-10-07:个人笔记(参考 flomo / Obsidian / 思源,轻量分块 + 标签)。
|
||||
// 放在**导航后面**(末尾)—— 它是"随手记",不该抢日常入口的位置。
|
||||
'notes' => ['笔记', '#7A6A4F', 'notes'],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -61,7 +64,7 @@ final class Module
|
||||
* 只是不再占顶栏的位置。
|
||||
*/
|
||||
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources',
|
||||
'tasks', 'days', 'ledger', 'books', 'nav'];
|
||||
'tasks', 'days', 'ledger', 'books', 'nav', 'notes'];
|
||||
|
||||
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
|
||||
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
|
||||
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
<?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'];
|
||||
|
||||
/** 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类) */
|
||||
public const MAX_BLOCKS = 60;
|
||||
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) {
|
||||
$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] ?? [];
|
||||
}
|
||||
unset($r);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 单条(带权限判定)。看不到返回 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['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0;
|
||||
return $r;
|
||||
}
|
||||
|
||||
/** 某条笔记的块(按位置) */
|
||||
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]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
/** 某条笔记的标签 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
+84
-2
@@ -97,6 +97,10 @@ final class Schema
|
||||
* 会**另建一张空表** —— 用户已配好的 CalDAV 口令看起来就"消失"了
|
||||
* (所有设备上的同步立刻 401)。而且必须先判旧表在不在:
|
||||
* 全新安装时没有旧表,直接 RENAME 会报错把整段升级打断。
|
||||
* 27 → **个人笔记(用户 2026-10-07)**:`notes`(卡片)+ `note_blocks`(卡内的块)
|
||||
* + `note_tags`(单层标签)。参考 flomo / Obsidian / 思源,但刻意**只取轻的**:
|
||||
* 块只做 text/image/list 三种、不做块引用;标签只存字符串、不建标签表;
|
||||
* 权限默认私有、单条可一键共享给全家。详见 App\Note 头部。
|
||||
*/
|
||||
/**
|
||||
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
||||
@@ -104,7 +108,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '26';
|
||||
public const VERSION = '27';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -448,7 +452,7 @@ SQL,
|
||||
CREATE TABLE IF NOT EXISTS `attachments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`target_type` ENUM('trip','trip_node','resource','task','task_comment') NOT NULL COMMENT '挂在谁身上',
|
||||
`target_type` ENUM('trip','trip_node','resource','task','task_comment','note') NOT NULL COMMENT '挂在谁身上',
|
||||
`target_id` INT UNSIGNED NOT NULL COMMENT '宿主的 id',
|
||||
`key` VARCHAR(255) NOT NULL COMMENT '文件(存储相对键)',
|
||||
`name` VARCHAR(200) DEFAULT NULL COMMENT '原始文件名(下载时回给它)',
|
||||
@@ -915,6 +919,57 @@ CREATE TABLE IF NOT EXISTS `nav_settings` (
|
||||
PRIMARY KEY (`person_id`, `k`),
|
||||
CONSTRAINT `fk_navset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航设置(每人一份)'
|
||||
SQL,
|
||||
// ---- 个人笔记(v27,用户 2026-10-07)----
|
||||
// 三张表都很薄。为什么这样切(详见 App\Note 头部):
|
||||
// · notes —— 卡片(标题 + 可见性 + 归属人)
|
||||
// · note_blocks—— 卡内的块(text/image/list + 排序)「分块」的落点
|
||||
// · note_tags —— 标签(单层,存字符串,**不建标签表**)
|
||||
// ⚠️ 外键一律 `int(10) unsigned` 与 persons.id / families.id 严格同型 ——
|
||||
// 类型不一致(比如写成 INT 而非 INT UNSIGNED)会 errno 150 建表失败,
|
||||
// 而报错信息只说"外键约束不对",很难一眼看出是类型问题(本仓踩过)。
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `notes` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`person_id` INT UNSIGNED NOT NULL COMMENT '作者(笔记属于人,不属于家)',
|
||||
`title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题(可空,纯正文笔记也允许)',
|
||||
`is_shared` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '共享给全家?0=私有(默认) 1=全家可见',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '改过就刷新(列表按它倒序)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_note_owner` (`family_id`, `person_id`, `updated_at`),
|
||||
KEY `idx_note_shared` (`family_id`, `is_shared`, `updated_at`),
|
||||
CONSTRAINT `fk_note_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_note_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='个人笔记:一条 = 一张卡片(v27 用户 2026-10-07)'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `note_blocks` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`note_id` INT UNSIGNED NOT NULL,
|
||||
`kind` VARCHAR(16) NOT NULL DEFAULT 'text'
|
||||
COMMENT '块类型:text=段落 / image=图片 / list=清单',
|
||||
`content` TEXT NOT NULL COMMENT 'text/list=正文;image=附件 key(走 media.php)',
|
||||
`position` INT NOT NULL DEFAULT 10 COMMENT '卡内顺序(步长 10,留插入空间)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_nb_note` (`note_id`, `position`),
|
||||
CONSTRAINT `fk_nb_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='笔记的块:卡片内的一段/一张图/一个清单(v27「分块」)'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `note_tags` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`note_id` INT UNSIGNED NOT NULL,
|
||||
`tag` VARCHAR(20) NOT NULL COMMENT '单层标签词(不含 #;同一笔记内不重复)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_nt` (`note_id`, `tag`),
|
||||
KEY `idx_nt_tag` (`tag`),
|
||||
CONSTRAINT `fk_nt_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='笔记标签(单层、多对多;标签词本身不建表,见 App\\Note)'
|
||||
SQL,
|
||||
];
|
||||
}
|
||||
@@ -1331,6 +1386,11 @@ SQL);
|
||||
// 建表语句的 IF NOT EXISTS 对**已存在的表**不会更新枚举 —— 老库必须显式改,
|
||||
// 否则插入 kind='trip' 会被严格模式整条拒掉(v7 就踩过同一类坑)。
|
||||
self::widenEventKindEnum($db);
|
||||
// v27:attachments.target_type 要能挂笔记的图片('note')。
|
||||
// ⚠️ 与上面 widenEventKindEnum 同一类坑:建表语句里的 ENUM 对**已存在的表**
|
||||
// 不会更新 —— 老库必须显式 MODIFY,否则上传笔记图片时插入
|
||||
// target_type='note' 会被严格模式整条拒掉(表现为"图传不上去")。
|
||||
self::widenAttachTargetEnum($db);
|
||||
// 每人的两本默认日历(观影 / 旅行):新装的由 Calendar::seedDefaults 在
|
||||
// 建账号时建,老库在这里补(幂等:按 (owner_id, kind, name) 唯一键查过再插)。
|
||||
self::tryStep('补建每人默认的观影/旅行日历本', static function () use ($db) {
|
||||
@@ -2118,6 +2178,28 @@ SQL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* attachments.target_type 加 'note'(v27 个人笔记的图片块)。
|
||||
* ⚠️ 判据用 strpos 找 'note' 要小心:ENUM 串里 'trip_node'、'task_comment' 都
|
||||
* 不含 note,所以直接找 `'note'`(带引号)最稳,不必担心误判。
|
||||
*/
|
||||
private static function widenAttachTargetEnum(\PDO $db): void
|
||||
{
|
||||
$st = $db->prepare(
|
||||
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'attachments' AND COLUMN_NAME = 'target_type'"
|
||||
);
|
||||
$st->execute();
|
||||
$type = (string)$st->fetchColumn();
|
||||
if ($type !== '' && strpos($type, "'note'") === false) {
|
||||
$db->exec(
|
||||
"ALTER TABLE `attachments` MODIFY COLUMN `target_type`
|
||||
ENUM('trip','trip_node','resource','task','task_comment','note') NOT NULL
|
||||
COMMENT '挂在谁身上'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function hasIndex(\PDO $db, string $table, string $index): bool
|
||||
{
|
||||
$st = $db->prepare(
|
||||
|
||||
Reference in New Issue
Block a user