Skip to main content

roam_sdk/api/
types.rs

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    // Each row is a tuple [uid, string, page_title] from the Datalog query
160    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        // Filter self-references
181        if page_title == current_page {
182            continue;
183        }
184
185        blocks.push(LinkedRefBlock {
186            uid,
187            string,
188            page_title,
189        });
190    }
191
192    // Group by page title
193    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    // Sort blocks within each group by string for stable order
203    // Groups already sorted alphabetically by BTreeMap
204    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
278/// Parse an order string into an `OrderValue`.
279/// Accepts "first", "last", numeric strings, or None (defaults to "last").
280pub 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
291/// Generate a short unique block UID compatible with Roam format.
292pub 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        // args should always be present, even when empty
618        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        // Each row is a tuple [uid, string, page_title]
643        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")],              // no page_title
687            vec![json!(""), json!("text"), json!("Page")], // empty uid
688            vec![json!("b3"), json!("text"), json!("")],   // empty page_title
689        ];
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}