## Keyboard shortcuts

Press `←` or `→` to navigate between chapters

Press `S` or `/` to search in the book

Press `?` to show this help

Press `Esc` to hide this help

# Logseq Blocks Support - Architecture Design

## Problem Statement

Logseq uses content blocks as the fundamental unit of information, not full documents. Each block can have:

- Properties: `#card`, `TODO`, `DONE`, custom properties
- Tags: Inline tags like `#flashcard`, `#important`
- References: Block references `((block-id))`, page references `[[page]]`
- Nesting: Outliner-style hierarchy (parent-child blocks)
- Metadata: Block-level properties (unlike page-level frontmatter)

Current KB limitation: Nodes only have `content: String` (flat markdown). Importing from Logseq loses block structure and properties.

Requirement: Support round-trip import/export with full block fidelity:

    Logseq Graph → KOGRAL Import → KOGRAL Storage → KOGRAL Export → Logseq Graph
         (blocks preserved)                    (blocks preserved)

## Use Cases

### 1. Flashcards (`#card`)

Logseq:

    - What is Rust's ownership model? #card
      - Rust uses ownership, borrowing, and lifetimes
      - Three rules: one owner, many borrows XOR one mutable

KB needs to preserve:

- Block with `#card` property
- Nested answer blocks
- Ability to query all cards

### 2. Task Tracking (`TODO`/`DONE`)

Logseq:

    - TODO Implement block parser #rust
      - DONE Research block structure
      - TODO Write parser tests

KB needs to preserve:

- Task status per block
- Hierarchical task breakdown
- Tags on tasks

### 3. Block References

Logseq:

    - Core concept: ((block-uuid-123))
      - See also: [[Related Page]]

KB needs to preserve:

- Block-to-block links (not just page-to-page)
- UUID references

### 4. Block Properties

Logseq:

    - This is a block with properties
      property1:: value1
      property2:: value2

KB needs to preserve:

- Custom key-value properties per block
- Property inheritance/override

## Design Options

### Option A: Blocks as First-Class Data Structure

Add `blocks` field to Node:

    #![allow(unused)]
    fn main() {
    pub struct Node {
        // ... existing fields ...
        pub content: String,              // Backward compat: flat markdown
        pub blocks: Option<Vec<Block>>,   // NEW: Structured blocks
    }

    pub struct Block {
        pub id: String,                   // UUID or auto-generated
        pub content: String,              // Block text
        pub properties: BlockProperties,  // Tags, status, custom props
        pub children: Vec<Block>,         // Nested blocks
        pub created: DateTime<Utc>,
        pub modified: DateTime<Utc>,
    }

    pub struct BlockProperties {
        pub tags: Vec<String>,            // #card, #important
        pub status: Option<TaskStatus>,   // TODO, DONE, WAITING
        pub custom: HashMap<String, String>, // property:: value
    }

    pub enum TaskStatus {
        Todo,
        Doing,
        Done,
        Waiting,
        Cancelled,
    }
    }

Pros:

