Skip to main content

jasper_core/
parser.rs

1//! Joplin 条目文件(`<id>.md`)解析器。
2//!
3//! 算法逆向自 joplin/packages/lib/models/BaseItem.ts:589-632 (unserialize)
4//! 并已对 JopinData 真实数据验证(笔记/笔记本/资源/标签/note_tag)。
5//!
6//! 文件结构(段间以单个空行分隔):
7//!   <标题>\n\n<正文 markdown>\n\n<key: value 元数据块>
8//! 笔记(type_=1)才有正文段;note_tag(type_=6) 无标题无空行(纯元数据)。
9
10use crate::model::*;
11use anyhow::{anyhow, Result};
12use std::collections::HashMap;
13
14/// 解析一个条目文件内容为 RawItem。
15pub fn parse_item(content: &str) -> Result<RawItem> {
16    let lines: Vec<&str> = content.split('\n').collect();
17    let mut props: HashMap<String, String> = HashMap::new();
18
19    // 从文件末尾向上扫描,连续的非空行=元数据块,遇到第一个空行即为分隔符。
20    // 若全程没有空行(如 note_tag),则整篇都是元数据,正文段为空。
21    let mut body_section_len = 0usize;
22    let mut found_separator = false;
23    let mut i = lines.len();
24    while i > 0 {
25        i -= 1;
26        let line = lines[i].trim();
27        if line.is_empty() {
28            body_section_len = i; // lines[0..i] 是 标题+正文 段
29            found_separator = true;
30            break;
31        }
32        let p = line
33            .find(':')
34            .ok_or_else(|| anyhow!("Invalid property line: {line:?}"))?;
35        let key = line[..p].trim().to_string();
36        let value = unescape(line[p + 1..].trim());
37        // 自底向上,先出现的(更靠下的)保留;正常不会有重复键。
38        props.entry(key).or_insert(value);
39    }
40    if !found_separator {
41        body_section_len = 0;
42    }
43
44    let type_ = props
45        .get("type_")
46        .ok_or_else(|| anyhow!("Missing required property: type_"))?
47        .parse::<i64>()
48        .map_err(|_| anyhow!("Invalid type_"))?;
49
50    let body_section = &lines[..body_section_len];
51    let title = body_section.first().map(|s| s.to_string());
52    // 正文 = 跳过标题(0)与其后的空行(1),其余 join。仅笔记取正文。
53    let body = if type_ == ItemType::Note as i64 && body_section.len() >= 2 {
54        Some(body_section[2..].join("\n"))
55    } else {
56        None
57    };
58
59    Ok(RawItem {
60        type_,
61        title,
62        body,
63        props,
64    })
65}
66
67/// 反转义元数据字段值。
68/// 来源:BaseItem.ts:444-449 unserialize_format。
69/// 顺序需与源码一致:\\n→\n、\\r→\r、\\\n→\\n、\\\r→\\r。
70fn unescape(s: &str) -> String {
71    if !s.contains('\\') {
72        return s.to_string();
73    }
74    s.replace("\\n", "\n")
75        .replace("\\r", "\r")
76        .replace("\\\n", "\\n")
77        .replace("\\\r", "\\r")
78}
79
80/// 解析 ISO `YYYY-MM-DDTHH:mm:ss.SSSZ`(UTC)为 Unix 毫秒。空值=0。
81/// 来源:BaseItem.ts:436-438。
82pub fn parse_time_ms(s: Option<&str>) -> i64 {
83    let s = match s {
84        Some(s) if !s.is_empty() => s,
85        _ => return 0,
86    };
87    chrono::DateTime::parse_from_rfc3339(s)
88        .map(|dt| dt.timestamp_millis())
89        .unwrap_or(0)
90}
91
92fn s(raw: &RawItem, key: &str) -> String {
93    raw.prop(key).unwrap_or("").to_string()
94}
95
96fn i(raw: &RawItem, key: &str) -> i64 {
97    raw.prop(key).and_then(|v| v.parse::<i64>().ok()).unwrap_or(0)
98}
99
100fn b(raw: &RawItem, key: &str) -> bool {
101    raw.prop(key).map(|v| v == "1").unwrap_or(false)
102}
103
104pub fn to_note(raw: &RawItem) -> Result<Note> {
105    Ok(Note {
106        id: raw.id().ok_or_else(|| anyhow!("note missing id"))?.to_string(),
107        parent_id: s(raw, "parent_id"),
108        title: raw.title.clone().unwrap_or_default(),
109        body: raw.body.clone().unwrap_or_default(),
110        created_time: parse_time_ms(raw.prop("created_time")),
111        updated_time: parse_time_ms(raw.prop("updated_time")),
112        markup_language: MarkupLanguage::from_i64(i(raw, "markup_language")),
113        is_todo: b(raw, "is_todo"),
114        todo_completed: i(raw, "todo_completed") != 0,
115        is_conflict: b(raw, "is_conflict"),
116        source_url: s(raw, "source_url"),
117        order: i(raw, "order"),
118    })
119}
120
121pub fn to_folder(raw: &RawItem) -> Result<Folder> {
122    Ok(Folder {
123        id: raw.id().ok_or_else(|| anyhow!("folder missing id"))?.to_string(),
124        parent_id: s(raw, "parent_id"),
125        title: raw.title.clone().unwrap_or_default(),
126        created_time: parse_time_ms(raw.prop("created_time")),
127        updated_time: parse_time_ms(raw.prop("updated_time")),
128        icon: s(raw, "icon"),
129    })
130}
131
132pub fn to_resource(raw: &RawItem) -> Result<Resource> {
133    Ok(Resource {
134        id: raw.id().ok_or_else(|| anyhow!("resource missing id"))?.to_string(),
135        title: raw.title.clone().unwrap_or_default(),
136        mime: s(raw, "mime"),
137        file_extension: s(raw, "file_extension"),
138        size: i(raw, "size"),
139        updated_time: parse_time_ms(raw.prop("updated_time")),
140    })
141}
142
143pub fn to_tag(raw: &RawItem) -> Result<Tag> {
144    Ok(Tag {
145        id: raw.id().ok_or_else(|| anyhow!("tag missing id"))?.to_string(),
146        parent_id: s(raw, "parent_id"),
147        title: raw.title.clone().unwrap_or_default(),
148    })
149}
150
151pub fn to_note_tag(raw: &RawItem) -> Result<NoteTag> {
152    Ok(NoteTag {
153        id: raw.id().ok_or_else(|| anyhow!("note_tag missing id"))?.to_string(),
154        note_id: s(raw, "note_id"),
155        tag_id: s(raw, "tag_id"),
156    })
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::path::PathBuf;
163
164    fn data_dir() -> PathBuf {
165        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../JopinData")
166    }
167
168    #[test]
169    fn parses_note() {
170        let note_md = "标题行\n\n正文第一行\n\n正文第三行\n\nid: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nparent_id: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\ncreated_time: 2025-01-08T10:02:33.158Z\nupdated_time: 2025-01-08T10:03:18.215Z\nmarkup_language: 1\nis_todo: 0\ntype_: 1";
171        let raw = parse_item(note_md).unwrap();
172        assert_eq!(raw.item_type(), ItemType::Note);
173        let n = to_note(&raw).unwrap();
174        assert_eq!(n.title, "标题行");
175        // 正文应保留内部空行,且不含标题
176        assert_eq!(n.body, "正文第一行\n\n正文第三行");
177        assert_eq!(n.parent_id, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
178        assert_eq!(n.created_time, 1736330553158);
179        assert_eq!(n.markup_language, MarkupLanguage::Markdown);
180    }
181
182    #[test]
183    fn parses_note_tag_without_title_or_blank() {
184        // note_tag 纯元数据,无标题无空行
185        let md = "id: 5828867dfe7f4ce184f3dff4e0e1e756\nnote_id: 8565caad9a4c487996e2e5be171af413\ntag_id: 1434aa3b57b54f839c8a5fc4025cbf10\ntype_: 6";
186        let raw = parse_item(md).unwrap();
187        assert_eq!(raw.item_type(), ItemType::NoteTag);
188        assert!(raw.title.is_none());
189        let nt = to_note_tag(&raw).unwrap();
190        assert_eq!(nt.note_id, "8565caad9a4c487996e2e5be171af413");
191        assert_eq!(nt.tag_id, "1434aa3b57b54f839c8a5fc4025cbf10");
192    }
193
194    /// 用真实数据集验证:遍历 JopinData 全部 .md,全部成功解析,类型分布符合预期。
195    #[test]
196    fn parses_all_real_data() {
197        let dir = data_dir();
198        if !dir.exists() {
199            eprintln!("skipping: test data not found {:?}", dir);
200            return;
201        }
202        let (mut notes, mut folders, mut resources, mut tags, mut note_tags, mut others) =
203            (0, 0, 0, 0, 0, 0);
204        let mut errors = vec![];
205        for entry in std::fs::read_dir(&dir).unwrap() {
206            let path = entry.unwrap().path();
207            let name = path.file_name().unwrap().to_string_lossy();
208            // 只认 32hex + .md
209            if !(name.len() == 35 && name.ends_with(".md")) {
210                continue;
211            }
212            let content = std::fs::read_to_string(&path).unwrap();
213            match parse_item(&content) {
214                Ok(raw) => match raw.item_type() {
215                    ItemType::Note => {
216                        to_note(&raw).unwrap();
217                        notes += 1;
218                    }
219                    ItemType::Folder => {
220                        to_folder(&raw).unwrap();
221                        folders += 1;
222                    }
223                    ItemType::Resource => {
224                        to_resource(&raw).unwrap();
225                        resources += 1;
226                    }
227                    ItemType::Tag => {
228                        to_tag(&raw).unwrap();
229                        tags += 1;
230                    }
231                    ItemType::NoteTag => {
232                        to_note_tag(&raw).unwrap();
233                        note_tags += 1;
234                    }
235                    ItemType::Other => others += 1,
236                },
237                Err(e) => errors.push(format!("{}: {}", name, e)),
238            }
239        }
240        eprintln!(
241            "解析结果: 笔记={notes} 笔记本={folders} 资源={resources} 标签={tags} note_tag={note_tags} 其它={others}"
242        );
243        assert!(errors.is_empty(), "解析失败: {:#?}", errors);
244        // 与 grep 统计对齐:342/88/135/3/4,其余(43 revisions)归 Other
245        assert_eq!(notes, 342);
246        assert_eq!(folders, 88);
247        assert_eq!(resources, 135);
248        assert_eq!(tags, 3);
249        assert_eq!(note_tags, 4);
250    }
251}