203 lines
5.9 KiB
Rust
Raw Normal View History

2026-01-23 16:13:23 +00:00
//! 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<String> {
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::<Vec<_>>()
.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::<Vec<_>>()
.join(", ");
let _ = writeln!(output, "relates-to:: {refs}");
}
if !node.depends_on.is_empty() {
let deps = node
.depends_on
.iter()
.map(|id| format!("[[{id}]]"))
.collect::<Vec<_>>()
.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<Utc>) -> Result<String> {
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<Utc>) -> 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"));
}
}