use crate::model::*;
use anyhow::{anyhow, Result};
use std::collections::HashMap;
pub fn parse_item(content: &str) -> Result<RawItem> {
let lines: Vec<&str> = content.split('\n').collect();
let mut props: HashMap<String, String> = HashMap::new();
let mut body_section_len = 0usize;
let mut found_separator = false;
let mut i = lines.len();
while i > 0 {
i -= 1;
let line = lines[i].trim();
if line.is_empty() {
body_section_len = i; found_separator = true;
break;
}
let p = line
.find(':')
.ok_or_else(|| anyhow!("Invalid property line: {line:?}"))?;
let key = line[..p].trim().to_string();
let value = unescape(line[p + 1..].trim());
props.entry(key).or_insert(value);
}
if !found_separator {
body_section_len = 0;
}
let type_ = props
.get("type_")
.ok_or_else(|| anyhow!("Missing required property: type_"))?
.parse::<i64>()
.map_err(|_| anyhow!("Invalid type_"))?;
let body_section = &lines[..body_section_len];
let title = body_section.first().map(|s| s.to_string());
let body = if type_ == ItemType::Note as i64 && body_section.len() >= 2 {
Some(body_section[2..].join("\n"))
} else {
None
};
Ok(RawItem {
type_,
title,
body,
props,
})
}
fn unescape(s: &str) -> String {
if !s.contains('\\') {
return s.to_string();
}
s.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\\n", "\\n")
.replace("\\\r", "\\r")
}
pub fn parse_time_ms(s: Option<&str>) -> i64 {
let s = match s {
Some(s) if !s.is_empty() => s,
_ => return 0,
};
chrono::DateTime::parse_from_rfc3339(s)
.map(|dt| dt.timestamp_millis())
.unwrap_or(0)
}
fn s(raw: &RawItem, key: &str) -> String {
raw.prop(key).unwrap_or("").to_string()
}
fn i(raw: &RawItem, key: &str) -> i64 {
raw.prop(key).and_then(|v| v.parse::<i64>().ok()).unwrap_or(0)
}
fn b(raw: &RawItem, key: &str) -> bool {
raw.prop(key).map(|v| v == "1").unwrap_or(false)
}
pub fn to_note(raw: &RawItem) -> Result<Note> {
Ok(Note {
id: raw.id().ok_or_else(|| anyhow!("note missing id"))?.to_string(),
parent_id: s(raw, "parent_id"),
title: raw.title.clone().unwrap_or_default(),
body: raw.body.clone().unwrap_or_default(),
created_time: parse_time_ms(raw.prop("created_time")),
updated_time: parse_time_ms(raw.prop("updated_time")),
markup_language: MarkupLanguage::from_i64(i(raw, "markup_language")),
is_todo: b(raw, "is_todo"),
todo_completed: i(raw, "todo_completed") != 0,
is_conflict: b(raw, "is_conflict"),
source_url: s(raw, "source_url"),
order: i(raw, "order"),
})
}
pub fn to_folder(raw: &RawItem) -> Result<Folder> {
Ok(Folder {
id: raw.id().ok_or_else(|| anyhow!("folder missing id"))?.to_string(),
parent_id: s(raw, "parent_id"),
title: raw.title.clone().unwrap_or_default(),
created_time: parse_time_ms(raw.prop("created_time")),
updated_time: parse_time_ms(raw.prop("updated_time")),
icon: s(raw, "icon"),
})
}
pub fn to_resource(raw: &RawItem) -> Result<Resource> {
Ok(Resource {
id: raw.id().ok_or_else(|| anyhow!("resource missing id"))?.to_string(),
title: raw.title.clone().unwrap_or_default(),
mime: s(raw, "mime"),
file_extension: s(raw, "file_extension"),
size: i(raw, "size"),
updated_time: parse_time_ms(raw.prop("updated_time")),
})
}
pub fn to_tag(raw: &RawItem) -> Result<Tag> {
Ok(Tag {
id: raw.id().ok_or_else(|| anyhow!("tag missing id"))?.to_string(),
parent_id: s(raw, "parent_id"),
title: raw.title.clone().unwrap_or_default(),
})
}
pub fn to_note_tag(raw: &RawItem) -> Result<NoteTag> {
Ok(NoteTag {
id: raw.id().ok_or_else(|| anyhow!("note_tag missing id"))?.to_string(),
note_id: s(raw, "note_id"),
tag_id: s(raw, "tag_id"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../JopinData")
}
#[test]
fn parses_note() {
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";
let raw = parse_item(note_md).unwrap();
assert_eq!(raw.item_type(), ItemType::Note);
let n = to_note(&raw).unwrap();
assert_eq!(n.title, "标题行");
assert_eq!(n.body, "正文第一行\n\n正文第三行");
assert_eq!(n.parent_id, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
assert_eq!(n.created_time, 1736330553158);
assert_eq!(n.markup_language, MarkupLanguage::Markdown);
}
#[test]
fn parses_note_tag_without_title_or_blank() {
let md = "id: 5828867dfe7f4ce184f3dff4e0e1e756\nnote_id: 8565caad9a4c487996e2e5be171af413\ntag_id: 1434aa3b57b54f839c8a5fc4025cbf10\ntype_: 6";
let raw = parse_item(md).unwrap();
assert_eq!(raw.item_type(), ItemType::NoteTag);
assert!(raw.title.is_none());
let nt = to_note_tag(&raw).unwrap();
assert_eq!(nt.note_id, "8565caad9a4c487996e2e5be171af413");
assert_eq!(nt.tag_id, "1434aa3b57b54f839c8a5fc4025cbf10");
}
#[test]
fn parses_all_real_data() {
let dir = data_dir();
if !dir.exists() {
eprintln!("skipping: test data not found {:?}", dir);
return;
}
let (mut notes, mut folders, mut resources, mut tags, mut note_tags, mut others) =
(0, 0, 0, 0, 0, 0);
let mut errors = vec![];
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
let name = path.file_name().unwrap().to_string_lossy();
if !(name.len() == 35 && name.ends_with(".md")) {
continue;
}
let content = std::fs::read_to_string(&path).unwrap();
match parse_item(&content) {
Ok(raw) => match raw.item_type() {
ItemType::Note => {
to_note(&raw).unwrap();
notes += 1;
}
ItemType::Folder => {
to_folder(&raw).unwrap();
folders += 1;
}
ItemType::Resource => {
to_resource(&raw).unwrap();
resources += 1;
}
ItemType::Tag => {
to_tag(&raw).unwrap();
tags += 1;
}
ItemType::NoteTag => {
to_note_tag(&raw).unwrap();
note_tags += 1;
}
ItemType::Other => others += 1,
},
Err(e) => errors.push(format!("{}: {}", name, e)),
}
}
eprintln!(
"解析结果: 笔记={notes} 笔记本={folders} 资源={resources} 标签={tags} note_tag={note_tags} 其它={others}"
);
assert!(errors.is_empty(), "解析失败: {:#?}", errors);
assert_eq!(notes, 342);
assert_eq!(folders, 88);
assert_eq!(resources, 135);
assert_eq!(tags, 3);
assert_eq!(note_tags, 4);
}
}