1130 lines
42 KiB
HTML
1130 lines
42 KiB
HTML
|
|
<!DOCTYPE HTML>
|
||
|
|
<html lang="en" class="rust sidebar-visible" dir="ltr">
|
||
|
|
<head>
|
||
|
|
<!-- Book generated using mdBook -->
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<title>Logseq Blocks Design - KOGRAL Documentation</title>
|
||
|
|
|
||
|
|
|
||
|
|
<!-- Custom HTML head -->
|
||
|
|
|
||
|
|
<meta name="description" content="Complete documentation for KOGRAL - Git-native knowledge graphs for developer teams">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
|
|
<meta name="theme-color" content="#ffffff">
|
||
|
|
|
||
|
|
<link rel="icon" href="../favicon.svg">
|
||
|
|
<link rel="shortcut icon" href="../favicon.png">
|
||
|
|
<link rel="stylesheet" href="../css/variables.css">
|
||
|
|
<link rel="stylesheet" href="../css/general.css">
|
||
|
|
<link rel="stylesheet" href="../css/chrome.css">
|
||
|
|
<link rel="stylesheet" href="../css/print.css" media="print">
|
||
|
|
|
||
|
|
<!-- Fonts -->
|
||
|
|
<link rel="stylesheet" href="../FontAwesome/css/font-awesome.css">
|
||
|
|
<link rel="stylesheet" href="../fonts/fonts.css">
|
||
|
|
|
||
|
|
<!-- Highlight.js Stylesheets -->
|
||
|
|
<link rel="stylesheet" id="highlight-css" href="../highlight.css">
|
||
|
|
<link rel="stylesheet" id="tomorrow-night-css" href="../tomorrow-night.css">
|
||
|
|
<link rel="stylesheet" id="ayu-highlight-css" href="../ayu-highlight.css">
|
||
|
|
|
||
|
|
<!-- Custom theme stylesheets -->
|
||
|
|
|
||
|
|
|
||
|
|
<!-- Provide site root and default themes to javascript -->
|
||
|
|
<script>
|
||
|
|
const path_to_root = "../";
|
||
|
|
const default_light_theme = "rust";
|
||
|
|
const default_dark_theme = "navy";
|
||
|
|
</script>
|
||
|
|
<!-- Start loading toc.js asap -->
|
||
|
|
<script src="../toc.js"></script>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div id="mdbook-help-container">
|
||
|
|
<div id="mdbook-help-popup">
|
||
|
|
<h2 class="mdbook-help-title">Keyboard shortcuts</h2>
|
||
|
|
<div>
|
||
|
|
<p>Press <kbd>←</kbd> or <kbd>→</kbd> to navigate between chapters</p>
|
||
|
|
<p>Press <kbd>S</kbd> or <kbd>/</kbd> to search in the book</p>
|
||
|
|
<p>Press <kbd>?</kbd> to show this help</p>
|
||
|
|
<p>Press <kbd>Esc</kbd> to hide this help</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div id="body-container">
|
||
|
|
<!-- Work around some values being stored in localStorage wrapped in quotes -->
|
||
|
|
<script>
|
||
|
|
try {
|
||
|
|
let theme = localStorage.getItem('mdbook-theme');
|
||
|
|
let sidebar = localStorage.getItem('mdbook-sidebar');
|
||
|
|
|
||
|
|
if (theme.startsWith('"') && theme.endsWith('"')) {
|
||
|
|
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
|
||
|
|
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
|
||
|
|
}
|
||
|
|
} catch (e) { }
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<!-- Set the theme before any content is loaded, prevents flash -->
|
||
|
|
<script>
|
||
|
|
const default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? default_dark_theme : default_light_theme;
|
||
|
|
let theme;
|
||
|
|
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
|
||
|
|
if (theme === null || theme === undefined) { theme = default_theme; }
|
||
|
|
const html = document.documentElement;
|
||
|
|
html.classList.remove('rust')
|
||
|
|
html.classList.add(theme);
|
||
|
|
html.classList.add("js");
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
|
||
|
|
|
||
|
|
<!-- Hide / unhide sidebar before it is displayed -->
|
||
|
|
<script>
|
||
|
|
let sidebar = null;
|
||
|
|
const sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
|
||
|
|
if (document.body.clientWidth >= 1080) {
|
||
|
|
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
|
||
|
|
sidebar = sidebar || 'visible';
|
||
|
|
} else {
|
||
|
|
sidebar = 'hidden';
|
||
|
|
}
|
||
|
|
sidebar_toggle.checked = sidebar === 'visible';
|
||
|
|
html.classList.remove('sidebar-visible');
|
||
|
|
html.classList.add("sidebar-" + sidebar);
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
|
||
|
|
<!-- populated by js -->
|
||
|
|
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
|
||
|
|
<noscript>
|
||
|
|
<iframe class="sidebar-iframe-outer" src="../toc.html"></iframe>
|
||
|
|
</noscript>
|
||
|
|
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
|
||
|
|
<div class="sidebar-resize-indicator"></div>
|
||
|
|
</div>
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
<div id="page-wrapper" class="page-wrapper">
|
||
|
|
|
||
|
|
<div class="page">
|
||
|
|
<div id="menu-bar-hover-placeholder"></div>
|
||
|
|
<div id="menu-bar" class="menu-bar sticky">
|
||
|
|
<div class="left-buttons">
|
||
|
|
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
|
||
|
|
<i class="fa fa-bars"></i>
|
||
|
|
</label>
|
||
|
|
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
|
||
|
|
<i class="fa fa-paint-brush"></i>
|
||
|
|
</button>
|
||
|
|
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
|
||
|
|
<li role="none"><button role="menuitem" class="theme" id="default_theme">Auto</button></li>
|
||
|
|
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
|
||
|
|
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
|
||
|
|
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
|
||
|
|
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
|
||
|
|
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
|
||
|
|
</ul>
|
||
|
|
<button id="search-toggle" class="icon-button" type="button" title="Search (`/`)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="/ s" aria-controls="searchbar">
|
||
|
|
<i class="fa fa-search"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<h1 class="menu-title">KOGRAL Documentation</h1>
|
||
|
|
|
||
|
|
<div class="right-buttons">
|
||
|
|
<a href="../print.html" title="Print this book" aria-label="Print this book">
|
||
|
|
<i id="print-button" class="fa fa-print"></i>
|
||
|
|
</a>
|
||
|
|
<a href="https://github.com/your-org/knowledge-base" title="Git repository" aria-label="Git repository">
|
||
|
|
<i id="git-repository-button" class="fa fa-github"></i>
|
||
|
|
</a>
|
||
|
|
<a href="https://github.com/your-org/knowledge-base/edit/main/docs/./architecture/logseq-blocks-design.md" title="Suggest an edit" aria-label="Suggest an edit">
|
||
|
|
<i id="git-edit-button" class="fa fa-edit"></i>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="search-wrapper" class="hidden">
|
||
|
|
<form id="searchbar-outer" class="searchbar-outer">
|
||
|
|
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
|
||
|
|
</form>
|
||
|
|
<div id="searchresults-outer" class="searchresults-outer hidden">
|
||
|
|
<div id="searchresults-header" class="searchresults-header"></div>
|
||
|
|
<ul id="searchresults">
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
|
||
|
|
<script>
|
||
|
|
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
|
||
|
|
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
|
||
|
|
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
|
||
|
|
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<div id="content" class="content">
|
||
|
|
<main>
|
||
|
|
<h1 id="logseq-blocks-support---architecture-design"><a class="header" href="#logseq-blocks-support---architecture-design">Logseq Blocks Support - Architecture Design</a></h1>
|
||
|
|
<h2 id="problem-statement"><a class="header" href="#problem-statement">Problem Statement</a></h2>
|
||
|
|
<p>Logseq uses <strong>content blocks</strong> as the fundamental unit of information, not full documents. Each block can have:</p>
|
||
|
|
<ul>
|
||
|
|
<li><strong>Properties</strong>: <code>#card</code>, <code>TODO</code>, <code>DONE</code>, custom properties</li>
|
||
|
|
<li><strong>Tags</strong>: Inline tags like <code>#flashcard</code>, <code>#important</code></li>
|
||
|
|
<li><strong>References</strong>: Block references <code>((block-id))</code>, page references <code>[[page]]</code></li>
|
||
|
|
<li><strong>Nesting</strong>: Outliner-style hierarchy (parent-child blocks)</li>
|
||
|
|
<li><strong>Metadata</strong>: Block-level properties (unlike page-level frontmatter)</li>
|
||
|
|
</ul>
|
||
|
|
<p><strong>Current KB limitation</strong>: Nodes only have <code>content: String</code> (flat markdown). Importing from Logseq loses block structure and properties.</p>
|
||
|
|
<p><strong>Requirement</strong>: Support round-trip import/export with full block fidelity:</p>
|
||
|
|
<pre><code>Logseq Graph → KOGRAL Import → KOGRAL Storage → KOGRAL Export → Logseq Graph
|
||
|
|
(blocks preserved) (blocks preserved)
|
||
|
|
</code></pre>
|
||
|
|
<h2 id="use-cases"><a class="header" href="#use-cases">Use Cases</a></h2>
|
||
|
|
<h3 id="1-flashcards-card"><a class="header" href="#1-flashcards-card">1. Flashcards (<code>#card</code>)</a></h3>
|
||
|
|
<p><strong>Logseq</strong>:</p>
|
||
|
|
<pre><code class="language-markdown">- What is Rust's ownership model? #card
|
||
|
|
- Rust uses ownership, borrowing, and lifetimes
|
||
|
|
- Three rules: one owner, many borrows XOR one mutable
|
||
|
|
</code></pre>
|
||
|
|
<p><strong>KB needs to preserve</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>Block with <code>#card</code> property</li>
|
||
|
|
<li>Nested answer blocks</li>
|
||
|
|
<li>Ability to query all cards</li>
|
||
|
|
</ul>
|
||
|
|
<h3 id="2-task-tracking-tododone"><a class="header" href="#2-task-tracking-tododone">2. Task Tracking (<code>TODO</code>/<code>DONE</code>)</a></h3>
|
||
|
|
<p><strong>Logseq</strong>:</p>
|
||
|
|
<pre><code class="language-markdown">- TODO Implement block parser #rust
|
||
|
|
- DONE Research block structure
|
||
|
|
- TODO Write parser tests
|
||
|
|
</code></pre>
|
||
|
|
<p><strong>KB needs to preserve</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>Task status per block</li>
|
||
|
|
<li>Hierarchical task breakdown</li>
|
||
|
|
<li>Tags on tasks</li>
|
||
|
|
</ul>
|
||
|
|
<h3 id="3-block-references"><a class="header" href="#3-block-references">3. Block References</a></h3>
|
||
|
|
<p><strong>Logseq</strong>:</p>
|
||
|
|
<pre><code class="language-markdown">- Core concept: ((block-uuid-123))
|
||
|
|
- See also: [[Related Page]]
|
||
|
|
</code></pre>
|
||
|
|
<p><strong>KB needs to preserve</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>Block-to-block links (not just page-to-page)</li>
|
||
|
|
<li>UUID references</li>
|
||
|
|
</ul>
|
||
|
|
<h3 id="4-block-properties"><a class="header" href="#4-block-properties">4. Block Properties</a></h3>
|
||
|
|
<p><strong>Logseq</strong>:</p>
|
||
|
|
<pre><code class="language-markdown">- This is a block with properties
|
||
|
|
property1:: value1
|
||
|
|
property2:: value2
|
||
|
|
</code></pre>
|
||
|
|
<p><strong>KB needs to preserve</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>Custom key-value properties per block</li>
|
||
|
|
<li>Property inheritance/override</li>
|
||
|
|
</ul>
|
||
|
|
<h2 id="design-options"><a class="header" href="#design-options">Design Options</a></h2>
|
||
|
|
<h3 id="option-a-blocks-as-first-class-data-structure"><a class="header" href="#option-a-blocks-as-first-class-data-structure">Option A: Blocks as First-Class Data Structure</a></h3>
|
||
|
|
<p><strong>Add <code>blocks</code> field to Node</strong>:</p>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>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,
|
||
|
|
}
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<p><strong>Pros</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>✅ Type-safe, explicit structure</li>
|
||
|
|
<li>✅ Queryable (find all #card blocks)</li>
|
||
|
|
<li>✅ Preserves hierarchy</li>
|
||
|
|
<li>✅ Supports block-level operations</li>
|
||
|
|
</ul>
|
||
|
|
<p><strong>Cons</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>❌ Adds complexity to Node</li>
|
||
|
|
<li>❌ Dual representation (content + blocks)</li>
|
||
|
|
<li>❌ Requires migration of existing data</li>
|
||
|
|
</ul>
|
||
|
|
<h3 id="option-b-parser-only-approach"><a class="header" href="#option-b-parser-only-approach">Option B: Parser-Only Approach</a></h3>
|
||
|
|
<p><strong>Keep <code>content: String</code>, parse blocks on-demand</strong>:</p>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>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"));
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<p><strong>Pros</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>✅ No schema changes</li>
|
||
|
|
<li>✅ Backward compatible</li>
|
||
|
|
<li>✅ Simple storage (still just String)</li>
|
||
|
|
</ul>
|
||
|
|
<p><strong>Cons</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>❌ Parse overhead on every access</li>
|
||
|
|
<li>❌ Can't query blocks in database (SurrealDB)</li>
|
||
|
|
<li>❌ Harder to index/search blocks</li>
|
||
|
|
</ul>
|
||
|
|
<h3 id="option-c-hybrid-approach-recommended"><a class="header" href="#option-c-hybrid-approach-recommended">Option C: Hybrid Approach (RECOMMENDED)</a></h3>
|
||
|
|
<p><strong>Combine both: structured storage + lazy parsing</strong>:</p>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<p><strong>Storage Strategy</strong>:</p>
|
||
|
|
<ol>
|
||
|
|
<li>
|
||
|
|
<p><strong>Filesystem</strong> - Store as markdown (Logseq compatible):</p>
|
||
|
|
<pre><code class="language-markdown">- Block 1 #card
|
||
|
|
- Nested block
|
||
|
|
- Block 2 TODO
|
||
|
|
</code></pre>
|
||
|
|
</li>
|
||
|
|
<li>
|
||
|
|
<p><strong>SurrealDB</strong> - Store both:</p>
|
||
|
|
<pre><code class="language-sql">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;
|
||
|
|
</code></pre>
|
||
|
|
</li>
|
||
|
|
</ol>
|
||
|
|
<p><strong>Pros</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>✅ Best of both worlds</li>
|
||
|
|
<li>✅ Filesystem stays Logseq-compatible</li>
|
||
|
|
<li>✅ SurrealDB can query blocks</li>
|
||
|
|
<li>✅ Lazy parsing (only when needed)</li>
|
||
|
|
<li>✅ Backward compatible</li>
|
||
|
|
</ul>
|
||
|
|
<p><strong>Cons</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>⚠️ Need to keep content/blocks in sync</li>
|
||
|
|
<li>⚠️ More complex implementation</li>
|
||
|
|
</ul>
|
||
|
|
<h2 id="recommended-implementation"><a class="header" href="#recommended-implementation">Recommended Implementation</a></h2>
|
||
|
|
<p><strong>Phase 1: Data Model</strong></p>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>// 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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<p><strong>Phase 2: Update Node Model</strong></p>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>// 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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<p><strong>Phase 3: Block Parser</strong></p>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>// 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
|
||
|
|
}
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<p><strong>Phase 4: Logseq Import/Export</strong></p>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>// 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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<h2 id="query-api-extensions"><a class="header" href="#query-api-extensions">Query API Extensions</a></h2>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>// 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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<h2 id="mcp-tool-extensions"><a class="header" href="#mcp-tool-extensions">MCP Tool Extensions</a></h2>
|
||
|
|
<pre><code class="language-json">{
|
||
|
|
"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" }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</code></pre>
|
||
|
|
<h2 id="configuration"><a class="header" href="#configuration">Configuration</a></h2>
|
||
|
|
<pre><code class="language-nickel"># 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 = {},
|
||
|
|
}
|
||
|
|
</code></pre>
|
||
|
|
<h2 id="migration-path"><a class="header" href="#migration-path">Migration Path</a></h2>
|
||
|
|
<p><strong>Phase 1</strong>: Add Block models (no behavior change)
|
||
|
|
<strong>Phase 2</strong>: Add BlockParser (opt-in via config)
|
||
|
|
<strong>Phase 3</strong>: Update Logseq import/export
|
||
|
|
<strong>Phase 4</strong>: Add block queries to CLI/MCP
|
||
|
|
<strong>Phase 5</strong>: SurrealDB block indexing</p>
|
||
|
|
<p><strong>Backward Compatibility</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>Existing nodes without <code>blocks</code> field work as before</li>
|
||
|
|
<li><code>content</code> remains source of truth</li>
|
||
|
|
<li><code>blocks</code> is optional cache/structure</li>
|
||
|
|
<li>Config flag <code>blocks.enabled</code> to opt-in</li>
|
||
|
|
</ul>
|
||
|
|
<h2 id="testing-strategy"><a class="header" href="#testing-strategy">Testing Strategy</a></h2>
|
||
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
|
|
</span><span class="boring">fn main() {
|
||
|
|
</span>#[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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
<span class="boring">}</span></code></pre></pre>
|
||
|
|
<h2 id="summary"><a class="header" href="#summary">Summary</a></h2>
|
||
|
|
<p><strong>Recommended Approach</strong>: Hybrid (Option C)</p>
|
||
|
|
<ul>
|
||
|
|
<li><strong>Add</strong> <code>Block</code> struct with properties, hierarchy</li>
|
||
|
|
<li><strong>Extend</strong> <code>Node</code> with optional <code>blocks: Option<Vec<Block>></code></li>
|
||
|
|
<li><strong>Implement</strong> bidirectional parser (markdown ↔ blocks)</li>
|
||
|
|
<li><strong>Preserve</strong> <code>content</code> as source of truth (backward compat)</li>
|
||
|
|
<li><strong>Enable</strong> block queries in CLI/MCP</li>
|
||
|
|
<li><strong>Support</strong> round-trip Logseq import/export</li>
|
||
|
|
</ul>
|
||
|
|
<p><strong>Benefits</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>✅ Full Logseq compatibility</li>
|
||
|
|
<li>✅ Queryable blocks (find #card, TODO, etc.)</li>
|
||
|
|
<li>✅ Backward compatible</li>
|
||
|
|
<li>✅ Extensible (custom properties)</li>
|
||
|
|
<li>✅ Type-safe structure</li>
|
||
|
|
</ul>
|
||
|
|
<p><strong>Trade-offs</strong>:</p>
|
||
|
|
<ul>
|
||
|
|
<li>⚠️ Added complexity</li>
|
||
|
|
<li>⚠️ Need to sync content ↔ blocks</li>
|
||
|
|
<li>⚠️ More storage for SurrealDB backend</li>
|
||
|
|
</ul>
|
||
|
|
<p><strong>Next Steps</strong>:</p>
|
||
|
|
<ol>
|
||
|
|
<li>Review and approve design</li>
|
||
|
|
<li>Implement Phase 1 (Block models)</li>
|
||
|
|
<li>Implement Phase 2 (BlockParser)</li>
|
||
|
|
<li>Update Logseq import/export</li>
|
||
|
|
<li>Add block queries to MCP/CLI</li>
|
||
|
|
</ol>
|
||
|
|
|
||
|
|
</main>
|
||
|
|
|
||
|
|
<nav class="nav-wrapper" aria-label="Page navigation">
|
||
|
|
<!-- Mobile navigation buttons -->
|
||
|
|
<a rel="prev" href="../architecture/storage-architecture.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||
|
|
<i class="fa fa-angle-left"></i>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<a rel="next prefetch" href="../architecture/adrs/001-nickel-vs-toml.html" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||
|
|
<i class="fa fa-angle-right"></i>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<div style="clear: both"></div>
|
||
|
|
</nav>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<nav class="nav-wide-wrapper" aria-label="Page navigation">
|
||
|
|
<a rel="prev" href="../architecture/storage-architecture.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||
|
|
<i class="fa fa-angle-left"></i>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<a rel="next prefetch" href="../architecture/adrs/001-nickel-vs-toml.html" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||
|
|
<i class="fa fa-angle-right"></i>
|
||
|
|
</a>
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
<script>
|
||
|
|
window.playground_copyable = true;
|
||
|
|
</script>
|
||
|
|
|
||
|
|
|
||
|
|
<script src="../elasticlunr.min.js"></script>
|
||
|
|
<script src="../mark.min.js"></script>
|
||
|
|
<script src="../searcher.js"></script>
|
||
|
|
|
||
|
|
<script src="../clipboard.min.js"></script>
|
||
|
|
<script src="../highlight.js"></script>
|
||
|
|
<script src="../book.js"></script>
|
||
|
|
|
||
|
|
<!-- Custom JS scripts -->
|
||
|
|
|
||
|
|
|
||
|
|
</div>
|
||
|
|
</body>
|
||
|
|
</html>
|