//! Logseq format export with block support //! //! Exports KOGRAL nodes to Logseq-compatible markdown format. //! Supports both flat content and structured blocks. use std::fmt::Write; use chrono::{DateTime, Utc}; use crate::block_parser::BlockParser; use crate::error::Result; use crate::models::{Node, NodeType}; /// Export a node to Logseq page format /// /// # Format /// /// ```markdown /// --- /// title: Page Title /// tags: [[tag1]], [[tag2]] /// created: 2026-01-17 /// --- /// /// - Block content /// - Nested block /// - TODO Task block /// ``` /// /// # Errors /// /// Returns an error if node content cannot be serialized to Logseq format pub fn export_to_logseq_page(node: &Node) -> Result { let mut output = String::new(); // Frontmatter (Logseq style with :: properties) let _ = writeln!(output, "title:: {}", node.title); if !node.tags.is_empty() { let tags = node .tags .iter() .map(|t| format!("[[{t}]]")) .collect::>() .join(", "); let _ = writeln!(output, "tags:: {tags}"); } let _ = writeln!(output, "created:: {}", format_logseq_date(node.created)); let _ = writeln!(output, "modified:: {}", format_logseq_date(node.modified)); let _ = writeln!(output, "type:: {}", node.node_type); let _ = writeln!(output, "status:: {}", node.status); // Add relationships as properties if !node.relates_to.is_empty() { let refs = node .relates_to .iter() .map(|id| format!("[[{id}]]")) .collect::>() .join(", "); let _ = writeln!(output, "relates-to:: {refs}"); } if !node.depends_on.is_empty() { let deps = node .depends_on .iter() .map(|id| format!("[[{id}]]")) .collect::>() .join(", "); let _ = writeln!(output, "depends-on:: {deps}"); } output.push('\n'); // Content // If node has parsed blocks, serialize them directly // Otherwise, use the content as-is if let Some(blocks) = &node.blocks { if blocks.is_empty() { output.push_str(&node.content); } else { output.push_str(&BlockParser::serialize(blocks)); } } else { output.push_str(&node.content); } Ok(output) } /// Export a node to Logseq journal format /// /// Journal pages use date-based naming and simpler format /// /// # Errors /// /// Returns an error if node content cannot be serialized to Logseq journal /// format pub fn export_to_logseq_journal(node: &Node, _date: DateTime) -> Result { let mut output = String::new(); // Journal pages typically don't have explicit frontmatter // Just content in block format // Add a header with the node title if not a journal type if node.node_type != NodeType::Journal { let _ = writeln!(output, "- ## {}", node.title); } // Add tags as first-level blocks for tag in &node.tags { let _ = writeln!(output, "- #{tag}"); } // Content if let Some(blocks) = &node.blocks { if blocks.is_empty() { output.push_str(&node.content); } else { output.push_str(&BlockParser::serialize(blocks)); } } else { output.push_str(&node.content); } Ok(output) } /// Format datetime for Logseq (supports both formats) fn format_logseq_date(dt: DateTime) -> String { // Logseq uses format: [[Jan 17th, 2026]] dt.format("%b %eth, %Y").to_string() } #[cfg(test)] mod tests { use super::*; use crate::models::{Block, TaskStatus}; #[test] fn test_export_simple_node() { let mut node = Node::new(NodeType::Note, "Test Note".to_string()); node.content = "Simple content without blocks".to_string(); node.add_tag("rust".to_string()); let exported = export_to_logseq_page(&node).unwrap(); assert!(exported.contains("title:: Test Note")); assert!(exported.contains("tags:: [[rust]]")); assert!(exported.contains("type:: note")); assert!(exported.contains("Simple content without blocks")); } #[test] fn test_export_node_with_blocks() { let mut node = Node::new(NodeType::Note, "Test".to_string()); // Create blocks let mut block1 = Block::new("First block".to_string()); block1.properties.add_tag("important".to_string()); let mut block2 = Block::new("Second task".to_string()); block2.properties.status = Some(TaskStatus::Todo); node.blocks = Some(vec![block1, block2]); let exported = export_to_logseq_page(&node).unwrap(); assert!(exported.contains("title:: Test")); assert!(exported.contains("- First block #important")); assert!(exported.contains("- TODO Second task")); } #[test] fn test_export_with_relationships() { let mut node = Node::new(NodeType::Decision, "ADR-001".to_string()); node.relates_to = vec!["node-123".to_string(), "node-456".to_string()]; node.depends_on = vec!["node-789".to_string()]; node.content = "Decision content".to_string(); let exported = export_to_logseq_page(&node).unwrap(); assert!(exported.contains("relates-to:: [[node-123]], [[node-456]]")); assert!(exported.contains("depends-on:: [[node-789]]")); } #[test] fn test_export_journal() { let mut node = Node::new(NodeType::Journal, "Daily Note".to_string()); node.content = "- Morning reflection\n- TODO Review code".to_string(); node.add_tag("daily".to_string()); let date = Utc::now(); let exported = export_to_logseq_journal(&node, date).unwrap(); assert!(exported.contains("#daily")); assert!(exported.contains("- Morning reflection")); assert!(exported.contains("- TODO Review code")); } }