Export CherryTree Notes to Obsidian (Python Script)
1. In CherryTree, save or export the notebook without a password (.ctd XML or .ctb SQLite).
2. Save this script as cherrytree_to_obsidian.py, set INPUT_FILE and OUTPUT_DIR, and run it with python cherrytree_to_obsidian.py.
#!/usr/bin/env python3
"""
cherrytree_to_obsidian.py
Converts CherryTree files (.ctb SQLite / .ctd XML) to Markdown for Obsidian.
Keeps the node hierarchy as folders/files.
"""
import sys
import re
import sqlite3
import xml.etree.ElementTree as ET
from pathlib import Path
# --- Settings ----------------------------------------------------------------
INPUT_FILE = r"C:\TMP\Cherry.ctd"
OUTPUT_DIR = r"C:\TMP\obsidian"
# -----------------------------------------------------------------------------
def sanitize_name(name: str) -> str:
"""Remove characters that are not valid in file/folder names."""
name = re.sub(r'[\\/*?:"<>|]', "_", name)
return name.strip()
def write_node(node_id: int, name: str, content: str, parent_path: Path, is_folder: bool):
"""Write a node as a .md file in the right place."""
safe_name = sanitize_name(name)
if is_folder:
folder_path = parent_path / safe_name
folder_path.mkdir(parents=True, exist_ok=True)
md_file = folder_path / "_index.md"
else:
parent_path.mkdir(parents=True, exist_ok=True)
md_file = parent_path / f"{safe_name}.md"
# Avoid overwriting on name collisions
if md_file.exists():
md_file = md_file.with_stem(f"{md_file.stem}_{node_id}")
md_file.write_text(content, encoding="utf-8")
return md_file
def apply_rich_format(text: str, attrs: dict) -> str:
"""Apply Markdown formatting from CherryTree rich_text attributes."""
if not text:
return text
weight = attrs.get("weight", "")
style = attrs.get("style", "")
strikethrough = attrs.get("strikethrough", "")
family = attrs.get("family", "")
link = attrs.get("link", "")
if family == "monospace":
text = f"`{text}`"
if weight == "heavy":
text = f"**{text}**"
if style == "italic":
text = f"*{text}*"
if strikethrough == "true":
text = f"~~{text}~~"
if link:
url = link.replace("webs ", "").replace("node ", "")
text = f"[{text}]({url})"
return text
# --- SQLite parser (.ctb) ----------------------------------------------------
def parse_ctb(filepath: str, output_root: Path):
"""Process .ctb files (SQLite)."""
conn = sqlite3.connect(filepath)
cur = conn.cursor()
cur.execute("SELECT node_id, name, txt, syntax, is_richtxt, level FROM node")
nodes = {
row[0]: {"name": row[1], "txt": row[2], "syntax": row[3], "rich": row[4], "level": row[5]}
for row in cur.fetchall()
}
cur.execute("SELECT node_id, father_id, sequence FROM children ORDER BY father_id, sequence")
children_map = {}
node_parent = {}
for node_id, father_id, _ in cur.fetchall():
children_map.setdefault(father_id, []).append(node_id)
node_parent[node_id] = father_id
conn.close()
def get_path(node_id: int) -> Path:
path_parts = []
current = node_id
while current in node_parent and node_parent[current] != 0:
current = node_parent[current]
path_parts.append(sanitize_name(nodes[current]["name"]))
path_parts.reverse()
return output_root.joinpath(*path_parts)
for node_id, data in nodes.items():
name = data["name"]
raw_txt = data["txt"] or ""
is_rich = data["rich"]
if is_rich:
try:
root_elem = ET.fromstring(f"<root>{raw_txt}</root>")
parts = []
for elem in root_elem.iter("rich_text"):
t = apply_rich_format(elem.text or "", elem.attrib)
if t:
parts.append(t)
content = "".join(parts)
except ET.ParseError:
content = raw_txt
else:
syntax = data["syntax"] or "text"
lang = "" if syntax == "plain-text" else syntax
content = f"```{lang}\n{raw_txt}\n```" if lang else raw_txt
has_children = node_id in children_map
parent_path = get_path(node_id)
write_node(node_id, name, content, parent_path, is_folder=has_children)
print(f" OK {name}")
print(f"\nExported to: {output_root.resolve()}")
# --- XML parser (.ctd / .ctx) ------------------------------------------------
def parse_ctd(filepath: str, output_root: Path):
"""Process .ctd files (XML)."""
tree = ET.parse(filepath)
root = tree.getroot()
def process_node(xml_node, current_path: Path):
name = xml_node.get("name", "unnamed")
node_id = xml_node.get("unique_id", "0")
parts = []
for rt in xml_node.findall("rich_text"):
t = apply_rich_format(rt.text or "", rt.attrib)
if t:
parts.append(t)
content = "".join(parts)
child_nodes = xml_node.findall("node")
has_children = len(child_nodes) > 0
write_node(int(node_id), name, content, current_path, is_folder=has_children)
print(f" OK {name}")
if has_children:
child_path = current_path / sanitize_name(name)
for child in child_nodes:
process_node(child, child_path)
output_root.mkdir(parents=True, exist_ok=True)
for top_node in root.findall("node"):
process_node(top_node, output_root)
print(f"\nExported to: {output_root.resolve()}")
# --- Main --------------------------------------------------------------------
def main():
input_path = Path(INPUT_FILE)
output_path = Path(OUTPUT_DIR)
if not input_path.exists():
print(f"ERROR: file not found: {input_path}")
sys.exit(1)
output_path.mkdir(parents=True, exist_ok=True)
ext = input_path.suffix.lower()
print(f"Input : {input_path}")
print(f"Output : {output_path}\n")
if ext == ".ctb":
parse_ctb(str(input_path), output_path)
elif ext in (".ctd", ".ctx"):
parse_ctd(str(input_path), output_path)
else:
print(f"ERROR: unsupported format: {ext}")
print("Supported formats: .ctb (SQLite), .ctd/.ctx (XML)")
sys.exit(1)
if __name__ == "__main__":
main()
Limitations: only text and its formatting are converted. Embedded images, tables and code boxes are not exported, so review those nodes by hand. Requires Python 3.9+.