- ✅ Type-safe, explicit structure
- ✅ Queryable (find all #card blocks)
- ✅ Preserves hierarchy
- ✅ Supports block-level operations

Cons:

- ❌ Adds complexity to Node
- ❌ Dual representation (content + blocks)
- ❌ Requires migration of existing data

### Option B: Parser-Only Approach

Keep `content: String`, parse blocks on-demand:

    #![allow(unused)]
    fn main() {
    pub struct BlockParser;

    impl BlockParser {
        // Parse markdown content into block structure
        fn parse(content: &str) -> Vec<Block>;

        // Serialize blocks back to markdown
        fn serialize(blocks: &[Block]) -> String;
    }

    // Usage
    let blocks = BlockParser::parse(&node.content);
    let filtered = blocks.iter().filter(|b| b.properties.tags.contains("card"));
    }

Pros:

- ✅ No schema changes
- ✅ Backward compatible
- ✅ Simple storage (still just String)

Cons:

- ❌ Parse overhead on every access
- ❌ Can't query blocks in database (SurrealDB)
- ❌ Harder to index/search blocks

### Option C: Hybrid Approach (RECOMMENDED)

Combine both: structured storage + lazy parsing:

    #![allow(unused)]
    fn main() {
    pub struct Node {
        // ... existing fields ...
        pub content: String,              // Source of truth (markdown)

        #[serde(skip_serializing_if = "Option::is_none")]
        pub blocks: Option<Vec<Block>>,   // Cached structure (parsed)
    }

    impl Node {
        // Parse blocks from content if not already cached
        pub fn get_blocks(&mut self) -> &Vec<Block> {
            if self.blocks.is_none() {
                self.blocks = Some(BlockParser::parse(&self.content));
            }
            self.blocks.as_ref().unwrap()
        }

        // Update content from blocks (when blocks modified)
        pub fn sync_blocks_to_content(&mut self) {
            if let Some(ref blocks) = self.blocks {
                self.content = BlockParser::serialize(blocks);
            }
        }
    }
    }

Storage Strategy:

1. Filesystem - Store as markdown (Logseq compatible):

       - Block 1 #card
         - Nested block
       - Block 2 TODO

2. SurrealDB - Store both:

       DEFINE TABLE block SCHEMAFULL;
       DEFINE FIELD node_id ON block TYPE record(node);
       DEFINE FIELD block_id ON block TYPE string;
       DEFINE FIELD content ON block TYPE string;
       DEFINE FIELD properties ON block TYPE object;
       DEFINE FIELD parent_id ON block TYPE option<string>;

       -- Index for queries
       DEFINE INDEX block_tags ON block COLUMNS properties.tags;
       DEFINE INDEX block_status ON block COLUMNS properties.status;

Pros:

- ✅ Best of both worlds
- ✅ Filesystem stays Logseq-compatible
- ✅ SurrealDB can query blocks
- ✅ Lazy parsing (only when needed)
- ✅ Backward compatible

Cons:

- ⚠️ Need to keep content/blocks in sync
- ⚠️ More complex implementation

## Recommended Implementation

Phase 1: Data Model

    #![allow(unused)]
    fn main() {
    // crates/kb-core/src/models/block.rs

    use chrono::{DateTime, Utc};
    use serde::{Deserialize, Serialize};
    use std::collections::HashMap;

    /// A content block (Logseq-style)
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct Block {
        /// Unique block identifier (UUID)
        pub id: String,

        /// Block content (markdown text, excluding nested blocks)
        pub content: String,

        /// Block properties (tags, status, custom)
        pub properties: BlockProperties,

        /// Child blocks (nested hierarchy)
        #[serde(default)]
        pub children: Vec<Block>,

        /// Creation timestamp
        pub created: DateTime<Utc>,

        /// Last modification timestamp
        pub modified: DateTime<Utc>,

        /// Parent block ID (if nested)
        #[serde(skip_serializing_if = "Option::is_none")]
        pub parent_id: Option<String>,
    }

    /// Block-level properties
    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    pub struct BlockProperties {
        /// Tags (e.g., #card, #important)
        #[serde(default)]
        pub tags: Vec<String>,

        /// Task status (TODO, DONE, etc.)
        #[serde(skip_serializing_if = "Option::is_none")]
        pub status: Option<TaskStatus>,

        /// Custom properties (property:: value)
        #[serde(default)]
        pub custom: HashMap<String, String>,

        /// Block references ((uuid))
        #[serde(default)]
        pub block_refs: Vec<String>,

        /// Page references ([[page]])
        #[serde(default)]
        pub page_refs: Vec<String>,
    }

    /// Task status for TODO blocks
    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    #[serde(rename_all = "UPPERCASE")]
    pub enum TaskStatus {
        Todo,
        Doing,
        Done,
        Later,
        Now,
        Waiting,
        Cancelled,
    }

    impl Block {
        /// Create a new block with content
        pub fn new(content: String) -> Self {
            use uuid::Uuid;
            Self {
                id: Uuid::new_v4().to_string(),
                content,
                properties: BlockProperties::default(),
                children: Vec::new(),
                created: Utc::now(),
                modified: Utc::now(),
                parent_id: None,
            }
        }

        /// Add a child block
        pub fn add_child(&mut self, mut child: Block) {
            child.parent_id = Some(self.id.clone());
            self.children.push(child);
            self.modified = Utc::now();
        }

        /// Add a tag to this block
        pub fn add_tag(&mut self, tag: String) {
            if !self.properties.tags.contains(&tag) {
                self.properties.tags.push(tag);
                self.modified = Utc::now();
            }
        }

        /// Set task status
        pub fn set_status(&mut self, status: TaskStatus) {
            self.properties.status = Some(status);
            self.modified = Utc::now();
        }

        /// Get all blocks (self + descendants) as flat list
        pub fn flatten(&self) -> Vec<&Block> {
            let mut result = vec![self];
            for child in &self.children {
                result.extend(child.flatten());
            }
            result
        }

        /// Find block by ID in tree
        pub fn find(&self, id: &str) -> Option<&Block> {
            if self.id == id {
                return Some(self);
            }
            for child in &self.children {
                if let Some(found) = child.find(id) {
                    return Some(found);
                }
            }
            None
        }
    }
    }

Phase 2: Update Node Model

    #![allow(unused)]
    fn main() {
    // crates/kb-core/src/models.rs (modifications)

    use crate::models::block::Block;

    pub struct Node {
        // ... existing fields ...
        pub content: String,

        /// Structured blocks (optional, parsed from content)
        #[serde(skip_serializing_if = "Option::is_none")]
        pub blocks: Option<Vec<Block>>,
    }

    impl Node {
        /// Get blocks, parsing from content if needed
        pub fn get_blocks(&mut self) -> Result<&Vec<Block>> {
            if self.blocks.is_none() {
                self.blocks = Some(crate::parser::BlockParser::parse(&self.content)?);
            }
            Ok(self.blocks.as_ref().unwrap())
        }

        /// Update content from blocks
        pub fn sync_blocks_to_content(&mut self) {
            if let Some(ref blocks) = self.blocks {
                self.content = crate::parser::BlockParser::serialize(blocks);
            }
        }

        /// Find all blocks with a specific tag
        pub fn find_blocks_by_tag(&mut self, tag: &str) -> Result<Vec<&Block>> {
            let blocks = self.get_blocks()?;
            let mut result = Vec::new();
            for block in blocks {
                for b in block.flatten() {
                    if b.properties.tags.iter().any(|t| t == tag) {
                        result.push(b);
                    }
                }
            }
            Ok(result)
        }

        /// Find all TODO blocks
        pub fn find_todos(&mut self) -> Result<Vec<&Block>> {
            let blocks = self.get_blocks()?;
            let mut result = Vec::new();
            for block in blocks {
                for b in block.flatten() {
                    if matches!(b.properties.status, Some(TaskStatus::Todo)) {
                        result.push(b);
                    }
                }
            }
            Ok(result)
        }
    }
    }

Phase 3: Block Parser

    #![allow(unused)]
    fn main() {
    // crates/kb-core/src/parser/block_parser.rs

    use crate::models::block::{Block, BlockProperties, TaskStatus};
    use regex::Regex;

    pub struct BlockParser;

    impl BlockParser {
        /// Parse markdown content into block structure
        ///
        /// Handles:
        /// - Outliner format (- prefix with indentation)
        /// - Tags (#card, #important)
        /// - Task status (TODO, DONE)
        /// - Properties (property:: value)
        /// - Block references (((uuid)))
        /// - Page references ([[page]])
        pub fn parse(content: &str) -> Result<Vec<Block>> {
            let mut blocks = Vec::new();
            let mut stack: Vec<(usize, Block)> = Vec::new(); // (indent_level, block)

            for line in content.lines() {
                // Detect indentation level
                let indent = count_indent(line);
                let trimmed = line.trim_start();

                // Skip empty lines
                if trimmed.is_empty() {
                    continue;
                }

                // Parse block line
                if let Some(block_content) = trimmed.strip_prefix("- ") {
                    let mut block = Self::parse_block_line(block_content)?;

                    // Pop stack until we find parent level
                    while let Some((level, _)) = stack.last() {
                        if *level < indent {
                            break;
                        }
                        stack.pop();
                    }

                    // Add as child to parent or as root
                    if let Some((_, parent)) = stack.last_mut() {
                        parent.add_child(block.clone());
                    } else {
                        blocks.push(block.clone());
                    }

                    stack.push((indent, block));
                }
            }

            Ok(blocks)
        }

        /// Parse a single block line (after "- " prefix)
        fn parse_block_line(line: &str) -> Result<Block> {
            let mut block = Block::new(String::new());
            let mut properties = BlockProperties::default();

            // Extract task status (TODO, DONE, etc.)
            let (status, remaining) = Self::extract_task_status(line);
            properties.status = status;

            // Extract tags (#card, #important)
            let (tags, remaining) = Self::extract_tags(remaining);
            properties.tags = tags;

            // Extract properties (property:: value)
            let (custom_props, remaining) = Self::extract_properties(remaining);
            properties.custom = custom_props;

            // Extract block references (((uuid)))
            let (block_refs, remaining) = Self::extract_block_refs(remaining);
            properties.block_refs = block_refs;

            // Extract page references ([[page]])
            let (page_refs, content) = Self::extract_page_refs(remaining);
            properties.page_refs = page_refs;

            block.content = content.trim().to_string();
            block.properties = properties;

            Ok(block)
        }

        /// Serialize blocks back to markdown
        pub fn serialize(blocks: &[Block]) -> String {
            let mut result = String::new();
            for block in blocks {
                Self::serialize_block(&mut result, block, 0);
            }
            result
        }

        fn serialize_block(output: &mut String, block: &Block, indent: usize) {
            // Write indent
            for _ in 0..indent {
                output.push_str("  ");
            }

            // Write prefix
            output.push_str("- ");

            // Write task status
            if let Some(status) = block.properties.status {
                output.push_str(&format!("{:?} ", status).to_uppercase());
            }

            // Write content
            output.push_str(&block.content);

            // Write tags
            for tag in &block.properties.tags {
                output.push_str(&format!(" #{}", tag));
            }

            // Write properties
            if !block.properties.custom.is_empty() {
                output.push('\n');
                for (key, value) in &block.properties.custom {
                    for _ in 0..=indent {
                        output.push_str("  ");
                    }
                    output.push_str(&format!("{}:: {}\n", key, value));
                }
            }

            output.push('\n');

            // Write children recursively
            for child in &block.children {
                Self::serialize_block(output, child, indent + 1);
            }
        }

        // Helper methods for extraction
        fn extract_task_status(line: &str) -> (Option<TaskStatus>, &str) {
            let line = line.trim_start();
            if let Some(rest) = line.strip_prefix("TODO ") {
                (Some(TaskStatus::Todo), rest)
            } else if let Some(rest) = line.strip_prefix("DONE ") {
                (Some(TaskStatus::Done), rest)
            } else if let Some(rest) = line.strip_prefix("DOING ") {
                (Some(TaskStatus::Doing), rest)
            } else if let Some(rest) = line.strip_prefix("LATER ") {
                (Some(TaskStatus::Later), rest)
            } else if let Some(rest) = line.strip_prefix("NOW ") {
                (Some(TaskStatus::Now), rest)
            } else if let Some(rest) = line.strip_prefix("WAITING ") {
                (Some(TaskStatus::Waiting), rest)
            } else if let Some(rest) = line.strip_prefix("CANCELLED ") {
                (Some(TaskStatus::Cancelled), rest)
            } else {
                (None, line)
            }
        }

        fn extract_tags(line: &str) -> (Vec<String>, String) {
            let tag_regex = Regex::new(r"#(\w+)").unwrap();
            let mut tags = Vec::new();
            let mut result = line.to_string();

            for cap in tag_regex.captures_iter(line) {
                if let Some(tag) = cap.get(1) {
                    tags.push(tag.as_str().to_string());
                    result = result.replace(&format!("#{}", tag.as_str()), "");
                }
            }

            (tags, result.trim().to_string())
        }

        fn extract_properties(line: &str) -> (HashMap<String, String>, String) {
            let prop_regex = Regex::new(r"(\w+)::\s*([^\n]+)").unwrap();
            let mut props = HashMap::new();
            let mut result = line.to_string();

            for cap in prop_regex.captures_iter(line) {
                if let (Some(key), Some(value)) = (cap.get(1), cap.get(2)) {
                    props.insert(key.as_str().to_string(), value.as_str().trim().to_string());
                    result = result.replace(&cap[0], "");
                }
            }

            (props, result.trim().to_string())
        }

        fn extract_block_refs(line: &str) -> (Vec<String>, String) {
            let ref_regex = Regex::new(r"\(\(([^)]+)\)\)").unwrap();
            let mut refs = Vec::new();
            let mut result = line.to_string();

            for cap in ref_regex.captures_iter(line) {
                if let Some(uuid) = cap.get(1) {
                    refs.push(uuid.as_str().to_string());
                    result = result.replace(&cap[0], "");
                }
            }

            (refs, result.trim().to_string())
        }

        fn extract_page_refs(line: &str) -> (Vec<String>, String) {
            let page_regex = Regex::new(r"\[\[([^\]]+)\]\]").unwrap();
            let mut pages = Vec::new();
            let result = line.to_string();

            for cap in page_regex.captures_iter(line) {
                if let Some(page) = cap.get(1) {
                    pages.push(page.as_str().to_string());
                    // Keep [[page]] in content for now (backward compat)
                }
            }

            (pages, result)
        }
    }

    fn count_indent(line: &str) -> usize {
        line.chars().take_while(|c| c.is_whitespace()).count() / 2
    }
    }

Phase 4: Logseq Import/Export

    #![allow(unused)]
    fn main() {
    // crates/kb-core/src/logseq.rs

    use crate::models::{Node, NodeType};
    use crate::models::block::Block;
    use crate::parser::BlockParser;

    pub struct LogseqImporter;

    impl LogseqImporter {
        /// Import a Logseq page (markdown file) as a Node
        pub fn import_page(path: &Path) -> Result<Node> {
            let content = std::fs::read_to_string(path)?;

            // Extract frontmatter if present
            let (frontmatter, body) = Self::split_frontmatter(&content);

            // Parse blocks from body
            let blocks = BlockParser::parse(&body)?;

            // Create node with blocks
            let mut node = Node::new(NodeType::Note, Self::extract_title(path));
            node.content = body;
            node.blocks = Some(blocks);

            // Apply frontmatter properties
            if let Some(fm) = frontmatter {
                Self::apply_frontmatter(&mut node, &fm)?;
            }

            Ok(node)
        }

        fn split_frontmatter(content: &str) -> (Option<String>, String) {
            if content.starts_with("---\n") {
                if let Some(end) = content[4..].find("\n---\n") {
                    let frontmatter = content[4..4 + end].to_string();
                    let body = content[4 + end + 5..].to_string();
                    return (Some(frontmatter), body);
                }
            }
            (None, content.to_string())
        }

        fn extract_title(path: &Path) -> String {
            path.file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("Untitled")
                .to_string()
        }

        fn apply_frontmatter(node: &mut Node, frontmatter: &str) -> Result<()> {
            // Parse YAML frontmatter and apply to node
            // ... implementation ...
            Ok(())
        }
    }

    pub struct LogseqExporter;

    impl LogseqExporter {
        /// Export a Node to Logseq page format
        pub fn export_page(node: &Node, path: &Path) -> Result<()> {
            let mut output = String::new();

            // Generate frontmatter
            output.push_str("---\n");
            output.push_str(&Self::generate_frontmatter(node)?);
            output.push_str("---\n\n");

            // Serialize blocks or use content
            if let Some(ref blocks) = node.blocks {
                output.push_str(&BlockParser::serialize(blocks));
            } else {
                output.push_str(&node.content);
            }

            std::fs::write(path, output)?;
            Ok(())
        }

        fn generate_frontmatter(node: &Node) -> Result<String> {
            let mut fm = String::new();
            fm.push_str(&format!("title: {}\n", node.title));
            fm.push_str(&format!("tags: {}\n", node.tags.join(", ")));
            // ... more frontmatter fields ...
            Ok(fm)
        }
    }
    }

## Query API Extensions

    #![allow(unused)]
    fn main() {
    // New methods in Graph or Query module

    impl Graph {
        /// Find all blocks with a specific tag across all nodes
        pub fn find_blocks_by_tag(&mut self, tag: &str) -> Vec<(&Node, &Block)> {
            let mut results = Vec::new();
            for node in self.nodes.values_mut() {
                if let Ok(blocks) = node.find_blocks_by_tag(tag) {
                    for block in blocks {
                        results.push((node as &Node, block));
                    }
                }
            }
            results
        }

        /// Find all flashcards (#card blocks)
        pub fn find_flashcards(&mut self) -> Vec<(&Node, &Block)> {
            self.find_blocks_by_tag("card")
        }

        /// Find all TODO items across knowledge base
        pub fn find_all_todos(&mut self) -> Vec<(&Node, &Block)> {
            let mut results = Vec::new();
            for node in self.nodes.values_mut() {
                if let Ok(todos) = node.find_todos() {
                    for block in todos {
                        results.push((node as &Node, block));
                    }
                }
            }
            results
        }
    }
    }

## MCP Tool Extensions

    {
      "name": "kogral/find_blocks",
      "description": "Find blocks by tag, status, or properties",
      "inputSchema": {
        "type": "object",
        "properties": {
          "tag": { "type": "string", "description": "Filter by tag (e.g., 'card')" },
          "status": { "type": "string", "enum": ["TODO", "DONE", "DOING"] },
          "property": { "type": "string", "description": "Custom property key" },
          "value": { "type": "string", "description": "Property value to match" }
        }
      }
    }

## Configuration

    # schemas/kb/contracts.ncl (additions)

    BlockConfig = {
      enabled | Bool
        | doc "Enable block-level parsing and storage"
        | default = true,

      preserve_hierarchy | Bool
        | doc "Preserve block nesting on import/export"
        | default = true,

      parse_on_load | Bool
        | doc "Automatically parse blocks when loading nodes"
        | default = false,  # Lazy parsing by default

      supported_statuses | Array String
        | doc "Supported task statuses"
        | default = ["TODO", "DONE", "DOING", "LATER", "NOW", "WAITING", "CANCELLED"],
    }

    KbConfig = {
      # ... existing fields ...

      blocks | BlockConfig
        | doc "Block-level features configuration"
        | default = {},
    }

## Migration Path

Phase 1: Add Block models (no behavior change) Phase 2: Add BlockParser (opt-in via config) Phase 3: Update Logseq import/export Phase 4: Add block queries to CLI/MCP Phase 5: SurrealDB block indexing

Backward Compatibility:

- Existing nodes without `blocks` field work as before
- `content` remains source of truth
- `blocks` is optional cache/structure
- Config flag `blocks.enabled` to opt-in

## Testing Strategy

    #![allow(unused)]
    fn main() {
    #[cfg(test)]
    mod tests {
        use super::*;

        #[test]
        fn test_parse_simple_block() {
            let content = "- This is a block #card";
            let blocks = BlockParser::parse(content).unwrap();

            assert_eq!(blocks.len(), 1);
            assert_eq!(blocks[0].content, "This is a block");
            assert_eq!(blocks[0].properties.tags, vec!["card"]);
        }

        #[test]
        fn test_parse_nested_blocks() {
            let content = r#"
    - Parent block
      - Child block 1
      - Child block 2
        - Grandchild
    "#;
            let blocks = BlockParser::parse(content).unwrap();

            assert_eq!(blocks.len(), 1);
            assert_eq!(blocks[0].children.len(), 2);
            assert_eq!(blocks[0].children[1].children.len(), 1);
        }

        #[test]
        fn test_parse_todo() {
            let content = "- TODO Implement feature #rust";
            let blocks = BlockParser::parse(content).unwrap();

            assert_eq!(blocks[0].properties.status, Some(TaskStatus::Todo));
            assert_eq!(blocks[0].content, "Implement feature");
            assert_eq!(blocks[0].properties.tags, vec!["rust"]);
        }

        #[test]
        fn test_roundtrip() {
            let original = r#"- Block 1 #card
      - Nested
    - TODO Block 2
      priority:: high
    "#;
            let blocks = BlockParser::parse(original).unwrap();
            let serialized = BlockParser::serialize(&blocks);
            let reparsed = BlockParser::parse(&serialized).unwrap();

            assert_eq!(blocks.len(), reparsed.len());
            assert_eq!(blocks[0].properties, reparsed[0].properties);
        }
    }
    }

## Summary

Recommended Approach: Hybrid (Option C)

- Add `Block` struct with properties, hierarchy
- Extend `Node` with optional `blocks: Option<Vec<Block>>`
- Implement bidirectional parser (markdown ↔ blocks)
- Preserve `content` as source of truth (backward compat)
- Enable block queries in CLI/MCP
- Support round-trip Logseq import/export

Benefits:

- ✅ Full Logseq compatibility
- ✅ Queryable blocks (find #card, TODO, etc.)
- ✅ Backward compatible
- ✅ Extensible (custom properties)
- ✅ Type-safe structure

Trade-offs:

- ⚠️ Added complexity
- ⚠️ Need to sync content ↔ blocks
- ⚠️ More storage for SurrealDB backend

Next Steps:

1. Review and approve design
2. Implement Phase 1 (Block models)
3. Implement Phase 2 (BlockParser)
4. Update Logseq import/export
5. Add block queries to MCP/CLI