Skip to content

Commit f9f9a09

Browse files
committed
refactor(notes): useNoteStore 611 行拆组合式 slice(note/folder/search + lib 外移),主入口 79 行
1 parent e99fbaf commit f9f9a09

8 files changed

Lines changed: 758 additions & 611 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* 笔记列表投影查询:content 解密后仅保留截断预览
3+
* Note list projection: decrypted content reduced to a truncated preview
4+
*
5+
* @ai-context: 从 useNoteStore 拆出的数据访问层。数百篇图片笔记下,若把全文
6+
* content(含内嵌 base64 图片串)常驻内存,内存峰值可达数百 MB;投影后仅保留
7+
* 卡片预览,全文按需走 noteStore.getById(解密路径)。decryptField 对明文旧数据
8+
* 优雅降级返回原文。
9+
* @ai-context: Extracted from useNoteStore. Keeps only truncated previews in memory;
10+
* full content is fetched on demand via the decrypted getById path.
11+
*/
12+
import { db } from '@/lib/storage/database';
13+
import { cryptoManager } from '@/lib/crypto';
14+
import type { Note } from '@/types/models';
15+
import { noteContentToPlainText } from './mindmap/mindmapText';
16+
17+
/** 列表预览保留长度(字符):卡片摘要/详情预览足够 */
18+
const PREVIEW_CHARS = 300;
19+
/** 内嵌 base64 图片串(预览/纯文本提取不需要图片数据) */
20+
const BASE64_IMAGE_RE = /data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/g;
21+
22+
/**
23+
* 笔记列表投影查询:content 解密后仅保留截断预览(剥离内嵌 base64 图片串)。
24+
* @ai-context P1-1:全文按需走 noteStore.getById(解密路径)。
25+
*/
26+
export async function getAllNoteMeta(): Promise<Note[]> {
27+
const items = await db.notes.toArray();
28+
const metas: Note[] = [];
29+
for (const item of items) {
30+
const note = item as Note;
31+
// content 为密文(SENSITIVE_FIELDS.notes)或明文(加密未初始化/旧数据):
32+
// decryptField 优雅降级返回原文;解密后立即剥离 base64 并截断,全文不常驻
33+
let preview = '';
34+
if (note.content) {
35+
try {
36+
// 统一纯文本提取(TipTap/导图;图片节点由 P0-4 跳过),
37+
// 双保险再剥离残留 base64 串,截断为卡片预览长度
38+
const raw = await cryptoManager.decryptField(note.content);
39+
preview = noteContentToPlainText(raw).replace(BASE64_IMAGE_RE, ' [图片] ').slice(0, PREVIEW_CHARS);
40+
} catch {
41+
preview = '';
42+
}
43+
}
44+
metas.push({ ...note, content: preview });
45+
}
46+
return metas;
47+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* 笔记排序纯函数:置顶优先 + 更新时间倒序
3+
* Note sort helper: pinned first, then updatedAt descending
4+
*
5+
* @ai-context: 从 useNoteStore 拆出的纯函数(无副作用,可独立单测)。
6+
* 列表展示、局部更新、筛选结果统一使用此排序,保证顺序单一事实源:
7+
* 置顶笔记优先,其余按 updatedAt 倒序。
8+
* @ai-context: Extracted from useNoteStore. Single source of truth for note
9+
* ordering — pinned notes first, then most recently updated.
10+
*/
11+
import type { Note } from '@/types/models';
12+
13+
export const sortNotes = (notes: Note[]): Note[] => {
14+
return [...notes].sort((a, b) => {
15+
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
16+
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
17+
});
18+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* 笔记模板常量:各类笔记模板的初始内容与默认标题
3+
* Note template constants: initial content and default titles per template
4+
*
5+
* @ai-context: 从 useNoteStore 拆出的纯常量模块(无副作用)。TEMPLATE_CONTENT
6+
* 为 TipTap JSON / 结构化 JSON 序列化字符串,createFromTemplate 创建时直接使用;
7+
* mindmap 与 todo 为占位(创建时由 createDefaultMindmap / createTodoNote 动态生成,
8+
* 避免复用模板中的节点 id)。TEMPLATE_TITLES 为各模板默认标题。
9+
* @ai-context: Pure constant module extracted from useNoteStore. mindmap and todo
10+
* entries are placeholders — real content is generated dynamically on creation so
11+
* node ids are never reused across notes.
12+
*/
13+
import type { Note } from '@/types/models';
14+
import { createEmptyTodoTemplate } from './todoTemplate';
15+
16+
/** 各模板创建时的初始内容(TipTap JSON / 结构化 JSON 字符串) */
17+
export const TEMPLATE_CONTENT: Record<Note['template'], string> = {
18+
outline: JSON.stringify({
19+
type: 'doc',
20+
content: [
21+
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '大纲笔记' }] },
22+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '一、' }] },
23+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '二、' }] },
24+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '三、' }] },
25+
],
26+
}),
27+
cornell: JSON.stringify({
28+
type: 'doc',
29+
content: [
30+
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '康奈尔笔记' }] },
31+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '线索栏' }] },
32+
{ type: 'paragraph', content: [{ type: 'text', text: '关键词 / 问题' }] },
33+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '笔记栏' }] },
34+
{ type: 'paragraph', content: [{ type: 'text', text: '主要内容记录' }] },
35+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '总结栏' }] },
36+
{ type: 'paragraph', content: [{ type: 'text', text: '归纳总结' }] },
37+
],
38+
}),
39+
qa: JSON.stringify({
40+
type: 'doc',
41+
content: [
42+
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '问答笔记' }] },
43+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Q1' }] },
44+
{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] },
45+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Q2' }] },
46+
{ type: 'paragraph', content: [{ type: 'text', text: 'A2' }] },
47+
],
48+
}),
49+
/** 思维导图模板:创建时由 createFromTemplate 调 createDefaultMindmap 动态生成(全新节点 id),此处仅占位 */
50+
mindmap: '',
51+
free: '',
52+
'qa-grid': JSON.stringify({
53+
rows: [],
54+
}),
55+
timeline: JSON.stringify({
56+
events: [],
57+
}),
58+
blank: '',
59+
video: JSON.stringify({
60+
type: 'doc',
61+
content: [
62+
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '视频笔记' }] },
63+
{ type: 'paragraph', content: [{ type: 'text', text: '在此记录视频学习内容,可使用时间戳标记关联视频进度。' }] },
64+
],
65+
}),
66+
/** v0.11.0: 待办笔记模板占位内容(实际创建时由 createTodoNote 动态生成) */
67+
todo: createEmptyTodoTemplate(),
68+
};
69+
70+
/** 各模板创建时的默认标题 */
71+
export const TEMPLATE_TITLES: Record<Note['template'], string> = {
72+
outline: '大纲笔记',
73+
cornell: '康奈尔笔记',
74+
qa: '问答笔记',
75+
mindmap: '思维导图笔记',
76+
free: '自由笔记',
77+
'qa-grid': '问答网格',
78+
timeline: '时间线笔记',
79+
blank: '空白笔记',
80+
video: '视频笔记',
81+
/** v0.11.0 */
82+
todo: '待办笔记',
83+
};
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* 笔记状态仓库 — 文件夹 CRUD slice
3+
* Note store — folder CRUD slice
4+
*
5+
* @ai-context: 从 useNoteStore 拆出。数据层 NoteFolder 支持 parentId 嵌套
6+
* (UI 仅渲染一级),删除必须整树处理:deleteFolder 把整树笔记移到根目录,
7+
* deleteFolderWithNotes 真删除整树笔记并清理搜索/链接索引,避免 parentId
8+
* 悬挂与孤儿笔记。
9+
* @ai-context: Extracted from useNoteStore. Deletion always walks the whole
10+
* folder subtree — either relocating its notes to root or deleting them.
11+
*/
12+
import { noteStore, noteFolderStore } from '@/lib/storage';
13+
import { createWithLog, updateWithLog, deleteWithLog } from '@/lib/storage/writeWithLog';
14+
import { dexieSearchIndexer } from '@/lib/search/dexieSearchIndexer';
15+
import { removeLinks } from '../lib/links/noteLinkStore';
16+
import { collectFolderTreeIds } from '../lib/folderTree';
17+
import type { NoteSlice, NoteState } from './noteStoreTypes';
18+
19+
type FolderSliceActions = Pick<NoteState,
20+
| 'loadFolders' | 'createFolder' | 'updateFolder'
21+
| 'deleteFolder' | 'deleteFolderWithNotes' | 'selectFolder'
22+
>;
23+
24+
export const createFolderSlice: NoteSlice<FolderSliceActions> = (set, get) => ({
25+
loadFolders: async () => {
26+
const folders = await noteFolderStore.getAll();
27+
set({ folders });
28+
},
29+
30+
createFolder: async (name, parentId?, color?) => {
31+
const folderData = {
32+
name,
33+
parentId,
34+
color,
35+
createdAt: new Date(),
36+
order: Date.now(),
37+
};
38+
const id = await createWithLog(noteFolderStore, 'noteFolders', folderData);
39+
await get().loadFolders();
40+
return id;
41+
},
42+
43+
updateFolder: async (id, changes) => {
44+
await updateWithLog(noteFolderStore, 'noteFolders', id, changes);
45+
await get().loadFolders();
46+
},
47+
48+
deleteFolder: async (id) => {
49+
// 递归收集分组树(数据层支持 parentId 嵌套,UI 仅渲染一级)
50+
const { notes, folders, selectedFolderId } = get();
51+
const treeIds = collectFolderTreeIds(folders, id);
52+
// 整棵分组树下的笔记移到根目录(folderId 设为 undefined)
53+
const affected = notes.filter((n) => n.folderId && treeIds.includes(n.folderId));
54+
for (const note of affected) {
55+
if (note.id !== undefined) {
56+
await updateWithLog(noteStore, 'notes', note.id, { folderId: undefined });
57+
}
58+
}
59+
// 删除分组树(含根与全部子孙,避免 parentId 悬挂)
60+
for (const folderId of treeIds) {
61+
await deleteWithLog(noteFolderStore, 'noteFolders', folderId);
62+
}
63+
if (selectedFolderId && treeIds.includes(selectedFolderId)) {
64+
set({ selectedFolderId: null });
65+
}
66+
await get().loadFolders();
67+
await get().loadNotes();
68+
},
69+
70+
deleteFolderWithNotes: async (id) => {
71+
const { notes, folders, selectedNoteId, selectedFolderId } = get();
72+
const treeIds = collectFolderTreeIds(folders, id);
73+
// 整棵分组树下的笔记全部真删除(清理搜索/链接索引)
74+
const noteIds = notes
75+
.filter((n) => n.folderId && treeIds.includes(n.folderId))
76+
.map((n) => n.id)
77+
.filter((nid): nid is string => nid !== undefined);
78+
for (const noteId of noteIds) {
79+
await deleteWithLog(noteStore, 'notes', noteId);
80+
try { await dexieSearchIndexer.remove(noteId); } catch { /* 忽略 */ }
81+
removeLinks(noteId).catch((err) => {
82+
console.debug('[noteStore] removeLinks failed (folder delete)', noteId, err);
83+
});
84+
}
85+
// 删除分组树(含根与全部子孙)
86+
for (const folderId of treeIds) {
87+
await deleteWithLog(noteFolderStore, 'noteFolders', folderId);
88+
}
89+
if (selectedNoteId && noteIds.includes(selectedNoteId)) {
90+
set({ selectedNoteId: null });
91+
}
92+
if (selectedFolderId && treeIds.includes(selectedFolderId)) {
93+
set({ selectedFolderId: null });
94+
}
95+
await get().loadFolders();
96+
await get().loadNotes();
97+
},
98+
99+
selectFolder: (id) => {
100+
set({ selectedFolderId: id });
101+
},
102+
});

0 commit comments

Comments
 (0)