Skip to main content

p_memory/
notes.rs

1use crate::{storage::{self, KnowledgeBase}, text, types::*, Error, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8fn default_chunk_chars() -> usize { 220 }
9
10/// 按文件路径同步一篇笔记的入参:库自己读文件,标题取文件名(去扩展名)。
11/// 路径存为笔记自己的一列:领域登记过根目录时存减掉根目录的相对路径,否则逐字符原样存。
12/// 它既用来读回正文,也用来定位同一文件。
13/// 正文真相源是文件:清洗只作用于送进索引的文本,库内不留任何正文副本。
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct NoteFileInput {
16    #[serde(flatten)] pub record: RecordInput,
17    /// 要读取的文件路径,同时作为 `(namespace, scope, path)` 的定位键。
18    pub path: PathBuf,
19    #[serde(default = "default_chunk_chars")] pub chunk_chars: usize,
20}
21impl NoteFileInput {
22    pub fn new(path: impl Into<PathBuf>) -> Self {
23        Self { record: RecordInput::default(), path: path.into(), chunk_chars: default_chunk_chars() }
24    }
25}
26
27/// 笔记的对外形态:库内只留路径,正文读时按 `source` 读文件,标题由文件名派生。
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Note {
30    #[serde(flatten)] pub header: RecordHeader,
31    /// 文件路径:写入时给进来的那条;库内一行存的是相对根目录的形态,读回时拼成绝对路径。
32    pub source: String,
33    /// 由 `source` 的文件名派生,不落库。
34    #[serde(default)] pub title: String,
35    pub chunk_chars: usize,
36}
37/// 切片不再重复携带 source/title;路径与标题经 `note_id` 关联 notes 取回。
38/// 切片正文只在索引里存一份:写入时切好、就地写进索引,`content` 读取时从索引取回。
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Chunk {
41    #[serde(flatten)] pub header: RecordHeader,
42    pub note_id: i64, pub ordinal: usize, pub offset: usize, pub limit: usize,
43    #[serde(default)] pub content: String,
44}
45/// `offset` 为 1 起始的起始行,`limit` 为行数。切片正文由 `content` 自带,
46/// 超长段落被多个切片共享同一行号也不影响取回。
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct TextChunk { pub ordinal: usize, pub offset: usize, pub limit: usize, pub content: String }
49
50/// Paragraph-aware chunking. Fenced code and tables are atomic, even above the
51/// target size. A long prose line may span chunks sharing that same line number.
52pub fn chunk_text(content: &str, target: usize) -> Result<Vec<TextChunk>> {
53    if !(16..=100_000).contains(&target) { return Err(Error::Validation("chunk_chars must be between 16 and 100000".into())); }
54    let lines: Vec<&str> = content.lines().collect();
55    let mut blocks: Vec<(usize, usize, String, bool)> = Vec::new();
56    let mut i = 0;
57    while i < lines.len() {
58        if lines[i].trim().is_empty() { i += 1; continue; }
59        let start = i;
60        let trimmed = lines[i].trim_start();
61        let fence_char = trimmed.chars().next().filter(|c| *c == '`' || *c == '~');
62        let fence_len = fence_char.map(|c| trimmed.chars().take_while(|x| *x == c).count()).unwrap_or(0);
63        let is_fence = fence_len >= 3;
64        let is_table = lines[i].contains('|') && i + 1 < lines.len() && {
65            let next = lines[i + 1].trim();
66            next.contains('-') && next.contains('|') && next.chars().all(|c| matches!(c, '-' | ':' | '|' | ' ' | '\t'))
67        };
68        i += 1;
69        if is_fence {
70            while i < lines.len() {
71                let line = lines[i].trim();
72                i += 1;
73                if line.chars().take_while(|c| Some(*c) == fence_char).count() >= fence_len
74                    && line.chars().all(|c| Some(c) == fence_char || c.is_whitespace()) { break; }
75            }
76        } else if is_table {
77            while i < lines.len() && lines[i].contains('|') && !lines[i].trim().is_empty() { i += 1; }
78        } else {
79            while i < lines.len() && !lines[i].trim().is_empty() {
80                let line = lines[i].trim_start();
81                if line.starts_with("```") || line.starts_with("~~~") || line.starts_with('#') { break; }
82                if i + 1 < lines.len() && lines[i].contains('|') && lines[i + 1].contains("---") { break; }
83                i += 1;
84            }
85        }
86        blocks.push((start, i, lines[start..i].join("\n"), is_fence || is_table));
87    }
88    let mut chunks = Vec::new();
89    for (start, end, body, atomic) in blocks {
90        if atomic || body.chars().count() <= target {
91            chunks.push(TextChunk { ordinal: 0, offset: start + 1, limit: end - start, content: body });
92            continue;
93        }
94        let mut current = String::new();
95        let mut count = 0;
96        let mut first_line = start + 1;
97        let mut last_line = first_line;
98        for (line_no, line) in lines.iter().enumerate().take(end).skip(start) {
99            if !current.is_empty() {
100                if count + 1 >= target {
101                    chunks.push(TextChunk { ordinal: 0, offset: first_line, limit: last_line - first_line + 1, content: std::mem::take(&mut current) });
102                    count = 0;
103                } else { current.push('\n'); count += 1; }
104            }
105            for ch in line.chars() {
106                if count == target {
107                    chunks.push(TextChunk { ordinal: 0, offset: first_line, limit: last_line - first_line + 1, content: std::mem::take(&mut current) });
108                    count = 0;
109                }
110                if current.is_empty() { first_line = line_no + 1; }
111                current.push(ch); count += 1; last_line = line_no + 1;
112            }
113        }
114        if !current.is_empty() { chunks.push(TextChunk { ordinal: 0, offset: first_line, limit: last_line - first_line + 1, content: current }); }
115    }
116    for (ordinal, chunk) in chunks.iter_mut().enumerate() { chunk.ordinal = ordinal; }
117    Ok(chunks)
118}
119
120/// 一篇笔记写入前的就绪形态:正文已读、切片已切、路径与标签已按库内规则解析。
121/// 组合写流程(先删后加)在动任何派生数据之前把它备齐,删除落地之后的新增不再有失败窗口。
122pub(crate) struct PreparedNote {
123    pub record: RecordInput,
124    pub namespace_id: i64, pub scope_id: i64, pub source: String,
125    pub given: String, pub title: String, pub split: Vec<TextChunk>,
126    pub chunk_chars: usize,
127}
128
129/// 读文件、切切片、解析领域与路径。term_id 的字典登记随调用方的事务提交。
130pub(crate) fn prepare_note(conn: &Connection, input: &NoteFileInput, split: Vec<TextChunk>) -> Result<PreparedNote> {
131    let given = input.path.to_string_lossy().into_owned();
132    storage::validate_identity("source", &given)?;
133    let namespace_id = storage::term_id(conn, &input.record.namespace)?;
134    let scope_id = storage::term_id(conn, &input.record.scope)?;
135    // 领域必须登记根目录:写入路径与它比对前缀,不重合直接拒绝、重合的部分裁掉。
136    // 库里存的永远是相对路径——项目从头到尾不知道前面的绝对路径是什么。
137    let root = storage::namespace_root(conn, namespace_id)?.ok_or_else(|| Error::Validation(
138        format!("namespace {} has no registered domain root; call notes.set_root before upserting notes", input.record.namespace)))?;
139    let source = relative_note_path(&root, &given)?;
140    let title = input.path.file_stem().map(|stem| stem.to_string_lossy().into_owned()).unwrap_or_default();
141    let (dirs, split_stem) = storage::split_note_path(&source);
142    // 文件名以写入时取好的 `title`(`file_stem`)为准,与落库的 `notes.name` 同源;
143    // `split_note_path` 只用来取目录段。
144    let stem = if title.is_empty() { split_stem } else { title.clone() };
145    // 这一份标签挂到这篇的每一条切片上:调用方给的 + 路径拆出来的。
146    let mut record = input.record.clone();
147    // 笔记的身份就是 `(namespace, scope, path)`,下游给的 id 一律忽略:
148    // 记录 id 由库自己分配,下游不能凭一个 id 凭空插一条笔记(那会让「笔记属于哪条路径」失守)。
149    record.id = None;
150    let mut tags = record.tags.clone();
151    tags.extend(dirs.iter().cloned());
152    if !stem.is_empty() { tags.push(stem.clone()); }
153    record.tags = tags.clone();
154    Ok(PreparedNote { record, namespace_id, scope_id, source, given, title, split, chunk_chars: input.chunk_chars })
155}
156
157/// 新增路径(内部方法):笔记与切片连同「正在写入」标记一次落主库,索引文档就地折好交回。
158/// 身份是 `(namespace, scope, path)`;调用方保证同一位要么是空的、要么刚被删除流程腾空。
159/// 切片记录全部新开——旧切片的向量已随删除流程作废,由向量对账照主库重算。
160pub(crate) fn add_note(conn: &Connection, note: &PreparedNote) -> Result<(Note, Vec<crate::index::IndexDocument>)> {
161    let (header, _) = storage::put_record(conn, RecordKind::Note, &note.record,
162        &json!({"chunk_chars": note.chunk_chars}), "")?;
163    // 文件名在写入这一刻就从路径取好(`file_stem` 认平台分隔符),随笔记落库:
164    // 索引那一列直接读它,索引侧补文档时也不必再拆一次路径。
165    conn.execute("INSERT INTO notes(record_id,namespace_id,scope_id,path,name) VALUES (?1,?2,?3,?4,?5)
166        ON CONFLICT(record_id) DO UPDATE SET namespace_id=excluded.namespace_id,scope_id=excluded.scope_id,path=excluded.path,name=excluded.name",
167        params![header.id, note.namespace_id, note.scope_id, note.source, note.title])?;
168    let mut documents = Vec::new();
169    for chunk in &note.split {
170        let content_digest = text::digest(&chunk.content);
171        let payload = json!({"note_id":header.id,"ordinal":chunk.ordinal,"offset":chunk.offset,"limit":chunk.limit});
172        let chunk_input = RecordInput { id: None, namespace: header.namespace.clone(), scope: header.scope.clone(),
173            tags: header.tags.clone(), evidence: vec![], metadata: header.metadata.clone(),
174            created_at_us: Some(header.created_at_us), updated_at_us: Some(header.updated_at_us), expected_revision: None };
175        let (chunk_header, document) = storage::put_record(conn, RecordKind::Chunk, &chunk_input, &payload, &chunk.content)?;
176        conn.execute("INSERT INTO chunks(record_id,note_id,ordinal,\"offset\",\"limit\",fingerprint) VALUES (?1,?2,?3,?4,?5,?6)",
177            params![chunk_header.id, header.id, chunk.ordinal as i64, chunk.offset as i64, chunk.limit as i64, content_digest])?;
178        documents.push(document);
179    }
180    Ok((Note { header, source: note.given.clone(), title: note.title.clone(), chunk_chars: note.chunk_chars }, documents))
181}
182
183/// 减掉领域根目录得到库里存的那条相对路径;不在根目录之内直接报错,不做猜测。
184/// 只统一分隔符,不改大小写——存的是什么名字,读回文件时就找什么名字。
185fn relative_note_path(root: &str, given: &str) -> Result<String> {
186    let root = root.replace('\\', "/");
187    let root = root.trim_end_matches('/');
188    let relative = given.replace('\\', "/");
189    let Some(relative) = relative.strip_prefix(root).and_then(|rest| rest.strip_prefix('/')) else {
190        return Err(Error::Validation(format!("note path {given} is outside the domain root {root}")));
191    };
192    if relative.is_empty() { return Err(Error::Validation("note path must name a file below the domain root".into())); }
193    Ok(relative.to_string())
194}
195
196#[derive(Clone)]
197pub struct NoteStore(pub(crate) KnowledgeBase);
198
199impl NoteStore {
200    /// 按文件路径同步一篇笔记。更新就是「先删后加」:同路径已有笔记,先把旧笔记连同
201    /// 它的切片整条删掉(写锁内落主库标记、删向量行与主库行,出锁后摘索引词条),
202    /// 再当新笔记写入;没有就直接新增。切片记录全部新开,向量由向量对账照主库重算。
203    pub fn upsert_file(&self, input: NoteFileInput) -> Result<WriteReceipt<Note>> {
204        let mut receipts = self.upsert_files(&[input])?;
205        let value = receipts.value.pop().ok_or_else(|| Error::Validation("empty upsert batch".into()))?;
206        Ok(WriteReceipt { value, revision: receipts.revision })
207    }
208
209    /// 批量版:一次写锁包住整批的 SQL,每篇各自完整地先删后加。
210    /// 索引操作(摘词条、交文档)在写锁外,由 Tantivy 自己的锁管。
211    /// 文件在动库之前全部读好切好——任何一处读取失败,整批原样拒绝,库一个字节都没动。
212    pub fn upsert_files(&self, inputs: &[NoteFileInput]) -> Result<WriteReceipt<Vec<Note>>> {
213        let split = inputs.iter().map(|input| -> Result<Vec<TextChunk>> {
214            let content = std::fs::read_to_string(&input.path)?;
215            chunk_text(&content, input.chunk_chars)
216        }).collect::<Result<Vec<_>>>()?;
217        let mut notes: Vec<Note> = Vec::new();
218        let (stale, documents) = self.0.with_writer_lock(|writer| {
219            let mut documents: Vec<crate::index::IndexDocument> = Vec::new();
220            let mut stale: Vec<i64> = Vec::new();
221            for (input, split) in inputs.iter().zip(split) {
222                // 解析与定位在标记事务里做:term_id 的字典登记随事务提交。
223                let tx = writer.conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
224                let prepared = prepare_note(&tx, input, split)?;
225                let by_path: Option<i64> = tx.query_row("SELECT record_id FROM notes WHERE namespace_id=?1 AND scope_id=?2 AND path=?3",
226                    params![prepared.namespace_id, prepared.scope_id, prepared.source], |r| r.get(0)).optional()?;
227                // 笔记只按 (namespace, scope, path) 定位:同路径就是同一篇,先删后加。
228                let mut removed: Vec<i64> = Vec::new();
229                if let Some(id) = by_path {
230                    // 切片排在笔记前面:删除按这个次序落库,RESTRICT 引用不会拦。
231                    let mut stmt = tx.prepare("SELECT record_id FROM chunks WHERE note_id=?1")?;
232                    for row in stmt.query_map([id], |r| r.get::<_, i64>(0))? { removed.push(row?); }
233                    removed.push(id);
234                    // 标记先行:主库上先把「正在删除」落定,派生动作排在它后面。
235                    storage::mark_records(&tx, &removed, storage::MARK_DELETING)?;
236                }
237                tx.commit()?;
238                if !removed.is_empty() {
239                    // 权威落:删向量行 + 删主库行(切片先于笔记)。
240                    let tx = writer.conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
241                    for id in &removed { storage::delete_record(&tx, &RecordKey { id: *id })?; }
242                    tx.commit()?;
243                    stale.extend(removed);
244                }
245                // 新增:记录连同「正在写入」标记一次落主库,索引文档就地折好带回。
246                let tx = writer.conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
247                let (note, docs) = add_note(&tx, &prepared)?;
248                tx.commit()?;
249                documents.extend(docs);
250                notes.push(note);
251            }
252            Ok((stale, documents))
253        })?;
254        // 索引操作在写锁外:先摘旧词条、再交新文档,各自走 Tantivy 的内部锁。
255        if !stale.is_empty() { self.0.index()?.stage_deletions(&stale)?; }
256        self.0.index_documents(&documents)?;
257        let revision = storage::current_revision(self.0.read()?.conn())?;
258        Ok(WriteReceipt { value: notes, revision })
259    }
260    /// 登记该知识领域的笔记根目录。登记之后写入的笔记路径必须是它的子路径:
261    /// 库里存相对路径,相对路径按段拆出的标签挂到这篇的每一条切片上。
262    pub fn set_root(&self, namespace: &str, root: &str) -> Result<()> {
263        storage::validate_identity("namespace", namespace)?;
264        if !std::path::Path::new(root).is_dir() {
265            return Err(Error::Validation(format!("domain root {root} is not an existing directory")));
266        }
267        let root = root.replace('\\', "/");
268        let root = root.trim_end_matches('/').to_string();
269        self.0.write(|writer| {
270            let namespace_id = storage::term_id(&writer.conn, namespace)?;
271            writer.conn.execute("INSERT INTO namespace_roots(namespace_id,root) VALUES (?1,?2)
272                ON CONFLICT(namespace_id) DO UPDATE SET root=excluded.root", params![namespace_id, root])?;
273            Ok(())
274        })
275    }
276    /// 注销该领域的笔记根目录登记。只影响之后写入时的路径计算,已入库的笔记不动。
277    /// 返回是否命中;没登记过的领域不报错。
278    pub fn unset_root(&self, namespace: &str) -> Result<bool> {
279        storage::validate_identity("namespace", namespace)?;
280        self.0.write(|writer| {
281            let namespace_id = storage::term_id(&writer.conn, namespace)?;
282            Ok(writer.conn.execute("DELETE FROM namespace_roots WHERE namespace_id=?1", [namespace_id])? > 0)
283        })
284    }
285    /// 该领域登记的笔记根目录;没登记就是 `None`。
286    pub fn root(&self, namespace: &str) -> Result<Option<String>> {
287        let state = self.0.read()?;
288        let conn = state.conn();
289        let namespace_id: Option<i64> = conn.query_row("SELECT id FROM strings WHERE text=?1",
290            [text::normalized_tag(namespace)], |r| r.get(0)).optional()?;
291        let Some(namespace_id) = namespace_id else { return Ok(None) };
292        storage::namespace_root(conn, namespace_id)
293    }
294    pub fn get(&self, id: i64, filter: &ReadFilter) -> Result<Note> {
295        storage::get(self.0.read()?.conn(), &RecordKey { id }, filter)
296    }
297    /// 批量读取一批笔记,只返回满足 `filter` 的那些。
298    ///
299    /// 语义等同于对每个 id 依次调用 `get`,但把过滤压成一条 SQL、一次取回,
300    /// 避免宿主逐条回库的往返开销。不满足过滤条件的 id 被静默跳过(不报错)。
301    pub fn get_many(&self, ids: &[i64], filter: &ReadFilter) -> Result<BTreeMap<i64, Note>> {
302        storage::load_many(self.0.read()?.conn(), ids, filter)
303    }
304    pub fn list(&self, page: &PageRequest) -> Result<Page<Note>> { storage::list(self.0.read()?.conn(), RecordKind::Note, page) }
305    pub fn get_chunk(&self, id: i64, filter: &ReadFilter) -> Result<Chunk> {
306        let state = self.0.read()?;
307        let conn = state.conn();
308        let mut chunk: Chunk = storage::get(conn, &RecordKey { id }, filter)?;
309        chunk.content = self.0.index()?.bodies(&[id])?.remove(&id).unwrap_or_default();
310        Ok(chunk)
311    }
312    pub fn chunks(&self, note_id: i64, filter: &ReadFilter) -> Result<Vec<Chunk>> {
313        let state = self.0.read()?;
314        let conn = state.conn();
315        let _: Note = storage::get(conn, &RecordKey { id: note_id }, filter)?;
316        let ids: Vec<i64> = {
317            let mut stmt = conn.prepare("SELECT record_id FROM chunks WHERE note_id=?1 ORDER BY ordinal")?;
318            let rows = stmt.query_map([note_id], |r| r.get::<_, i64>(0))?;
319            rows.collect::<std::result::Result<Vec<_>, _>>()?
320        };
321        // 一次批量取回:逐条 `get` 会为每片各跑一遍过滤与装配,一篇几百片就是几百次回库。
322        let mut loaded: BTreeMap<i64, Chunk> = storage::load_many(conn, &ids, filter)?;
323        let mut chunks: Vec<Chunk> = ids.iter().filter_map(|id| loaded.remove(id)).collect();
324        // 一批切片一次取回正文:正文只存在索引里,逐条回库或回源文件都没有意义。
325        let ids: Vec<i64> = chunks.iter().map(|chunk| chunk.header.id).collect();
326        let mut bodies = self.0.index()?.bodies(&ids)?;
327        for chunk in &mut chunks { chunk.content = bodies.remove(&chunk.header.id).unwrap_or_default(); }
328        Ok(chunks)
329    }
330    /// 批量删除笔记:主库查 id,命中才继续;单条也是批量的一种。
331    /// 每篇连同它的切片一起删(`chunks.note_id` 是 RESTRICT 引用):
332    /// 写锁内落主库标记、删向量行与主库行,出锁后摘索引词条。
333    /// 断电留下「正在删除」标记的,下次开机把这条删除做完。
334    pub fn delete(&self, ids: &[i64], filter: &ReadFilter) -> Result<WriteReceipt<usize>> {
335        let removed = self.0.delete_flow(ids, filter, RecordKind::Note, |tx, id| {
336            let mut out = Vec::new();
337            let mut stmt = tx.prepare("SELECT record_id FROM chunks WHERE note_id=?1")?;
338            for row in stmt.query_map([id], |r| r.get::<_, i64>(0))? { out.push(row?); }
339            Ok(out)
340        })?;
341        let revision = storage::current_revision(self.0.read()?.conn())?;
342        Ok(WriteReceipt { value: removed, revision })
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn split_note_path_separates_directories_from_the_file_name() {
352        assert_eq!(storage::split_note_path("notes/characters/overview.md"), (vec!["notes".to_string(), "characters".to_string()], "overview".to_string()));
353        // 目录段里的点不是扩展名:只有最后一段去后缀。
354        assert_eq!(storage::split_note_path("v1.2/角色.设定.md"), (vec!["v1.2".to_string()], "角色.设定".to_string()));
355        assert_eq!(storage::split_note_path("overview"), (Vec::new(), "overview".to_string()));
356    }
357
358    #[test]
359    fn note_paths_must_stay_inside_the_domain_root() {
360        assert_eq!(relative_note_path("E:/data/demo", r"E:\data\demo\notes\overview.md").unwrap(), "notes/overview.md");
361        // 前缀只是像,不是子路径;也不许正好等于根目录本身。
362        assert!(relative_note_path("E:/data/demo", "E:/data/demox/overview.md").is_err());
363        assert!(relative_note_path("E:/data/demo", "E:/data/other/overview.md").is_err());
364        assert!(relative_note_path("E:/data/demo", "E:/data/demo").is_err());
365    }
366}