203 lines
5.9 KiB
Rust
203 lines
5.9 KiB
Rust
//! 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"));
|
|
}
|
|
}
|