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/// 新建标签条目(type_=5)。字段集与顺序逐字对齐 Joplin 真实数据
182/// (含空 `parent_id`/`user_data`,**无 `deleted_time`**——标签不进回收站)。
183/// title 已由调用方 trim(Joplin `Tag.save` 也 trim;此处不再改动大小写,混合大小写标签受支持)。
184pub fn new_tag_md(id: &str, title: &str, now: i64) -> String {
185    let iso = format_iso(now);
186    let props = [
187        format!("id: {id}"),
188        format!("created_time: {iso}"),
189        format!("updated_time: {iso}"),
190        format!("user_created_time: {iso}"),
191        format!("user_updated_time: {iso}"),
192        "encryption_cipher_text: ".to_string(),
193        "encryption_applied: 0".to_string(),
194        "is_shared: 0".to_string(),
195        "parent_id: ".to_string(),
196        "user_data: ".to_string(),
197        "type_: 5".to_string(),
198    ];
199    format!("{title}\n\n{}", props.join("\n"))
200}
201
202/// 新建 note_tag 关联条目(type_=6,纯元数据、无标题无空行)。字段集/顺序对齐 Joplin。
203pub fn new_note_tag_md(id: &str, note_id: &str, tag_id: &str, now: i64) -> String {
204    let iso = format_iso(now);
205    let props = [
206        format!("id: {id}"),
207        format!("note_id: {note_id}"),
208        format!("tag_id: {tag_id}"),
209        format!("created_time: {iso}"),
210        format!("updated_time: {iso}"),
211        format!("user_created_time: {iso}"),
212        format!("user_updated_time: {iso}"),
213        "encryption_cipher_text: ".to_string(),
214        "encryption_applied: 0".to_string(),
215        "is_shared: 0".to_string(),
216        "type_: 6".to_string(),
217    ];
218    props.join("\n")
219}
220
221/// 把现有笔记本移动到新的父笔记本(改 parent_id),刷新更新时间,其余元数据原样保留。
222/// 笔记本无正文段(仅标题),故标题逐字保留、仅重写元数据块。
223pub fn move_folder_md(original: &str, new_parent_id: &str, now: i64) -> Result<String> {
224    let lines: Vec<&str> = original.split('\n').collect();
225    let mut sep = None;
226    let mut i = lines.len();
227    while i > 0 {
228        i -= 1;
229        let t = lines[i].trim();
230        if t.is_empty() {
231            sep = Some(i);
232            break;
233        }
234        if !t.contains(':') {
235            break;
236        }
237    }
238    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
239    let iso = format_iso(now);
240    let new_meta: Vec<String> = lines[sep + 1..]
241        .iter()
242        .map(|l| {
243            let key = l.trim_start();
244            if key.starts_with("parent_id:") {
245                format!("parent_id: {new_parent_id}")
246            } else if key.starts_with("updated_time:") {
247                format!("updated_time: {iso}")
248            } else if key.starts_with("user_updated_time:") {
249                format!("user_updated_time: {iso}")
250            } else {
251                (*l).to_string()
252            }
253        })
254        .collect();
255    let head = lines[..sep].join("\n");
256    Ok(format!("{head}\n\n{}", new_meta.join("\n")))
257}
258
259/// 重命名笔记本:只改标题并刷新更新时间,parent_id 等元数据逐字保留。
260/// 笔记本无正文段(仅 `标题\n\n元数据`),故整段标题以新标题替换、仅重写元数据块。
261pub fn rename_folder_md(original: &str, new_title: &str, now: i64) -> Result<String> {
262    let lines: Vec<&str> = original.split('\n').collect();
263    let mut sep = None;
264    let mut i = lines.len();
265    while i > 0 {
266        i -= 1;
267        let t = lines[i].trim();
268        if t.is_empty() {
269            sep = Some(i);
270            break;
271        }
272        if !t.contains(':') {
273            break;
274        }
275    }
276    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
277    let iso = format_iso(now);
278    let new_meta: Vec<String> = lines[sep + 1..]
279        .iter()
280        .map(|l| {
281            let key = l.trim_start();
282            if key.starts_with("updated_time:") {
283                format!("updated_time: {iso}")
284            } else if key.starts_with("user_updated_time:") {
285                format!("user_updated_time: {iso}")
286            } else {
287                (*l).to_string()
288            }
289        })
290        .collect();
291    Ok(format!("{new_title}\n\n{}", new_meta.join("\n")))
292}
293
294/// 生成一个新资源(type_=4)的元数据 `.md` 内容。
295/// 字段集/顺序/默认值对齐真实 Joplin 资源(含 OCR 字段:ocr_driver_id 默认 1、ocr_status 0)。
296/// 资源条目无正文段,仅 `标题\n\n元数据`。
297pub fn new_resource_md(
298    id: &str,
299    title: &str,
300    mime: &str,
301    file_extension: &str,
302    size: i64,
303    now: i64,
304) -> String {
305    let iso = format_iso(now);
306    let props = [
307        format!("id: {id}"),
308        format!("mime: {mime}"),
309        "filename: ".to_string(),
310        format!("created_time: {iso}"),
311        format!("updated_time: {iso}"),
312        format!("user_created_time: {iso}"),
313        format!("user_updated_time: {iso}"),
314        format!("file_extension: {file_extension}"),
315        "encryption_cipher_text: ".to_string(),
316        "encryption_applied: 0".to_string(),
317        "encryption_blob_encrypted: 0".to_string(),
318        format!("size: {size}"),
319        "is_shared: 0".to_string(),
320        "share_id: ".to_string(),
321        "master_key_id: ".to_string(),
322        "user_data: ".to_string(),
323        format!("blob_updated_time: {now}"),
324        "ocr_text: ".to_string(),
325        "ocr_details: ".to_string(),
326        "ocr_status: 0".to_string(),
327        "ocr_error: ".to_string(),
328        "ocr_driver_id: 1".to_string(),
329        "type_: 4".to_string(),
330    ];
331    format!("{title}\n\n{}", props.join("\n"))
332}
333
334/// 更新资源条目的标题并刷新更新时间,其余元数据原样保留。
335/// 资源条目“正文段”仅一行标题,故直接以新标题替换。
336pub fn update_resource_md(original: &str, new_title: &str, now: i64) -> Result<String> {
337    let lines: Vec<&str> = original.split('\n').collect();
338    let mut sep = None;
339    let mut i = lines.len();
340    while i > 0 {
341        i -= 1;
342        let t = lines[i].trim();
343        if t.is_empty() {
344            sep = Some(i);
345            break;
346        }
347        if !t.contains(':') {
348            break;
349        }
350    }
351    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
352    let iso = format_iso(now);
353    let new_meta: Vec<String> = lines[sep + 1..]
354        .iter()
355        .map(|l| {
356            let key = l.trim_start();
357            if key.starts_with("updated_time:") {
358                format!("updated_time: {iso}")
359            } else if key.starts_with("user_updated_time:") {
360                format!("user_updated_time: {iso}")
361            } else {
362                (*l).to_string()
363            }
364        })
365        .collect();
366    Ok(format!("{new_title}\n\n{}", new_meta.join("\n")))
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::parser;
373
374    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";
375
376    #[test]
377    fn update_preserves_metadata_changes_body_and_time() {
378        let out = update_note_md(SAMPLE, "新标题", "新正文", 1_700_000_000_000).unwrap();
379        let raw = parser::parse_item(&out).unwrap();
380        let note = parser::to_note(&raw).unwrap();
381        assert_eq!(note.title, "新标题");
382        assert_eq!(note.body, "新正文");
383        // 其余元数据保留
384        assert_eq!(note.parent_id, "0f1e2d3c4b5a69788796a5b4c3d2e1f0");
385        assert_eq!(raw.prop("source_url"), Some("https://x.com"));
386        assert_eq!(raw.prop("id"), Some("a1b2c3d4e5f60718293a4b5c6d7e8f90"));
387        // created_time 不变,updated_time 已刷新
388        assert_eq!(note.created_time, 1_704_067_200_000); // 2024-01-01
389        assert_eq!(note.updated_time, 1_700_000_000_000);
390    }
391
392    #[test]
393    fn move_changes_parent_keeps_rest() {
394        let out = move_note_md(SAMPLE, "ffffffffffffffffffffffffffffffff", 1_700_000_000_000).unwrap();
395        let raw = parser::parse_item(&out).unwrap();
396        let note = parser::to_note(&raw).unwrap();
397        // parent 改了,标题/正文/其余元数据保留
398        assert_eq!(note.parent_id, "ffffffffffffffffffffffffffffffff");
399        assert_eq!(note.title, "原标题");
400        assert_eq!(note.body, "原正文第一行\n\n原正文第二行");
401        assert_eq!(raw.prop("source_url"), Some("https://x.com"));
402        assert_eq!(raw.prop("id"), Some("a1b2c3d4e5f60718293a4b5c6d7e8f90"));
403        // created_time 不变,updated_time 已刷新
404        assert_eq!(note.created_time, 1_704_067_200_000);
405        assert_eq!(note.updated_time, 1_700_000_000_000);
406    }
407
408    #[test]
409    fn new_note_is_parseable() {
410        let id = "ffffffffffffffffffffffffffffffff";
411        let out = new_note_md(id, "parentparentparentparentparent12", "标题", "正文内容", false, 1_700_000_000_000);
412        let raw = parser::parse_item(&out).unwrap();
413        let note = parser::to_note(&raw).unwrap();
414        assert_eq!(note.id, id);
415        assert_eq!(note.title, "标题");
416        assert_eq!(note.body, "正文内容");
417        assert_eq!(note.parent_id, "parentparentparentparentparent12");
418        assert_eq!(note.markup_language, crate::model::MarkupLanguage::Markdown);
419        assert!(!note.is_todo);
420    }
421
422    #[test]
423    fn new_todo_sets_is_todo() {
424        let out = new_note_md("ffffffffffffffffffffffffffffffff", "parentparentparentparentparent12", "待办", "", true, 1_700_000_000_000);
425        let note = parser::to_note(&parser::parse_item(&out).unwrap()).unwrap();
426        assert!(note.is_todo);
427        assert!(!note.todo_completed);
428    }
429
430    #[test]
431    fn new_folder_is_parseable() {
432        let id = "abcabcabcabcabcabcabcabcabcabc12";
433        let out = new_folder_md(id, "parentparentparentparentparent12", "我的笔记本", 1_700_000_000_000);
434        let raw = parser::parse_item(&out).unwrap();
435        assert_eq!(raw.item_type(), crate::model::ItemType::Folder);
436        let f = parser::to_folder(&raw).unwrap();
437        assert_eq!(f.id, id);
438        assert_eq!(f.title, "我的笔记本");
439        assert_eq!(f.parent_id, "parentparentparentparentparent12");
440    }
441
442    #[test]
443    fn new_tag_is_parseable_and_matches_joplin_shape() {
444        let id = "1434aa3b57b54f839c8a5fc4025cbf10";
445        let out = new_tag_md(id, "emulator", 1_700_000_000_000);
446        let raw = parser::parse_item(&out).unwrap();
447        assert_eq!(raw.item_type(), crate::model::ItemType::Tag);
448        let tag = parser::to_tag(&raw).unwrap();
449        assert_eq!(tag.id, id);
450        assert_eq!(tag.title, "emulator");
451        assert_eq!(tag.parent_id, ""); // 空 parent_id
452        // 字段集与顺序须与 Joplin 真实数据一致(含 user_data,无 deleted_time)
453        let expected = "emulator\n\nid: 1434aa3b57b54f839c8a5fc4025cbf10\n\
454            created_time: 2023-11-14T22:13:20.000Z\nupdated_time: 2023-11-14T22:13:20.000Z\n\
455            user_created_time: 2023-11-14T22:13:20.000Z\nuser_updated_time: 2023-11-14T22:13:20.000Z\n\
456            encryption_cipher_text: \nencryption_applied: 0\nis_shared: 0\nparent_id: \nuser_data: \ntype_: 5";
457        assert_eq!(out, expected);
458    }
459
460    #[test]
461    fn new_note_tag_is_parseable_and_matches_joplin_shape() {
462        let (id, note_id, tag_id) = (
463            "5828867dfe7f4ce184f3dff4e0e1e756",
464            "8565caad9a4c487996e2e5be171af413",
465            "1434aa3b57b54f839c8a5fc4025cbf10",
466        );
467        let out = new_note_tag_md(id, note_id, tag_id, 1_700_000_000_000);
468        let raw = parser::parse_item(&out).unwrap();
469        assert_eq!(raw.item_type(), crate::model::ItemType::NoteTag);
470        assert!(raw.title.is_none()); // 纯元数据、无标题
471        let nt = parser::to_note_tag(&raw).unwrap();
472        assert_eq!((nt.id.as_str(), nt.note_id.as_str(), nt.tag_id.as_str()), (id, note_id, tag_id));
473        let expected = "id: 5828867dfe7f4ce184f3dff4e0e1e756\n\
474            note_id: 8565caad9a4c487996e2e5be171af413\ntag_id: 1434aa3b57b54f839c8a5fc4025cbf10\n\
475            created_time: 2023-11-14T22:13:20.000Z\nupdated_time: 2023-11-14T22:13:20.000Z\n\
476            user_created_time: 2023-11-14T22:13:20.000Z\nuser_updated_time: 2023-11-14T22:13:20.000Z\n\
477            encryption_cipher_text: \nencryption_applied: 0\nis_shared: 0\ntype_: 6";
478        assert_eq!(out, expected);
479    }
480
481    #[test]
482    fn move_folder_changes_parent() {
483        let orig = new_folder_md("abcabcabcabcabcabcabcabcabcabc12", "oldoldoldoldoldoldoldoldoldold12", "本子", 1_700_000_000_000);
484        let out = move_folder_md(&orig, "newnewnewnewnewnewnewnewnewnew12", 1_700_000_999_000).unwrap();
485        let f = parser::to_folder(&parser::parse_item(&out).unwrap()).unwrap();
486        assert_eq!(f.parent_id, "newnewnewnewnewnewnewnewnewnew12");
487        assert_eq!(f.title, "本子");
488        assert_eq!(f.updated_time, 1_700_000_999_000);
489    }
490
491    #[test]
492    fn rename_folder_changes_title_keeps_parent() {
493        let orig = new_folder_md("abcabcabcabcabcabcabcabcabcabc12", "parentparentparentparentparent12", "旧名字", 1_700_000_000_000);
494        let out = rename_folder_md(&orig, "新名字", 1_700_000_999_000).unwrap();
495        let f = parser::to_folder(&parser::parse_item(&out).unwrap()).unwrap();
496        assert_eq!(f.title, "新名字");
497        assert_eq!(f.parent_id, "parentparentparentparentparent12"); // 父级逐字保留
498        assert_eq!(f.updated_time, 1_700_000_999_000); // 刷新更新时间
499    }
500
501    #[test]
502    fn new_resource_is_parseable() {
503        let id = "abcdef0123456789abcdef0123456789";
504        let out = new_resource_md(id, "photo.png", "image/png", "png", 12345, 1_700_000_000_000);
505        let raw = parser::parse_item(&out).unwrap();
506        assert_eq!(raw.item_type(), crate::model::ItemType::Resource);
507        let r = parser::to_resource(&raw).unwrap();
508        assert_eq!(r.id, id);
509        assert_eq!(r.title, "photo.png");
510        assert_eq!(r.mime, "image/png");
511        assert_eq!(r.file_extension, "png");
512        assert_eq!(r.size, 12345);
513        // 资源条目无正文
514        assert!(raw.body.is_none());
515        assert_eq!(raw.prop("ocr_driver_id"), Some("1"));
516    }
517
518    #[test]
519    fn rename_resource_keeps_metadata() {
520        let id = "abcdef0123456789abcdef0123456789";
521        let orig = new_resource_md(id, "old.png", "image/png", "png", 99, 1_700_000_000_000);
522        let out = update_resource_md(&orig, "新名字.png", 1_700_000_999_000).unwrap();
523        let raw = parser::parse_item(&out).unwrap();
524        let r = parser::to_resource(&raw).unwrap();
525        assert_eq!(r.title, "新名字.png");
526        assert_eq!(r.id, id); // id 不变
527        assert_eq!(r.mime, "image/png");
528        assert_eq!(r.size, 99);
529        assert_eq!(r.updated_time, 1_700_000_999_000); // 已刷新
530    }
531
532    #[test]
533    fn new_id_is_32_hex() {
534        let id = new_id();
535        assert_eq!(id.len(), 32);
536        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
537        assert_ne!(new_id(), new_id());
538    }
539}