Skip to main content

jasper_core/
serialize.rs

1//! 写回:把编辑后的笔记重新序列化为 Joplin 的 `.md` 格式。
2//!
3//! 设计原则:编辑现有笔记时,**保留原文件的元数据块逐字不变**,只替换
4//! 标题、正文,并刷新 updated_time / user_updated_time。这样与 Joplin
5//! 双向兼容、diff 最小,且无需复刻字段顺序与转义规则。
6
7use anyhow::{anyhow, Result};
8
9/// Unix 毫秒 → Joplin 的 ISO 时间 `YYYY-MM-DDTHH:mm:ss.SSSZ`(UTC)。
10pub fn format_iso(ms: i64) -> String {
11    chrono::DateTime::from_timestamp_millis(ms)
12        .unwrap_or_default()
13        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14        .to_string()
15}
16
17/// 当前时间(Unix 毫秒)。
18/// 用 std 而非 chrono::Utc::now():省掉 chrono 的 clock/wasmbind feature,
19/// 插件沙箱(wasm32-unknown-unknown)才能零 wasm-bindgen 依赖地编译 core。
20pub fn now_ms() -> i64 {
21    std::time::SystemTime::now()
22        .duration_since(std::time::UNIX_EPOCH)
23        .expect("系统时钟早于 Unix 纪元")
24        .as_millis() as i64
25}
26
27/// 生成一个新的 32 位十六进制条目 ID。
28pub fn new_id() -> String {
29    let mut bytes = [0u8; 16];
30    getrandom::getrandom(&mut bytes).expect("getrandom 失败");
31    let mut s = String::with_capacity(32);
32    for b in bytes {
33        s.push_str(&format!("{b:02x}"));
34    }
35    s
36}
37
38/// 把现有笔记的原始内容更新为新的标题/正文,并刷新更新时间,其余元数据原样保留。
39pub fn update_note_md(original: &str, new_title: &str, new_body: &str, now: i64) -> Result<String> {
40    let lines: Vec<&str> = original.split('\n').collect();
41
42    // 自底向上找到 正文与元数据之间的分隔空行(与解析逻辑一致)
43    let mut sep = None;
44    let mut i = lines.len();
45    while i > 0 {
46        i -= 1;
47        let t = lines[i].trim();
48        if t.is_empty() {
49            sep = Some(i);
50            break;
51        }
52        if !t.contains(':') {
53            break; // 非法元数据行,停止
54        }
55    }
56    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
57
58    let iso = format_iso(now);
59    let new_meta: Vec<String> = lines[sep + 1..]
60        .iter()
61        .map(|l| {
62            let key = l.trim_start();
63            if key.starts_with("updated_time:") {
64                format!("updated_time: {iso}")
65            } else if key.starts_with("user_updated_time:") {
66                format!("user_updated_time: {iso}")
67            } else {
68                (*l).to_string()
69            }
70        })
71        .collect();
72
73    Ok(format!("{new_title}\n\n{new_body}\n\n{}", new_meta.join("\n")))
74}
75
76/// 把现有笔记移动到新笔记本:只改 `parent_id` 并刷新更新时间,
77/// 标题/正文/其余元数据逐字保留(与 Joplin diff 最小、双向兼容)。
78pub fn move_note_md(original: &str, new_parent_id: &str, now: i64) -> Result<String> {
79    let lines: Vec<&str> = original.split('\n').collect();
80
81    // 自底向上找正文与元数据之间的分隔空行(与解析/更新逻辑一致)
82    let mut sep = None;
83    let mut i = lines.len();
84    while i > 0 {
85        i -= 1;
86        let t = lines[i].trim();
87        if t.is_empty() {
88            sep = Some(i);
89            break;
90        }
91        if !t.contains(':') {
92            break;
93        }
94    }
95    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
96
97    let iso = format_iso(now);
98    let new_meta: Vec<String> = lines[sep + 1..]
99        .iter()
100        .map(|l| {
101            let key = l.trim_start();
102            if key.starts_with("parent_id:") {
103                format!("parent_id: {new_parent_id}")
104            } else if key.starts_with("updated_time:") {
105                format!("updated_time: {iso}")
106            } else if key.starts_with("user_updated_time:") {
107                format!("user_updated_time: {iso}")
108            } else {
109                (*l).to_string()
110            }
111        })
112        .collect();
113
114    // 标题/正文段(含其内部空行)逐字保留,仅替换元数据块
115    let head = lines[..sep].join("\n");
116    Ok(format!("{head}\n\n{}", new_meta.join("\n")))
117}
118
119/// 生成一篇新笔记的 `.md` 内容(字段顺序对齐真实 Joplin 笔记)。
120/// `is_todo` 为 true 时建为待办(is_todo: 1),否则普通笔记。
121pub fn new_note_md(id: &str, parent_id: &str, title: &str, body: &str, is_todo: bool, now: i64) -> String {
122    let iso = format_iso(now);
123    let props = [
124        format!("id: {id}"),
125        format!("parent_id: {parent_id}"),
126        format!("created_time: {iso}"),
127        format!("updated_time: {iso}"),
128        "is_conflict: 0".to_string(),
129        "latitude: 0.00000000".to_string(),
130        "longitude: 0.00000000".to_string(),
131        "altitude: 0.0000".to_string(),
132        "author: ".to_string(),
133        "source_url: ".to_string(),
134        format!("is_todo: {}", if is_todo { 1 } else { 0 }),
135        "todo_due: 0".to_string(),
136        "todo_completed: 0".to_string(),
137        "source: jasper".to_string(),
138        "source_application: net.cozic.jasper".to_string(),
139        "application_data: ".to_string(),
140        "order: 0".to_string(),
141        format!("user_created_time: {iso}"),
142        format!("user_updated_time: {iso}"),
143        "encryption_cipher_text: ".to_string(),
144        "encryption_applied: 0".to_string(),
145        "markup_language: 1".to_string(),
146        "is_shared: 0".to_string(),
147        "share_id: ".to_string(),
148        "conflict_original_id: ".to_string(),
149        "master_key_id: ".to_string(),
150        "user_data: ".to_string(),
151        "deleted_time: 0".to_string(),
152        "type_: 1".to_string(),
153    ];
154    format!("{title}\n\n{body}\n\n{}", props.join("\n"))
155}
156
157/// 生成一个新笔记本(type_=2)的 `.md` 内容(字段顺序对齐真实 Joplin 笔记本)。
158/// 笔记本无正文段,仅 `标题\n\n元数据`。
159pub fn new_folder_md(id: &str, parent_id: &str, title: &str, now: i64) -> String {
160    let iso = format_iso(now);
161    let props = [
162        format!("id: {id}"),
163        format!("created_time: {iso}"),
164        format!("updated_time: {iso}"),
165        format!("user_created_time: {iso}"),
166        format!("user_updated_time: {iso}"),
167        "encryption_cipher_text: ".to_string(),
168        "encryption_applied: 0".to_string(),
169        format!("parent_id: {parent_id}"),
170        "is_shared: 0".to_string(),
171        "share_id: ".to_string(),
172        "master_key_id: ".to_string(),
173        "icon: ".to_string(),
174        "user_data: ".to_string(),
175        "deleted_time: 0".to_string(),
176        "type_: 2".to_string(),
177    ];
178    format!("{title}\n\n{}", props.join("\n"))
179}
180
181/// 把现有笔记本移动到新的父笔记本(改 parent_id),刷新更新时间,其余元数据原样保留。
182/// 笔记本无正文段(仅标题),故标题逐字保留、仅重写元数据块。
183pub fn move_folder_md(original: &str, new_parent_id: &str, now: i64) -> Result<String> {
184    let lines: Vec<&str> = original.split('\n').collect();
185    let mut sep = None;
186    let mut i = lines.len();
187    while i > 0 {
188        i -= 1;
189        let t = lines[i].trim();
190        if t.is_empty() {
191            sep = Some(i);
192            break;
193        }
194        if !t.contains(':') {
195            break;
196        }
197    }
198    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
199    let iso = format_iso(now);
200    let new_meta: Vec<String> = lines[sep + 1..]
201        .iter()
202        .map(|l| {
203            let key = l.trim_start();
204            if key.starts_with("parent_id:") {
205                format!("parent_id: {new_parent_id}")
206            } else if key.starts_with("updated_time:") {
207                format!("updated_time: {iso}")
208            } else if key.starts_with("user_updated_time:") {
209                format!("user_updated_time: {iso}")
210            } else {
211                (*l).to_string()
212            }
213        })
214        .collect();
215    let head = lines[..sep].join("\n");
216    Ok(format!("{head}\n\n{}", new_meta.join("\n")))
217}
218
219/// 重命名笔记本:只改标题并刷新更新时间,parent_id 等元数据逐字保留。
220/// 笔记本无正文段(仅 `标题\n\n元数据`),故整段标题以新标题替换、仅重写元数据块。
221pub fn rename_folder_md(original: &str, new_title: &str, now: i64) -> Result<String> {
222    let lines: Vec<&str> = original.split('\n').collect();
223    let mut sep = None;
224    let mut i = lines.len();
225    while i > 0 {
226        i -= 1;
227        let t = lines[i].trim();
228        if t.is_empty() {
229            sep = Some(i);
230            break;
231        }
232        if !t.contains(':') {
233            break;
234        }
235    }
236    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
237    let iso = format_iso(now);
238    let new_meta: Vec<String> = lines[sep + 1..]
239        .iter()
240        .map(|l| {
241            let key = l.trim_start();
242            if key.starts_with("updated_time:") {
243                format!("updated_time: {iso}")
244            } else if key.starts_with("user_updated_time:") {
245                format!("user_updated_time: {iso}")
246            } else {
247                (*l).to_string()
248            }
249        })
250        .collect();
251    Ok(format!("{new_title}\n\n{}", new_meta.join("\n")))
252}
253
254/// 生成一个新资源(type_=4)的元数据 `.md` 内容。
255/// 字段集/顺序/默认值对齐真实 Joplin 资源(含 OCR 字段:ocr_driver_id 默认 1、ocr_status 0)。
256/// 资源条目无正文段,仅 `标题\n\n元数据`。
257pub fn new_resource_md(
258    id: &str,
259    title: &str,
260    mime: &str,
261    file_extension: &str,
262    size: i64,
263    now: i64,
264) -> String {
265    let iso = format_iso(now);
266    let props = [
267        format!("id: {id}"),
268        format!("mime: {mime}"),
269        "filename: ".to_string(),
270        format!("created_time: {iso}"),
271        format!("updated_time: {iso}"),
272        format!("user_created_time: {iso}"),
273        format!("user_updated_time: {iso}"),
274        format!("file_extension: {file_extension}"),
275        "encryption_cipher_text: ".to_string(),
276        "encryption_applied: 0".to_string(),
277        "encryption_blob_encrypted: 0".to_string(),
278        format!("size: {size}"),
279        "is_shared: 0".to_string(),
280        "share_id: ".to_string(),
281        "master_key_id: ".to_string(),
282        "user_data: ".to_string(),
283        format!("blob_updated_time: {now}"),
284        "ocr_text: ".to_string(),
285        "ocr_details: ".to_string(),
286        "ocr_status: 0".to_string(),
287        "ocr_error: ".to_string(),
288        "ocr_driver_id: 1".to_string(),
289        "type_: 4".to_string(),
290    ];
291    format!("{title}\n\n{}", props.join("\n"))
292}
293
294/// 更新资源条目的标题并刷新更新时间,其余元数据原样保留。
295/// 资源条目“正文段”仅一行标题,故直接以新标题替换。
296pub fn update_resource_md(original: &str, new_title: &str, now: i64) -> Result<String> {
297    let lines: Vec<&str> = original.split('\n').collect();
298    let mut sep = None;
299    let mut i = lines.len();
300    while i > 0 {
301        i -= 1;
302        let t = lines[i].trim();
303        if t.is_empty() {
304            sep = Some(i);
305            break;
306        }
307        if !t.contains(':') {
308            break;
309        }
310    }
311    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
312    let iso = format_iso(now);
313    let new_meta: Vec<String> = lines[sep + 1..]
314        .iter()
315        .map(|l| {
316            let key = l.trim_start();
317            if key.starts_with("updated_time:") {
318                format!("updated_time: {iso}")
319            } else if key.starts_with("user_updated_time:") {
320                format!("user_updated_time: {iso}")
321            } else {
322                (*l).to_string()
323            }
324        })
325        .collect();
326    Ok(format!("{new_title}\n\n{}", new_meta.join("\n")))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::parser;
333
334    const SAMPLE: &str = "原标题\n\n原正文第一行\n\n原正文第二行\n\nid: a1b2c3d4e5f60718293a4b5c6d7e8f90\nparent_id: 0f1e2d3c4b5a69788796a5b4c3d2e1f0\ncreated_time: 2024-01-01T00:00:00.000Z\nupdated_time: 2024-01-01T00:00:00.000Z\nsource_url: https://x.com\nmarkup_language: 1\nuser_updated_time: 2024-01-01T00:00:00.000Z\ntype_: 1";
335
336    #[test]
337    fn update_preserves_metadata_changes_body_and_time() {
338        let out = update_note_md(SAMPLE, "新标题", "新正文", 1_700_000_000_000).unwrap();
339        let raw = parser::parse_item(&out).unwrap();
340        let note = parser::to_note(&raw).unwrap();
341        assert_eq!(note.title, "新标题");
342        assert_eq!(note.body, "新正文");
343        // 其余元数据保留
344        assert_eq!(note.parent_id, "0f1e2d3c4b5a69788796a5b4c3d2e1f0");
345        assert_eq!(raw.prop("source_url"), Some("https://x.com"));
346        assert_eq!(raw.prop("id"), Some("a1b2c3d4e5f60718293a4b5c6d7e8f90"));
347        // created_time 不变,updated_time 已刷新
348        assert_eq!(note.created_time, 1_704_067_200_000); // 2024-01-01
349        assert_eq!(note.updated_time, 1_700_000_000_000);
350    }
351
352    #[test]
353    fn move_changes_parent_keeps_rest() {
354        let out = move_note_md(SAMPLE, "ffffffffffffffffffffffffffffffff", 1_700_000_000_000).unwrap();
355        let raw = parser::parse_item(&out).unwrap();
356        let note = parser::to_note(&raw).unwrap();
357        // parent 改了,标题/正文/其余元数据保留
358        assert_eq!(note.parent_id, "ffffffffffffffffffffffffffffffff");
359        assert_eq!(note.title, "原标题");
360        assert_eq!(note.body, "原正文第一行\n\n原正文第二行");
361        assert_eq!(raw.prop("source_url"), Some("https://x.com"));
362        assert_eq!(raw.prop("id"), Some("a1b2c3d4e5f60718293a4b5c6d7e8f90"));
363        // created_time 不变,updated_time 已刷新
364        assert_eq!(note.created_time, 1_704_067_200_000);
365        assert_eq!(note.updated_time, 1_700_000_000_000);
366    }
367
368    #[test]
369    fn new_note_is_parseable() {
370        let id = "ffffffffffffffffffffffffffffffff";
371        let out = new_note_md(id, "parentparentparentparentparent12", "标题", "正文内容", false, 1_700_000_000_000);
372        let raw = parser::parse_item(&out).unwrap();
373        let note = parser::to_note(&raw).unwrap();
374        assert_eq!(note.id, id);
375        assert_eq!(note.title, "标题");
376        assert_eq!(note.body, "正文内容");
377        assert_eq!(note.parent_id, "parentparentparentparentparent12");
378        assert_eq!(note.markup_language, crate::model::MarkupLanguage::Markdown);
379        assert!(!note.is_todo);
380    }
381
382    #[test]
383    fn new_todo_sets_is_todo() {
384        let out = new_note_md("ffffffffffffffffffffffffffffffff", "parentparentparentparentparent12", "待办", "", true, 1_700_000_000_000);
385        let note = parser::to_note(&parser::parse_item(&out).unwrap()).unwrap();
386        assert!(note.is_todo);
387        assert!(!note.todo_completed);
388    }
389
390    #[test]
391    fn new_folder_is_parseable() {
392        let id = "abcabcabcabcabcabcabcabcabcabc12";
393        let out = new_folder_md(id, "parentparentparentparentparent12", "我的笔记本", 1_700_000_000_000);
394        let raw = parser::parse_item(&out).unwrap();
395        assert_eq!(raw.item_type(), crate::model::ItemType::Folder);
396        let f = parser::to_folder(&raw).unwrap();
397        assert_eq!(f.id, id);
398        assert_eq!(f.title, "我的笔记本");
399        assert_eq!(f.parent_id, "parentparentparentparentparent12");
400    }
401
402    #[test]
403    fn move_folder_changes_parent() {
404        let orig = new_folder_md("abcabcabcabcabcabcabcabcabcabc12", "oldoldoldoldoldoldoldoldoldold12", "本子", 1_700_000_000_000);
405        let out = move_folder_md(&orig, "newnewnewnewnewnewnewnewnewnew12", 1_700_000_999_000).unwrap();
406        let f = parser::to_folder(&parser::parse_item(&out).unwrap()).unwrap();
407        assert_eq!(f.parent_id, "newnewnewnewnewnewnewnewnewnew12");
408        assert_eq!(f.title, "本子");
409        assert_eq!(f.updated_time, 1_700_000_999_000);
410    }
411
412    #[test]
413    fn rename_folder_changes_title_keeps_parent() {
414        let orig = new_folder_md("abcabcabcabcabcabcabcabcabcabc12", "parentparentparentparentparent12", "旧名字", 1_700_000_000_000);
415        let out = rename_folder_md(&orig, "新名字", 1_700_000_999_000).unwrap();
416        let f = parser::to_folder(&parser::parse_item(&out).unwrap()).unwrap();
417        assert_eq!(f.title, "新名字");
418        assert_eq!(f.parent_id, "parentparentparentparentparent12"); // 父级逐字保留
419        assert_eq!(f.updated_time, 1_700_000_999_000); // 刷新更新时间
420    }
421
422    #[test]
423    fn new_resource_is_parseable() {
424        let id = "abcdef0123456789abcdef0123456789";
425        let out = new_resource_md(id, "photo.png", "image/png", "png", 12345, 1_700_000_000_000);
426        let raw = parser::parse_item(&out).unwrap();
427        assert_eq!(raw.item_type(), crate::model::ItemType::Resource);
428        let r = parser::to_resource(&raw).unwrap();
429        assert_eq!(r.id, id);
430        assert_eq!(r.title, "photo.png");
431        assert_eq!(r.mime, "image/png");
432        assert_eq!(r.file_extension, "png");
433        assert_eq!(r.size, 12345);
434        // 资源条目无正文
435        assert!(raw.body.is_none());
436        assert_eq!(raw.prop("ocr_driver_id"), Some("1"));
437    }
438
439    #[test]
440    fn rename_resource_keeps_metadata() {
441        let id = "abcdef0123456789abcdef0123456789";
442        let orig = new_resource_md(id, "old.png", "image/png", "png", 99, 1_700_000_000_000);
443        let out = update_resource_md(&orig, "新名字.png", 1_700_000_999_000).unwrap();
444        let raw = parser::parse_item(&out).unwrap();
445        let r = parser::to_resource(&raw).unwrap();
446        assert_eq!(r.title, "新名字.png");
447        assert_eq!(r.id, id); // id 不变
448        assert_eq!(r.mime, "image/png");
449        assert_eq!(r.size, 99);
450        assert_eq!(r.updated_time, 1_700_000_999_000); // 已刷新
451    }
452
453    #[test]
454    fn new_id_is_32_hex() {
455        let id = new_id();
456        assert_eq!(id.len(), 32);
457        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
458        assert_ne!(new_id(), new_id());
459    }
460}