jasper-core 0.2.0

Joplin sync-format core: model, parser, serializer, in-memory index (no IO; portable to WASM)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
//! 写回:把编辑后的笔记重新序列化为 Joplin 的 `.md` 格式。
//!
//! 设计原则:编辑现有笔记时,**保留原文件的元数据块逐字不变**,只替换
//! 标题、正文,并刷新 updated_time / user_updated_time。这样与 Joplin
//! 双向兼容、diff 最小,且无需复刻字段顺序与转义规则。

use anyhow::{anyhow, Result};

/// Unix 毫秒 → Joplin 的 ISO 时间 `YYYY-MM-DDTHH:mm:ss.SSSZ`(UTC)。
pub fn format_iso(ms: i64) -> String {
    chrono::DateTime::from_timestamp_millis(ms)
        .unwrap_or_default()
        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
        .to_string()
}

/// 当前时间(Unix 毫秒)。
/// 用 std 而非 chrono::Utc::now():省掉 chrono 的 clock/wasmbind feature,
/// 插件沙箱(wasm32-unknown-unknown)才能零 wasm-bindgen 依赖地编译 core。
pub fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("系统时钟早于 Unix 纪元")
        .as_millis() as i64
}

/// 生成一个新的 32 位十六进制条目 ID。
pub fn new_id() -> String {
    let mut bytes = [0u8; 16];
    getrandom::getrandom(&mut bytes).expect("getrandom 失败");
    let mut s = String::with_capacity(32);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

/// 把现有笔记的原始内容更新为新的标题/正文,并刷新更新时间,其余元数据原样保留。
pub fn update_note_md(original: &str, new_title: &str, new_body: &str, now: i64) -> Result<String> {
    let lines: Vec<&str> = original.split('\n').collect();

    // 自底向上找到 正文与元数据之间的分隔空行(与解析逻辑一致)
    let mut sep = None;
    let mut i = lines.len();
    while i > 0 {
        i -= 1;
        let t = lines[i].trim();
        if t.is_empty() {
            sep = Some(i);
            break;
        }
        if !t.contains(':') {
            break; // 非法元数据行,停止
        }
    }
    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;

    let iso = format_iso(now);
    let new_meta: Vec<String> = lines[sep + 1..]
        .iter()
        .map(|l| {
            let key = l.trim_start();
            if key.starts_with("updated_time:") {
                format!("updated_time: {iso}")
            } else if key.starts_with("user_updated_time:") {
                format!("user_updated_time: {iso}")
            } else {
                (*l).to_string()
            }
        })
        .collect();

    Ok(format!("{new_title}\n\n{new_body}\n\n{}", new_meta.join("\n")))
}

/// 把现有笔记移动到新笔记本:只改 `parent_id` 并刷新更新时间,
/// 标题/正文/其余元数据逐字保留(与 Joplin diff 最小、双向兼容)。
pub fn move_note_md(original: &str, new_parent_id: &str, now: i64) -> Result<String> {
    let lines: Vec<&str> = original.split('\n').collect();

    // 自底向上找正文与元数据之间的分隔空行(与解析/更新逻辑一致)
    let mut sep = None;
    let mut i = lines.len();
    while i > 0 {
        i -= 1;
        let t = lines[i].trim();
        if t.is_empty() {
            sep = Some(i);
            break;
        }
        if !t.contains(':') {
            break;
        }
    }
    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;

    let iso = format_iso(now);
    let new_meta: Vec<String> = lines[sep + 1..]
        .iter()
        .map(|l| {
            let key = l.trim_start();
            if key.starts_with("parent_id:") {
                format!("parent_id: {new_parent_id}")
            } else if key.starts_with("updated_time:") {
                format!("updated_time: {iso}")
            } else if key.starts_with("user_updated_time:") {
                format!("user_updated_time: {iso}")
            } else {
                (*l).to_string()
            }
        })
        .collect();

    // 标题/正文段(含其内部空行)逐字保留,仅替换元数据块
    let head = lines[..sep].join("\n");
    Ok(format!("{head}\n\n{}", new_meta.join("\n")))
}

/// 生成一篇新笔记的 `.md` 内容(字段顺序对齐真实 Joplin 笔记)。
/// `is_todo` 为 true 时建为待办(is_todo: 1),否则普通笔记。
pub fn new_note_md(id: &str, parent_id: &str, title: &str, body: &str, is_todo: bool, now: i64) -> String {
    let iso = format_iso(now);
    let props = [
        format!("id: {id}"),
        format!("parent_id: {parent_id}"),
        format!("created_time: {iso}"),
        format!("updated_time: {iso}"),
        "is_conflict: 0".to_string(),
        "latitude: 0.00000000".to_string(),
        "longitude: 0.00000000".to_string(),
        "altitude: 0.0000".to_string(),
        "author: ".to_string(),
        "source_url: ".to_string(),
        format!("is_todo: {}", if is_todo { 1 } else { 0 }),
        "todo_due: 0".to_string(),
        "todo_completed: 0".to_string(),
        "source: jasper".to_string(),
        "source_application: net.cozic.jasper".to_string(),
        "application_data: ".to_string(),
        "order: 0".to_string(),
        format!("user_created_time: {iso}"),
        format!("user_updated_time: {iso}"),
        "encryption_cipher_text: ".to_string(),
        "encryption_applied: 0".to_string(),
        "markup_language: 1".to_string(),
        "is_shared: 0".to_string(),
        "share_id: ".to_string(),
        "conflict_original_id: ".to_string(),
        "master_key_id: ".to_string(),
        "user_data: ".to_string(),
        "deleted_time: 0".to_string(),
        "type_: 1".to_string(),
    ];
    format!("{title}\n\n{body}\n\n{}", props.join("\n"))
}

/// 生成一个新笔记本(type_=2)的 `.md` 内容(字段顺序对齐真实 Joplin 笔记本)。
/// 笔记本无正文段,仅 `标题\n\n元数据`。
pub fn new_folder_md(id: &str, parent_id: &str, title: &str, now: i64) -> String {
    let iso = format_iso(now);
    let props = [
        format!("id: {id}"),
        format!("created_time: {iso}"),
        format!("updated_time: {iso}"),
        format!("user_created_time: {iso}"),
        format!("user_updated_time: {iso}"),
        "encryption_cipher_text: ".to_string(),
        "encryption_applied: 0".to_string(),
        format!("parent_id: {parent_id}"),
        "is_shared: 0".to_string(),
        "share_id: ".to_string(),
        "master_key_id: ".to_string(),
        "icon: ".to_string(),
        "user_data: ".to_string(),
        "deleted_time: 0".to_string(),
        "type_: 2".to_string(),
    ];
    format!("{title}\n\n{}", props.join("\n"))
}

/// 把现有笔记本移动到新的父笔记本(改 parent_id),刷新更新时间,其余元数据原样保留。
/// 笔记本无正文段(仅标题),故标题逐字保留、仅重写元数据块。
pub fn move_folder_md(original: &str, new_parent_id: &str, now: i64) -> Result<String> {
    let lines: Vec<&str> = original.split('\n').collect();
    let mut sep = None;
    let mut i = lines.len();
    while i > 0 {
        i -= 1;
        let t = lines[i].trim();
        if t.is_empty() {
            sep = Some(i);
            break;
        }
        if !t.contains(':') {
            break;
        }
    }
    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
    let iso = format_iso(now);
    let new_meta: Vec<String> = lines[sep + 1..]
        .iter()
        .map(|l| {
            let key = l.trim_start();
            if key.starts_with("parent_id:") {
                format!("parent_id: {new_parent_id}")
            } else if key.starts_with("updated_time:") {
                format!("updated_time: {iso}")
            } else if key.starts_with("user_updated_time:") {
                format!("user_updated_time: {iso}")
            } else {
                (*l).to_string()
            }
        })
        .collect();
    let head = lines[..sep].join("\n");
    Ok(format!("{head}\n\n{}", new_meta.join("\n")))
}

/// 重命名笔记本:只改标题并刷新更新时间,parent_id 等元数据逐字保留。
/// 笔记本无正文段(仅 `标题\n\n元数据`),故整段标题以新标题替换、仅重写元数据块。
pub fn rename_folder_md(original: &str, new_title: &str, now: i64) -> Result<String> {
    let lines: Vec<&str> = original.split('\n').collect();
    let mut sep = None;
    let mut i = lines.len();
    while i > 0 {
        i -= 1;
        let t = lines[i].trim();
        if t.is_empty() {
            sep = Some(i);
            break;
        }
        if !t.contains(':') {
            break;
        }
    }
    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
    let iso = format_iso(now);
    let new_meta: Vec<String> = lines[sep + 1..]
        .iter()
        .map(|l| {
            let key = l.trim_start();
            if key.starts_with("updated_time:") {
                format!("updated_time: {iso}")
            } else if key.starts_with("user_updated_time:") {
                format!("user_updated_time: {iso}")
            } else {
                (*l).to_string()
            }
        })
        .collect();
    Ok(format!("{new_title}\n\n{}", new_meta.join("\n")))
}

/// 生成一个新资源(type_=4)的元数据 `.md` 内容。
/// 字段集/顺序/默认值对齐真实 Joplin 资源(含 OCR 字段:ocr_driver_id 默认 1、ocr_status 0)。
/// 资源条目无正文段,仅 `标题\n\n元数据`。
pub fn new_resource_md(
    id: &str,
    title: &str,
    mime: &str,
    file_extension: &str,
    size: i64,
    now: i64,
) -> String {
    let iso = format_iso(now);
    let props = [
        format!("id: {id}"),
        format!("mime: {mime}"),
        "filename: ".to_string(),
        format!("created_time: {iso}"),
        format!("updated_time: {iso}"),
        format!("user_created_time: {iso}"),
        format!("user_updated_time: {iso}"),
        format!("file_extension: {file_extension}"),
        "encryption_cipher_text: ".to_string(),
        "encryption_applied: 0".to_string(),
        "encryption_blob_encrypted: 0".to_string(),
        format!("size: {size}"),
        "is_shared: 0".to_string(),
        "share_id: ".to_string(),
        "master_key_id: ".to_string(),
        "user_data: ".to_string(),
        format!("blob_updated_time: {now}"),
        "ocr_text: ".to_string(),
        "ocr_details: ".to_string(),
        "ocr_status: 0".to_string(),
        "ocr_error: ".to_string(),
        "ocr_driver_id: 1".to_string(),
        "type_: 4".to_string(),
    ];
    format!("{title}\n\n{}", props.join("\n"))
}

/// 更新资源条目的标题并刷新更新时间,其余元数据原样保留。
/// 资源条目“正文段”仅一行标题,故直接以新标题替换。
pub fn update_resource_md(original: &str, new_title: &str, now: i64) -> Result<String> {
    let lines: Vec<&str> = original.split('\n').collect();
    let mut sep = None;
    let mut i = lines.len();
    while i > 0 {
        i -= 1;
        let t = lines[i].trim();
        if t.is_empty() {
            sep = Some(i);
            break;
        }
        if !t.contains(':') {
            break;
        }
    }
    let sep = sep.ok_or_else(|| anyhow!("无法定位元数据块"))?;
    let iso = format_iso(now);
    let new_meta: Vec<String> = lines[sep + 1..]
        .iter()
        .map(|l| {
            let key = l.trim_start();
            if key.starts_with("updated_time:") {
                format!("updated_time: {iso}")
            } else if key.starts_with("user_updated_time:") {
                format!("user_updated_time: {iso}")
            } else {
                (*l).to_string()
            }
        })
        .collect();
    Ok(format!("{new_title}\n\n{}", new_meta.join("\n")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::parser;

    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";

    #[test]
    fn update_preserves_metadata_changes_body_and_time() {
        let out = update_note_md(SAMPLE, "新标题", "新正文", 1_700_000_000_000).unwrap();
        let raw = parser::parse_item(&out).unwrap();
        let note = parser::to_note(&raw).unwrap();
        assert_eq!(note.title, "新标题");
        assert_eq!(note.body, "新正文");
        // 其余元数据保留
        assert_eq!(note.parent_id, "0f1e2d3c4b5a69788796a5b4c3d2e1f0");
        assert_eq!(raw.prop("source_url"), Some("https://x.com"));
        assert_eq!(raw.prop("id"), Some("a1b2c3d4e5f60718293a4b5c6d7e8f90"));
        // created_time 不变,updated_time 已刷新
        assert_eq!(note.created_time, 1_704_067_200_000); // 2024-01-01
        assert_eq!(note.updated_time, 1_700_000_000_000);
    }

    #[test]
    fn move_changes_parent_keeps_rest() {
        let out = move_note_md(SAMPLE, "ffffffffffffffffffffffffffffffff", 1_700_000_000_000).unwrap();
        let raw = parser::parse_item(&out).unwrap();
        let note = parser::to_note(&raw).unwrap();
        // parent 改了,标题/正文/其余元数据保留
        assert_eq!(note.parent_id, "ffffffffffffffffffffffffffffffff");
        assert_eq!(note.title, "原标题");
        assert_eq!(note.body, "原正文第一行\n\n原正文第二行");
        assert_eq!(raw.prop("source_url"), Some("https://x.com"));
        assert_eq!(raw.prop("id"), Some("a1b2c3d4e5f60718293a4b5c6d7e8f90"));
        // created_time 不变,updated_time 已刷新
        assert_eq!(note.created_time, 1_704_067_200_000);
        assert_eq!(note.updated_time, 1_700_000_000_000);
    }

    #[test]
    fn new_note_is_parseable() {
        let id = "ffffffffffffffffffffffffffffffff";
        let out = new_note_md(id, "parentparentparentparentparent12", "标题", "正文内容", false, 1_700_000_000_000);
        let raw = parser::parse_item(&out).unwrap();
        let note = parser::to_note(&raw).unwrap();
        assert_eq!(note.id, id);
        assert_eq!(note.title, "标题");
        assert_eq!(note.body, "正文内容");
        assert_eq!(note.parent_id, "parentparentparentparentparent12");
        assert_eq!(note.markup_language, crate::model::MarkupLanguage::Markdown);
        assert!(!note.is_todo);
    }

    #[test]
    fn new_todo_sets_is_todo() {
        let out = new_note_md("ffffffffffffffffffffffffffffffff", "parentparentparentparentparent12", "待办", "", true, 1_700_000_000_000);
        let note = parser::to_note(&parser::parse_item(&out).unwrap()).unwrap();
        assert!(note.is_todo);
        assert!(!note.todo_completed);
    }

    #[test]
    fn new_folder_is_parseable() {
        let id = "abcabcabcabcabcabcabcabcabcabc12";
        let out = new_folder_md(id, "parentparentparentparentparent12", "我的笔记本", 1_700_000_000_000);
        let raw = parser::parse_item(&out).unwrap();
        assert_eq!(raw.item_type(), crate::model::ItemType::Folder);
        let f = parser::to_folder(&raw).unwrap();
        assert_eq!(f.id, id);
        assert_eq!(f.title, "我的笔记本");
        assert_eq!(f.parent_id, "parentparentparentparentparent12");
    }

    #[test]
    fn move_folder_changes_parent() {
        let orig = new_folder_md("abcabcabcabcabcabcabcabcabcabc12", "oldoldoldoldoldoldoldoldoldold12", "本子", 1_700_000_000_000);
        let out = move_folder_md(&orig, "newnewnewnewnewnewnewnewnewnew12", 1_700_000_999_000).unwrap();
        let f = parser::to_folder(&parser::parse_item(&out).unwrap()).unwrap();
        assert_eq!(f.parent_id, "newnewnewnewnewnewnewnewnewnew12");
        assert_eq!(f.title, "本子");
        assert_eq!(f.updated_time, 1_700_000_999_000);
    }

    #[test]
    fn rename_folder_changes_title_keeps_parent() {
        let orig = new_folder_md("abcabcabcabcabcabcabcabcabcabc12", "parentparentparentparentparent12", "旧名字", 1_700_000_000_000);
        let out = rename_folder_md(&orig, "新名字", 1_700_000_999_000).unwrap();
        let f = parser::to_folder(&parser::parse_item(&out).unwrap()).unwrap();
        assert_eq!(f.title, "新名字");
        assert_eq!(f.parent_id, "parentparentparentparentparent12"); // 父级逐字保留
        assert_eq!(f.updated_time, 1_700_000_999_000); // 刷新更新时间
    }

    #[test]
    fn new_resource_is_parseable() {
        let id = "abcdef0123456789abcdef0123456789";
        let out = new_resource_md(id, "photo.png", "image/png", "png", 12345, 1_700_000_000_000);
        let raw = parser::parse_item(&out).unwrap();
        assert_eq!(raw.item_type(), crate::model::ItemType::Resource);
        let r = parser::to_resource(&raw).unwrap();
        assert_eq!(r.id, id);
        assert_eq!(r.title, "photo.png");
        assert_eq!(r.mime, "image/png");
        assert_eq!(r.file_extension, "png");
        assert_eq!(r.size, 12345);
        // 资源条目无正文
        assert!(raw.body.is_none());
        assert_eq!(raw.prop("ocr_driver_id"), Some("1"));
    }

    #[test]
    fn rename_resource_keeps_metadata() {
        let id = "abcdef0123456789abcdef0123456789";
        let orig = new_resource_md(id, "old.png", "image/png", "png", 99, 1_700_000_000_000);
        let out = update_resource_md(&orig, "新名字.png", 1_700_000_999_000).unwrap();
        let raw = parser::parse_item(&out).unwrap();
        let r = parser::to_resource(&raw).unwrap();
        assert_eq!(r.title, "新名字.png");
        assert_eq!(r.id, id); // id 不变
        assert_eq!(r.mime, "image/png");
        assert_eq!(r.size, 99);
        assert_eq!(r.updated_time, 1_700_000_999_000); // 已刷新
    }

    #[test]
    fn new_id_is_32_hex() {
        let id = new_id();
        assert_eq!(id.len(), 32);
        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
        assert_ne!(new_id(), new_id());
    }
}