1use anyhow::{anyhow, Result};
8
9pub 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
17pub 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
27pub 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
38pub 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 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; }
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
76pub 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 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 let head = lines[..sep].join("\n");
116 Ok(format!("{head}\n\n{}", new_meta.join("\n")))
117}
118
119pub 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
157pub 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
181pub 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
202pub 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
221pub 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
259pub 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
294pub 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
334pub 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 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 assert_eq!(note.created_time, 1_704_067_200_000); 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 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 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, ""); 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()); 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"); assert_eq!(f.updated_time, 1_700_000_999_000); }
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 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); assert_eq!(r.mime, "image/png");
528 assert_eq!(r.size, 99);
529 assert_eq!(r.updated_time, 1_700_000_999_000); }
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}