use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct PullRequest {
#[serde(rename = "eid")]
pub eid: serde_json::Value,
pub selector: String,
}
#[derive(Debug, Deserialize)]
pub struct PullResponse {
pub result: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Block {
pub uid: String,
pub string: String,
pub order: i64,
#[serde(default)]
pub children: Vec<Block>,
#[serde(default)]
pub open: bool,
#[serde(default, skip_serializing)]
pub refs: Vec<RefEntity>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RefEntity {
pub uid: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub string: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DailyNote {
pub date: NaiveDate,
pub uid: String,
pub title: String,
pub blocks: Vec<Block>,
}
impl DailyNote {
pub fn from_pull_response(date: NaiveDate, uid: String, result: &serde_json::Value) -> Self {
let title = result
.get(":node/title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let blocks = result
.get(":block/children")
.and_then(|v| v.as_array())
.map(|arr| {
let mut blocks: Vec<Block> = arr.iter().map(parse_block_from_json).collect();
blocks.sort_by_key(|b| b.order);
blocks
})
.unwrap_or_default();
Self {
date,
uid,
title,
blocks,
}
}
}
fn parse_block_from_json(val: &serde_json::Value) -> Block {
let uid = val
.get(":block/uid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let string = val
.get(":block/string")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let order = val
.get(":block/order")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let open = val
.get(":block/open")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let mut children: Vec<Block> = val
.get(":block/children")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(parse_block_from_json).collect())
.unwrap_or_default();
children.sort_by_key(|b| b.order);
let refs: Vec<RefEntity> = val
.get(":block/refs")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(parse_ref_entity).collect())
.unwrap_or_default();
Block {
uid,
string,
order,
children,
open,
refs,
}
}
fn parse_ref_entity(val: &serde_json::Value) -> Option<RefEntity> {
let uid = val.get(":block/uid").and_then(|v| v.as_str())?.to_string();
let title = val
.get(":node/title")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let string = val
.get(":block/string")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(RefEntity { uid, title, string })
}
#[derive(Debug, Serialize)]
pub struct QueryRequest {
pub query: String,
pub args: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct QueryResponse {
pub result: Vec<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LinkedRefBlock {
pub uid: String,
pub string: String,
pub page_title: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LinkedRefGroup {
pub page_title: String,
pub blocks: Vec<LinkedRefBlock>,
}
pub fn parse_linked_refs(
result: &[Vec<serde_json::Value>],
current_page: &str,
) -> Vec<LinkedRefGroup> {
let mut blocks: Vec<LinkedRefBlock> = Vec::new();
for row in result {
let uid = row
.first()
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let string = row
.get(1)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let page_title = row
.get(2)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if uid.is_empty() || page_title.is_empty() {
continue;
}
if page_title == current_page {
continue;
}
blocks.push(LinkedRefBlock {
uid,
string,
page_title,
});
}
let mut groups: std::collections::BTreeMap<String, Vec<LinkedRefBlock>> =
std::collections::BTreeMap::new();
for block in blocks {
groups
.entry(block.page_title.clone())
.or_default()
.push(block);
}
groups
.into_iter()
.map(|(page_title, mut blocks)| {
blocks.sort_by(|a, b| a.string.cmp(&b.string));
LinkedRefGroup { page_title, blocks }
})
.collect()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PageCreate {
pub title: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub uid: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "action")]
#[allow(clippy::enum_variant_names)]
pub enum WriteAction {
#[serde(rename = "create-block")]
CreateBlock {
location: BlockLocation,
block: NewBlock,
},
#[serde(rename = "update-block")]
UpdateBlock { block: BlockUpdate },
#[serde(rename = "delete-block")]
DeleteBlock { block: BlockRef },
#[serde(rename = "move-block")]
MoveBlock {
block: BlockRef,
location: BlockLocation,
},
#[serde(rename = "create-page")]
CreatePage { page: PageCreate },
#[serde(rename = "batch-actions")]
BatchActions { actions: Vec<WriteAction> },
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockLocation {
#[serde(rename = "parent-uid")]
pub parent_uid: String,
pub order: OrderValue,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum OrderValue {
Index(i64),
Position(String),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NewBlock {
pub string: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub uid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub open: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockUpdate {
pub uid: String,
pub string: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockRef {
pub uid: String,
}
pub fn parse_order(order: Option<&str>) -> OrderValue {
match order {
None | Some("last") => OrderValue::Position("last".into()),
Some("first") => OrderValue::Position("first".into()),
Some(n) => n
.parse::<i64>()
.map(OrderValue::Index)
.unwrap_or(OrderValue::Position("last".into())),
}
}
pub fn generate_block_uid() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU32 = AtomicU32::new(0);
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let count = COUNTER.fetch_add(1, Ordering::Relaxed) as u64;
let seed = nanos
.wrapping_mul(6364136223846793005)
.wrapping_add(count ^ (std::process::id() as u64));
let mut uid = String::with_capacity(9);
let mut val = seed;
for _ in 0..9 {
uid.push(CHARS[(val % 62) as usize] as char);
val /= 62;
val = val.wrapping_mul(2862933555777941757).wrapping_add(nanos);
}
uid
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn pull_request_serializes() {
let req = PullRequest {
eid: json!(["block/uid", "abc123"]),
selector: "[:block/string :block/uid {:block/children ...}]".into(),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["eid"], json!(["block/uid", "abc123"]));
assert!(json["selector"].is_string());
}
#[test]
fn pull_response_deserializes() {
let raw =
r#"{"result": {":block/uid": "abc123", ":block/string": "hello", ":block/order": 0}}"#;
let resp: PullResponse = serde_json::from_str(raw).unwrap();
assert_eq!(resp.result[":block/uid"], "abc123");
}
#[test]
fn block_serde_roundtrip() {
let block = Block {
uid: "def456".into(),
string: "Hello [[world]]".into(),
order: 0,
children: vec![Block {
uid: "ghi789".into(),
string: "Child block".into(),
order: 0,
children: vec![],
open: true,
refs: vec![],
}],
open: true,
refs: vec![],
};
let json = serde_json::to_string(&block).unwrap();
let deserialized: Block = serde_json::from_str(&json).unwrap();
assert_eq!(block, deserialized);
assert_eq!(deserialized.children.len(), 1);
}
#[test]
fn block_deserializes_without_optional_fields() {
let raw = r#"{"uid": "abc", "string": "test", "order": 0}"#;
let block: Block = serde_json::from_str(raw).unwrap();
assert!(block.children.is_empty());
assert!(!block.open);
}
#[test]
fn write_action_create_block_serializes() {
let action = WriteAction::CreateBlock {
location: BlockLocation {
parent_uid: "page-uid".into(),
order: OrderValue::Position("last".into()),
},
block: NewBlock {
string: "New block content".into(),
uid: None,
open: None,
},
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "create-block");
assert_eq!(json["location"]["parent-uid"], "page-uid");
assert_eq!(json["location"]["order"], "last");
}
#[test]
fn write_action_update_block_serializes() {
let action = WriteAction::UpdateBlock {
block: BlockUpdate {
uid: "abc123".into(),
string: "Updated content".into(),
},
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "update-block");
assert_eq!(json["block"]["uid"], "abc123");
}
#[test]
fn write_action_delete_block_serializes() {
let action = WriteAction::DeleteBlock {
block: BlockRef {
uid: "abc123".into(),
},
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "delete-block");
assert_eq!(json["block"]["uid"], "abc123");
}
#[test]
fn write_action_move_block_serializes() {
let action = WriteAction::MoveBlock {
block: BlockRef {
uid: "block1".into(),
},
location: BlockLocation {
parent_uid: "new-parent".into(),
order: OrderValue::Position("last".into()),
},
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "move-block");
assert_eq!(json["block"]["uid"], "block1");
assert_eq!(json["location"]["parent-uid"], "new-parent");
assert_eq!(json["location"]["order"], "last");
}
#[test]
fn order_value_index_serializes_as_number() {
let order = OrderValue::Index(5);
let json = serde_json::to_value(&order).unwrap();
assert_eq!(json, 5);
}
#[test]
fn order_value_position_serializes_as_string() {
let order = OrderValue::Position("last".into());
let json = serde_json::to_value(&order).unwrap();
assert_eq!(json, "last");
}
#[test]
fn daily_note_from_pull_response_parses_blocks() {
let pull_result = json!({
":node/title": "February 21, 2026",
":block/uid": "02-21-2026",
":block/children": [
{
":block/uid": "block2",
":block/string": "Second block",
":block/order": 1,
":block/open": true
},
{
":block/uid": "block1",
":block/string": "First block",
":block/order": 0,
":block/open": true
}
]
});
let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
assert_eq!(note.title, "February 21, 2026");
assert_eq!(note.uid, "02-21-2026");
assert_eq!(note.date, date);
assert_eq!(note.blocks.len(), 2);
assert_eq!(note.blocks[0].string, "First block");
assert_eq!(note.blocks[1].string, "Second block");
}
#[test]
fn daily_note_from_pull_response_with_nested_children() {
let pull_result = json!({
":node/title": "February 21, 2026",
":block/uid": "02-21-2026",
":block/children": [
{
":block/uid": "parent",
":block/string": "Parent block",
":block/order": 0,
":block/open": true,
":block/children": [
{
":block/uid": "child2",
":block/string": "Child B",
":block/order": 1
},
{
":block/uid": "child1",
":block/string": "Child A",
":block/order": 0
}
]
}
]
});
let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
assert_eq!(note.blocks.len(), 1);
assert_eq!(note.blocks[0].children.len(), 2);
assert_eq!(note.blocks[0].children[0].string, "Child A");
assert_eq!(note.blocks[0].children[1].string, "Child B");
}
#[test]
fn daily_note_from_empty_pull_response() {
let pull_result = json!({});
let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
assert!(note.blocks.is_empty());
assert_eq!(note.title, "");
assert_eq!(note.blocks.len(), 0);
}
#[test]
fn daily_note_from_pull_response_no_children() {
let pull_result = json!({
":node/title": "February 21, 2026",
":block/uid": "02-21-2026"
});
let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
assert!(note.blocks.is_empty());
assert_eq!(note.title, "February 21, 2026");
}
#[test]
fn block_parses_refs_from_pull_response() {
let pull_result = json!({
":node/title": "February 21, 2026",
":block/uid": "02-21-2026",
":block/children": [
{
":block/uid": "block1",
":block/string": "Links to [[ProjectX]]",
":block/order": 0,
":block/open": true,
":block/refs": [
{
":block/uid": "page-uid-1",
":node/title": "ProjectX"
}
]
}
]
});
let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
assert_eq!(note.blocks[0].refs.len(), 1);
assert_eq!(note.blocks[0].refs[0].uid, "page-uid-1");
assert_eq!(note.blocks[0].refs[0].title.as_deref(), Some("ProjectX"));
}
#[test]
fn block_parses_without_refs() {
let pull_result = json!({
":node/title": "February 21, 2026",
":block/uid": "02-21-2026",
":block/children": [
{
":block/uid": "block1",
":block/string": "No links here",
":block/order": 0
}
]
});
let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 21).unwrap();
let note = DailyNote::from_pull_response(date, "02-21-2026".into(), &pull_result);
assert!(note.blocks[0].refs.is_empty());
}
#[test]
fn refs_not_serialized_in_block_json() {
let block = Block {
uid: "b1".into(),
string: "test".into(),
order: 0,
children: vec![],
open: true,
refs: vec![RefEntity {
uid: "ref1".into(),
title: Some("Page".into()),
string: None,
}],
};
let json = serde_json::to_value(&block).unwrap();
assert!(json.get("refs").is_none());
}
#[test]
fn query_request_serializes() {
let req = QueryRequest {
query: "[:find ?b :where [?b :block/string]]".into(),
args: vec![],
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["query"], "[:find ?b :where [?b :block/string]]");
assert_eq!(json["args"], json!([]));
}
#[test]
fn query_request_serializes_with_args() {
let req = QueryRequest {
query: "[:find ?b :in $ ?title :where [?b :node/title ?title]]".into(),
args: vec![json!("My Page")],
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["args"], json!(["My Page"]));
}
#[test]
fn query_response_deserializes() {
let raw = r#"{"result": [["abc", "hello text", "My Page"]]}"#;
let resp: QueryResponse = serde_json::from_str(raw).unwrap();
assert_eq!(resp.result.len(), 1);
assert_eq!(resp.result[0].len(), 3);
assert_eq!(resp.result[0][0], "abc");
}
#[test]
fn parse_linked_refs_groups_by_page() {
let result = vec![
vec![json!("b1"), json!("mentions [[Target]]"), json!("Page A")],
vec![json!("b2"), json!("also refs [[Target]]"), json!("Page B")],
vec![
json!("b3"),
json!("another ref [[Target]]"),
json!("Page A"),
],
];
let groups = parse_linked_refs(&result, "Target");
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].page_title, "Page A");
assert_eq!(groups[0].blocks.len(), 2);
assert_eq!(groups[1].page_title, "Page B");
assert_eq!(groups[1].blocks.len(), 1);
}
#[test]
fn parse_linked_refs_filters_self_refs() {
let result = vec![
vec![json!("b1"), json!("self ref [[MyPage]]"), json!("MyPage")],
vec![
json!("b2"),
json!("external ref [[MyPage]]"),
json!("Other Page"),
],
];
let groups = parse_linked_refs(&result, "MyPage");
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].page_title, "Other Page");
}
#[test]
fn parse_linked_refs_handles_empty() {
let groups = parse_linked_refs(&[], "AnyPage");
assert!(groups.is_empty());
}
#[test]
fn parse_linked_refs_skips_missing_fields() {
let result = vec![
vec![json!("b1"), json!("text")], vec![json!(""), json!("text"), json!("Page")], vec![json!("b3"), json!("text"), json!("")], ];
let groups = parse_linked_refs(&result, "X");
assert!(groups.is_empty());
}
#[test]
fn write_action_create_page_serializes() {
let action = WriteAction::CreatePage {
page: PageCreate {
title: "My New Page".into(),
uid: Some("page-uid-123".into()),
},
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "create-page");
assert_eq!(json["page"]["title"], "My New Page");
assert_eq!(json["page"]["uid"], "page-uid-123");
}
#[test]
fn write_action_create_page_without_uid() {
let action = WriteAction::CreatePage {
page: PageCreate {
title: "Auto UID Page".into(),
uid: None,
},
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "create-page");
assert_eq!(json["page"]["title"], "Auto UID Page");
assert!(json["page"].get("uid").is_none());
}
#[test]
fn parse_linked_refs_sorts_blocks_within_group() {
let result = vec![
vec![json!("b1"), json!("Zebra [[T]]"), json!("Page")],
vec![json!("b2"), json!("Alpha [[T]]"), json!("Page")],
];
let groups = parse_linked_refs(&result, "T");
assert_eq!(groups[0].blocks[0].string, "Alpha [[T]]");
assert_eq!(groups[0].blocks[1].string, "Zebra [[T]]");
}
}