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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct NoteFileInput {
16 #[serde(flatten)] pub record: RecordInput,
17 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#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Note {
30 #[serde(flatten)] pub header: RecordHeader,
31 pub source: String,
33 #[serde(default)] pub title: String,
35 pub chunk_chars: usize,
36}
37#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct TextChunk { pub ordinal: usize, pub offset: usize, pub limit: usize, pub content: String }
49
50pub 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
120pub(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
129pub(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 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 let stem = if title.is_empty() { split_stem } else { title.clone() };
145 let mut record = input.record.clone();
147 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
157pub(crate) fn add_note(conn: &Connection, note: &PreparedNote) -> Result<(Note, Vec<crate::index::IndexDocument>)> {
161 let (header, _) = storage::put_record(conn, RecordKind::Note, ¬e.record,
162 &json!({"chunk_chars": note.chunk_chars}), "")?;
163 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 ¬e.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
183fn 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 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 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 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 let mut removed: Vec<i64> = Vec::new();
229 if let Some(id) = by_path {
230 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 storage::mark_records(&tx, &removed, storage::MARK_DELETING)?;
236 }
237 tx.commit()?;
238 if !removed.is_empty() {
239 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 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 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 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 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 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 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 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 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 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 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 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}