1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Serialize)]
5pub struct PullRequest {
6 #[serde(rename = "eid")]
7 pub eid: serde_json::Value,
8 pub selector: String,
9}
10
11#[derive(Debug, Deserialize)]
12pub struct PullResponse {
13 pub result: serde_json::Value,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Block {
18 pub uid: String,
19 pub string: String,
20 pub order: i64,
21 #[serde(default)]
22 pub children: Vec<Block>,
23 #[serde(default)]
24 pub open: bool,
25 #[serde(default, skip_serializing)]
26 pub refs: Vec<RefEntity>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub struct RefEntity {
31 pub uid: String,
32 #[serde(default)]
33 pub title: Option<String>,
34 #[serde(default)]
35 pub string: Option<String>,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39pub struct DailyNote {
40 pub date: NaiveDate,
41 pub uid: String,
42 pub title: String,
43 pub blocks: Vec<Block>,
44}
45
46impl DailyNote {
47 pub fn from_pull_response(date: NaiveDate, uid: String, result: &serde_json::Value) -> Self {
48 let title = result
49 .get(":node/title")
50 .and_then(|v| v.as_str())
51 .unwrap_or("")
52 .to_string();
53
54 let blocks = result
55 .get(":block/children")
56 .and_then(|v| v.as_array())
57 .map(|arr| {
58 let mut blocks: Vec<Block> = arr.iter().map(parse_block_from_json).collect();
59 blocks.sort_by_key(|b| b.order);
60 blocks
61 })
62 .unwrap_or_default();
63
64 Self {
65 date,
66 uid,
67 title,
68 blocks,
69 }
70 }
71}
72
73fn parse_block_from_json(val: &serde_json::Value) -> Block {
74 let uid = val
75 .get(":block/uid")
76 .and_then(|v| v.as_str())
77 .unwrap_or("")
78 .to_string();
79 let string = val
80 .get(":block/string")
81 .and_then(|v| v.as_str())
82 .unwrap_or("")
83 .to_string();
84 let order = val
85 .get(":block/order")
86 .and_then(|v| v.as_i64())
87 .unwrap_or(0);
88 let open = val
89 .get(":block/open")
90 .and_then(|v| v.as_bool())
91 .unwrap_or(true);
92
93 let mut children: Vec<Block> = val
94 .get(":block/children")
95 .and_then(|v| v.as_array())
96 .map(|arr| arr.iter().map(parse_block_from_json).collect())
97 .unwrap_or_default();
98 children.sort_by_key(|b| b.order);
99
100 let refs: Vec<RefEntity> = val
101 .get(":block/refs")
102 .and_then(|v| v.as_array())
103 .map(|arr| arr.iter().filter_map(parse_ref_entity).collect())
104 .unwrap_or_default();
105
106 Block {
107 uid,
108 string,
109 order,
110 children,
111 open,
112 refs,
113 }
114}
115
116fn parse_ref_entity(val: &serde_json::Value) -> Option<RefEntity> {
117 let uid = val.get(":block/uid").and_then(|v| v.as_str())?.to_string();
118 let title = val
119 .get(":node/title")
120 .and_then(|v| v.as_str())
121 .map(|s| s.to_string());
122 let string = val
123 .get(":block/string")
124 .and_then(|v| v.as_str())
125 .map(|s| s.to_string());
126 Some(RefEntity { uid, title, string })
127}
128
129#[derive(Debug, Serialize)]
130pub struct QueryRequest {
131 pub query: String,
132 pub args: Vec<serde_json::Value>,
133}
134
135#[derive(Debug, Deserialize)]
136pub struct QueryResponse {
137 pub result: Vec<Vec<serde_json::Value>>,
138}
139
140#[derive(Debug, Clone, PartialEq)]
141pub struct LinkedRefBlock {
142 pub uid: String,
143 pub string: String,
144 pub page_title: String,
145}
146
147#[derive(Debug, Clone, PartialEq)]
148pub struct LinkedRefGroup {
149 pub page_title: String,
150 pub blocks: Vec<LinkedRefBlock>,
151}
152
153pub fn parse_linked_refs(
154 result: &[Vec<serde_json::Value>],
155 current_page: &str,
156) -> Vec<LinkedRefGroup> {
157 let mut blocks: Vec<LinkedRefBlock> = Vec::new();
158
159 for row in result {
161 let uid = row
162 .first()
163 .and_then(|v| v.as_str())
164 .unwrap_or("")
165 .to_string();
166 let string = row
167 .get(1)
168 .and_then(|v| v.as_str())
169 .unwrap_or("")
170 .to_string();
171 let page_title = row
172 .get(2)
173 .and_then(|v| v.as_str())
174 .unwrap_or("")
175 .to_string();
176
177 if uid.is_empty() || page_title.is_empty() {
178 continue;
179 }
180 if page_title == current_page {
182 continue;
183 }
184
185 blocks.push(LinkedRefBlock {
186 uid,
187 string,
188 page_title,
189 });
190 }
191
192 let mut groups: std::collections::BTreeMap<String, Vec<LinkedRefBlock>> =
194 std::collections::BTreeMap::new();
195 for block in blocks {
196 groups
197 .entry(block.page_title.clone())
198 .or_default()
199 .push(block);
200 }
201
202 groups
205 .into_iter()
206 .map(|(page_title, mut blocks)| {
207 blocks.sort_by(|a, b| a.string.cmp(&b.string));
208 LinkedRefGroup { page_title, blocks }
209 })
210 .collect()
211}
212
213#[derive(Debug, Serialize, Deserialize)]
214pub struct PageCreate {
215 pub title: String,
216 #[serde(skip_serializing_if = "Option::is_none", default)]
217 pub uid: Option<String>,
218}
219
220#[derive(Debug, Serialize, Deserialize)]
221#[serde(tag = "action")]
222#[allow(clippy::enum_variant_names)]
223pub enum WriteAction {
224 #[serde(rename = "create-block")]
225 CreateBlock {
226 location: BlockLocation,
227 block: NewBlock,
228 },
229 #[serde(rename = "update-block")]
230 UpdateBlock { block: BlockUpdate },
231 #[serde(rename = "delete-block")]
232 DeleteBlock { block: BlockRef },
233 #[serde(rename = "move-block")]
234 MoveBlock {
235 block: BlockRef,
236 location: BlockLocation,
237 },
238 #[serde(rename = "create-page")]
239 CreatePage { page: PageCreate },
240 #[serde(rename = "batch-actions")]
241 BatchActions { actions: Vec<WriteAction> },
242}
243
244#[derive(Debug, Serialize, Deserialize)]
245pub struct BlockLocation {
246 #[serde(rename = "parent-uid")]
247 pub parent_uid: String,
248 pub order: OrderValue,
249}
250
251#[derive(Debug, Serialize, Deserialize)]
252#[serde(untagged)]
253pub enum OrderValue {
254 Index(i64),
255 Position(String),
256}
257
258#[derive(Debug, Serialize, Deserialize)]
259pub struct NewBlock {
260 pub string: String,
261 #[serde(skip_serializing_if = "Option::is_none", default)]
262 pub uid: Option<String>,
263 #[serde(skip_serializing_if = "Option::is_none", default)]
264 pub open: Option<bool>,
265}
266
267#[derive(Debug, Serialize, Deserialize)]
268pub struct BlockUpdate {
269 pub uid: String,
270 pub string: String,
271}
272
273#[derive(Debug, Serialize, Deserialize)]
274pub struct BlockRef {
275 pub uid: String,
276}
277
278pub fn parse_order(order: Option<&str>) -> OrderValue {
281 match order {
282 None | Some("last") => OrderValue::Position("last".into()),
283 Some("first") => OrderValue::Position("first".into()),
284 Some(n) => n
285 .parse::<i64>()
286 .map(OrderValue::Index)
287 .unwrap_or(OrderValue::Position("last".into())),
288 }
289}
290
291pub fn generate_block_uid() -> String {
293 use std::sync::atomic::{AtomicU32, Ordering};
294 use std::time::{SystemTime, UNIX_EPOCH};
295
296 static COUNTER: AtomicU32 = AtomicU32::new(0);
297
298 const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
299
300 let nanos = SystemTime::now()
301 .duration_since(UNIX_EPOCH)
302 .unwrap_or_default()
303 .as_nanos() as u64;
304 let count = COUNTER.fetch_add(1, Ordering::Relaxed) as u64;
305 let seed = nanos
306 .wrapping_mul(6364136223846793005)
307 .wrapping_add(count ^ (std::process::id() as u64));
308
309 let mut uid = String::with_capacity(9);
310 let mut val = seed;
311 for _ in 0..9 {
312 uid.push(CHARS[(val % 62) as usize] as char);
313 val /= 62;
314 val = val.wrapping_mul(2862933555777941757).wrapping_add(nanos);
315 }
316 uid
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use serde_json::json;
323
324 #[test]
325 fn pull_request_serializes() {
326 let req = PullRequest {
327 eid: json!(["block/uid", "abc123"]),
328 selector: "[:block/string :block/uid {:block/children ...}]".into(),
329 };
330 let json = serde_json::to_value(&req).unwrap();
331 assert_eq!(json["eid"], json!(["block/uid", "abc123"]));
332 assert!(json["selector"].is_string());
333 }
334
335 #[test]
336 fn pull_response_deserializes() {
337 let raw =
338 r#"{"result": {":block/uid": "abc123", ":block/string": "hello", ":block/order": 0}}"#;
339 let resp: PullResponse = serde_json::from_str(raw).unwrap();
340 assert_eq!(resp.result[":block/uid"], "abc123");
341 }
342
343 #[test]
344 fn block_serde_roundtrip() {
345 let block = Block {
346 uid: "def456".into(),
347 string: "Hello [[world]]".into(),
348 order: 0,
349 children: vec![Block {
350 uid: "ghi789".into(),
351 string: "Child block".into(),
352 order: 0,
353 children: vec![],
354 open: true,
355 refs: vec![],
356 }],
357 open: true,
358 refs: vec![],
359 };
360 let json = serde_json::to_string(&block).unwrap();
361 let deserialized: Block = serde_json::from_str(&json).unwrap();
362 assert_eq!(block, deserialized);
363 assert_eq!(deserialized.children.len(), 1);
364 }
365
366 #[test]
367 fn block_deserializes_without_optional_fields() {
368 let raw = r#"{"uid": "abc", "string": "test", "order": 0}"#;
369 let block: Block = serde_json::from_str(raw).unwrap();
370 assert!(block.children.is_empty());
371 assert!(!block.open);
372 }
373
374 #[test]
375 fn write_action_create_block_serializes() {
376 let action = WriteAction::CreateBlock {
377 location: BlockLocation {
378 parent_uid: "page-uid".into(),
379 order: OrderValue::Position("last".into()),
380 },
381 block: NewBlock {
382 string: "New block content".into(),
383 uid: None,
384 open: None,
385 },
386 };
387 let json = serde_json::to_value(&action).unwrap();
388 assert_eq!(json["action"], "create-block");
389 assert_eq!(json["location"]["parent-uid"], "page-uid");
390 assert_eq!(json["location"]["order"], "last");
391 }
392
393 #[test]
394 fn write_action_update_block_serializes() {
395 let action = WriteAction::UpdateBlock {
396 block: BlockUpdate {
397 uid: "abc123".into(),
398 string: "Updated content".into(),
399 },
400 };
401 let json = serde_json::to_value(&action).unwrap();
402 assert_eq!(json["action"], "update-block");
403 assert_eq!(json["block"]["uid"], "abc123");
404 }
405
406 #[test]
407 fn write_action_delete_block_serializes() {
408 let action = WriteAction::DeleteBlock {
409 block: BlockRef {
410 uid: "abc123".into(),
411 },
412 };
413 let json = serde_json::to_value(&action).unwrap();
414 assert_eq!(json["action"], "delete-block");
415 assert_eq!(json["block"]["uid"], "abc123");
416 }
417
418 #[test]
419 fn write_action_move_block_serializes() {
420 let action = WriteAction::MoveBlock {
421 block: BlockRef {
422 uid: "block1".into(),
423 },
424 location: BlockLocation {
425 parent_uid: "new-parent".into(),
426 order: OrderValue::Position("last".into()),
427 },
428 };
429 let json = serde_json::to_value(&action).unwrap();
430 assert_eq!(json["action"], "move-block");
431 assert_eq!(json["block"]["uid"], "block1");
432 assert_eq!(json["location"]["parent-uid"], "new-parent");
433 assert_eq!(json["location"]["order"], "last");
434 }
435
436 #[test]
437 fn order_value_index_serializes_as_number() {
438 let order = OrderValue::Index(5);
439 let json = serde_json::to_value(&order).unwrap();
440 assert_eq!(json, 5);
441 }
442
443 #[test]
444 fn order_value_position_serializes_as_string() {
445 let order = OrderValue::Position("last".into());
446 let json = serde_json::to_value(&order).unwrap();
447 assert_eq!(json, "last");
448 }
449
450 #[test]
451 fn daily_note_from_pull_response_parses_blocks() {
452 let pull_result = json!({
453 ":node/title": "February 21, 2026",
454 ":block/uid": "02-21-2026",
455 ":block/children": [
456 {
457 ":block/uid": "block2",
458 ":block/string": "Second block",
459 ":block/order": 1,
460 ":block/open": true
461 },
462 {
463 ":block/uid": "block1",
464 ":block/string": "First block",
465 ":block/order": 0,
466 ":block/open": true
467 }
468 ]
469 });
470
471 let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
472 let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
473
474 assert_eq!(note.title, "February 21, 2026");
475 assert_eq!(note.uid, "02-21-2026");
476 assert_eq!(note.date, date);
477 assert_eq!(note.blocks.len(), 2);
478 assert_eq!(note.blocks[0].string, "First block");
479 assert_eq!(note.blocks[1].string, "Second block");
480 }
481
482 #[test]
483 fn daily_note_from_pull_response_with_nested_children() {
484 let pull_result = json!({
485 ":node/title": "February 21, 2026",
486 ":block/uid": "02-21-2026",
487 ":block/children": [
488 {
489 ":block/uid": "parent",
490 ":block/string": "Parent block",
491 ":block/order": 0,
492 ":block/open": true,
493 ":block/children": [
494 {
495 ":block/uid": "child2",
496 ":block/string": "Child B",
497 ":block/order": 1
498 },
499 {
500 ":block/uid": "child1",
501 ":block/string": "Child A",
502 ":block/order": 0
503 }
504 ]
505 }
506 ]
507 });
508
509 let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
510 let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
511
512 assert_eq!(note.blocks.len(), 1);
513 assert_eq!(note.blocks[0].children.len(), 2);
514 assert_eq!(note.blocks[0].children[0].string, "Child A");
515 assert_eq!(note.blocks[0].children[1].string, "Child B");
516 }
517
518 #[test]
519 fn daily_note_from_empty_pull_response() {
520 let pull_result = json!({});
521 let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
522 let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
523
524 assert!(note.blocks.is_empty());
525 assert_eq!(note.title, "");
526 assert_eq!(note.blocks.len(), 0);
527 }
528
529 #[test]
530 fn daily_note_from_pull_response_no_children() {
531 let pull_result = json!({
532 ":node/title": "February 21, 2026",
533 ":block/uid": "02-21-2026"
534 });
535 let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
536 let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
537
538 assert!(note.blocks.is_empty());
539 assert_eq!(note.title, "February 21, 2026");
540 }
541
542 #[test]
543 fn block_parses_refs_from_pull_response() {
544 let pull_result = json!({
545 ":node/title": "February 21, 2026",
546 ":block/uid": "02-21-2026",
547 ":block/children": [
548 {
549 ":block/uid": "block1",
550 ":block/string": "Links to [[ProjectX]]",
551 ":block/order": 0,
552 ":block/open": true,
553 ":block/refs": [
554 {
555 ":block/uid": "page-uid-1",
556 ":node/title": "ProjectX"
557 }
558 ]
559 }
560 ]
561 });
562
563 let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
564 let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
565
566 assert_eq!(note.blocks[0].refs.len(), 1);
567 assert_eq!(note.blocks[0].refs[0].uid, "page-uid-1");
568 assert_eq!(note.blocks[0].refs[0].title.as_deref(), Some("ProjectX"));
569 }
570
571 #[test]
572 fn block_parses_without_refs() {
573 let pull_result = json!({
574 ":node/title": "February 21, 2026",
575 ":block/uid": "02-21-2026",
576 ":block/children": [
577 {
578 ":block/uid": "block1",
579 ":block/string": "No links here",
580 ":block/order": 0
581 }
582 ]
583 });
584
585 let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
586 let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
587
588 assert!(note.blocks[0].refs.is_empty());
589 }
590
591 #[test]
592 fn refs_not_serialized_in_block_json() {
593 let block = Block {
594 uid: "b1".into(),
595 string: "test".into(),
596 order: 0,
597 children: vec![],
598 open: true,
599 refs: vec![RefEntity {
600 uid: "ref1".into(),
601 title: Some("Page".into()),
602 string: None,
603 }],
604 };
605 let json = serde_json::to_value(&block).unwrap();
606 assert!(json.get("refs").is_none());
607 }
608
609 #[test]
610 fn query_request_serializes() {
611 let req = QueryRequest {
612 query: "[:find ?b :where [?b :block/string]]".into(),
613 args: vec![],
614 };
615 let json = serde_json::to_value(&req).unwrap();
616 assert_eq!(json["query"], "[:find ?b :where [?b :block/string]]");
617 assert_eq!(json["args"], json!([]));
619 }
620
621 #[test]
622 fn query_request_serializes_with_args() {
623 let req = QueryRequest {
624 query: "[:find ?b :in $ ?title :where [?b :node/title ?title]]".into(),
625 args: vec![json!("My Page")],
626 };
627 let json = serde_json::to_value(&req).unwrap();
628 assert_eq!(json["args"], json!(["My Page"]));
629 }
630
631 #[test]
632 fn query_response_deserializes() {
633 let raw = r#"{"result": [["abc", "hello text", "My Page"]]}"#;
634 let resp: QueryResponse = serde_json::from_str(raw).unwrap();
635 assert_eq!(resp.result.len(), 1);
636 assert_eq!(resp.result[0].len(), 3);
637 assert_eq!(resp.result[0][0], "abc");
638 }
639
640 #[test]
641 fn parse_linked_refs_groups_by_page() {
642 let result = vec![
644 vec![json!("b1"), json!("mentions [[Target]]"), json!("Page A")],
645 vec![json!("b2"), json!("also refs [[Target]]"), json!("Page B")],
646 vec![
647 json!("b3"),
648 json!("another ref [[Target]]"),
649 json!("Page A"),
650 ],
651 ];
652
653 let groups = parse_linked_refs(&result, "Target");
654 assert_eq!(groups.len(), 2);
655 assert_eq!(groups[0].page_title, "Page A");
656 assert_eq!(groups[0].blocks.len(), 2);
657 assert_eq!(groups[1].page_title, "Page B");
658 assert_eq!(groups[1].blocks.len(), 1);
659 }
660
661 #[test]
662 fn parse_linked_refs_filters_self_refs() {
663 let result = vec![
664 vec![json!("b1"), json!("self ref [[MyPage]]"), json!("MyPage")],
665 vec![
666 json!("b2"),
667 json!("external ref [[MyPage]]"),
668 json!("Other Page"),
669 ],
670 ];
671
672 let groups = parse_linked_refs(&result, "MyPage");
673 assert_eq!(groups.len(), 1);
674 assert_eq!(groups[0].page_title, "Other Page");
675 }
676
677 #[test]
678 fn parse_linked_refs_handles_empty() {
679 let groups = parse_linked_refs(&[], "AnyPage");
680 assert!(groups.is_empty());
681 }
682
683 #[test]
684 fn parse_linked_refs_skips_missing_fields() {
685 let result = vec![
686 vec![json!("b1"), json!("text")], vec![json!(""), json!("text"), json!("Page")], vec![json!("b3"), json!("text"), json!("")], ];
690
691 let groups = parse_linked_refs(&result, "X");
692 assert!(groups.is_empty());
693 }
694
695 #[test]
696 fn write_action_create_page_serializes() {
697 let action = WriteAction::CreatePage {
698 page: PageCreate {
699 title: "My New Page".into(),
700 uid: Some("page-uid-123".into()),
701 },
702 };
703 let json = serde_json::to_value(&action).unwrap();
704 assert_eq!(json["action"], "create-page");
705 assert_eq!(json["page"]["title"], "My New Page");
706 assert_eq!(json["page"]["uid"], "page-uid-123");
707 }
708
709 #[test]
710 fn write_action_create_page_without_uid() {
711 let action = WriteAction::CreatePage {
712 page: PageCreate {
713 title: "Auto UID Page".into(),
714 uid: None,
715 },
716 };
717 let json = serde_json::to_value(&action).unwrap();
718 assert_eq!(json["action"], "create-page");
719 assert_eq!(json["page"]["title"], "Auto UID Page");
720 assert!(json["page"].get("uid").is_none());
721 }
722
723 #[test]
724 fn parse_linked_refs_sorts_blocks_within_group() {
725 let result = vec![
726 vec![json!("b1"), json!("Zebra [[T]]"), json!("Page")],
727 vec![json!("b2"), json!("Alpha [[T]]"), json!("Page")],
728 ];
729
730 let groups = parse_linked_refs(&result, "T");
731 assert_eq!(groups[0].blocks[0].string, "Alpha [[T]]");
732 assert_eq!(groups[0].blocks[1].string, "Zebra [[T]]");
733 }
734}