196 lines
5.2 KiB
Python
Executable File
196 lines
5.2 KiB
Python
Executable File
"""
|
|
generate_sidebar_tree.py
|
|
|
|
Generates sidebar-tree.json from your org-roam directory.
|
|
Groups notes by #+category: property.
|
|
Notes without a category go into "uncategorised".
|
|
|
|
Output shape:
|
|
{
|
|
"groups": [
|
|
{
|
|
"label": "java",
|
|
"files": [
|
|
{ "title": "Java MOC", "url": "/20250402185735-java_moc.html" },
|
|
...
|
|
]
|
|
},
|
|
...
|
|
{
|
|
"label": "uncategorised",
|
|
"files": [ ... ]
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from collections import defaultdict
|
|
|
|
|
|
def get_org_metadata(file_path: Path) -> dict:
|
|
"""Extract title, category (and optionally id) from an Org file."""
|
|
title_pattern = re.compile(r"^#\+title:\s*(.+)", re.IGNORECASE | re.MULTILINE)
|
|
category_pattern = re.compile(r"^#\+category:\s*(.+)", re.IGNORECASE | re.MULTILINE)
|
|
id_pattern = re.compile(r"^:ID:\s*(.+)", re.MULTILINE)
|
|
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
except OSError:
|
|
return {}
|
|
|
|
title_match = title_pattern.search(content)
|
|
category_match = category_pattern.search(content)
|
|
id_match = id_pattern.search(content)
|
|
|
|
return {
|
|
"title": title_match.group(1).strip() if title_match else "Untitled",
|
|
"category": category_match.group(1).strip() if category_match else None,
|
|
"id": id_match.group(1).strip() if id_match else None,
|
|
}
|
|
|
|
|
|
def generate_sidebar_tree(org_dir: Path, out_file: Path) -> None:
|
|
"""Generate sidebar-tree.json grouped by #+category:."""
|
|
print(" Generating sidebar-tree.json...")
|
|
|
|
org_dir = org_dir.expanduser().resolve()
|
|
out_file = out_file.expanduser()
|
|
|
|
# Collect all .org files, skip lock/temp files
|
|
org_files = [
|
|
f for f in org_dir.rglob("*.org")
|
|
if not re.search(r"/\.#[^/]+\.org$", str(f))
|
|
]
|
|
|
|
groups: dict[str, list[dict]] = defaultdict(list)
|
|
|
|
for file_path in org_files:
|
|
meta = get_org_metadata(file_path)
|
|
if not meta:
|
|
continue
|
|
|
|
# Build the URL the same way as search-index.json
|
|
try:
|
|
relative = file_path.relative_to(org_dir).with_suffix("")
|
|
except ValueError:
|
|
continue
|
|
|
|
entry = {
|
|
"title": meta["title"],
|
|
"url": f"/{relative}.html",
|
|
}
|
|
|
|
category = (meta["category"] or "").strip().lower() or "uncategorised"
|
|
groups[category].append(entry)
|
|
|
|
# Sort files within each group alphabetically by title
|
|
for cat in groups:
|
|
groups[cat].sort(key=lambda e: e["title"].lower())
|
|
|
|
# Sort groups alphabetically, but always put "uncategorised" last
|
|
sorted_labels = sorted(
|
|
groups.keys(),
|
|
key=lambda k: (k == "uncategorised", k.lower())
|
|
)
|
|
output = {
|
|
"tree": build_tree(org_dir)
|
|
}
|
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(out_file, "w", encoding="utf-8") as f:
|
|
json.dump(output, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
files_count, folder_count = count_nodes(output["tree"])
|
|
|
|
print(f" sidebar-tree.json generated ({folder_count} folders, {files_count} files)")
|
|
print(f" → {out_file}")
|
|
|
|
|
|
|
|
def count_nodes(nodes):
|
|
total_files = 0
|
|
total_folders = 0
|
|
|
|
if not nodes:
|
|
return 0, 0 # ✅ IMPORTANT BASE CASE
|
|
|
|
for node in nodes:
|
|
total_folders += 1
|
|
total_files += len(node.get("files", []))
|
|
|
|
child_files, child_folders = count_nodes(node.get("children", []))
|
|
total_files += child_files
|
|
total_folders += child_folders
|
|
|
|
return total_files, total_folders
|
|
|
|
def build_tree(org_dir: Path) -> list:
|
|
"""Build nested folder tree based on filesystem structure."""
|
|
|
|
def insert_node(nodes, parts, entry):
|
|
if not parts:
|
|
return
|
|
|
|
label = parts[0]
|
|
|
|
# Find or create folder
|
|
node = next((n for n in nodes if n["label"] == label), None)
|
|
if not node:
|
|
node = {"label": label, "children": [], "files": []}
|
|
nodes.append(node)
|
|
|
|
if len(parts) == 1:
|
|
node["files"].append(entry)
|
|
else:
|
|
insert_node(node["children"], parts[1:], entry)
|
|
|
|
tree = []
|
|
|
|
org_files = [
|
|
f for f in org_dir.rglob("*.org")
|
|
if not re.search(r"/\.#[^/]+\.org$", str(f))
|
|
]
|
|
|
|
for file_path in org_files:
|
|
meta = get_org_metadata(file_path)
|
|
if not meta:
|
|
continue
|
|
|
|
try:
|
|
relative = file_path.relative_to(org_dir)
|
|
except ValueError:
|
|
continue
|
|
|
|
entry = {
|
|
"title": meta["title"],
|
|
"url": f"/{relative.with_suffix('')}.html",
|
|
}
|
|
|
|
folder_parts = relative.parts[:-1] # ignore filename
|
|
insert_node(tree, list(folder_parts), entry)
|
|
|
|
# Sort everything
|
|
def sort_tree(nodes):
|
|
nodes.sort(key=lambda n: n["label"].lower())
|
|
for node in nodes:
|
|
node["files"].sort(key=lambda f: f["title"].lower())
|
|
sort_tree(node["children"])
|
|
|
|
sort_tree(tree)
|
|
|
|
return tree
|
|
|
|
|
|
def main():
|
|
base_dir = Path.home() / "master-folder" / "org_files" / "org_roam"
|
|
sidebar_tree = base_dir / "output" / "assets" / "sidebar-tree.json"
|
|
|
|
generate_sidebar_tree(base_dir, sidebar_tree)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|