1use crate::model::*;
11use anyhow::{anyhow, Result};
12use std::collections::HashMap;
13
14pub 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 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; 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 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 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
67fn 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
80pub 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 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 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 #[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 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 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}