This commit is contained in:
@@ -1,132 +1,113 @@
|
||||
"""
|
||||
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".
|
||||
Generates sidebar-tree.json from the org-roam directory.
|
||||
|
||||
Output shape:
|
||||
{
|
||||
"groups": [
|
||||
"mocs": [
|
||||
{ "title": "Brain MOC", "url": "/20241210233721-brain_moc.html", "pinned": true },
|
||||
...
|
||||
],
|
||||
"tree": [
|
||||
{
|
||||
"label": "java",
|
||||
"files": [
|
||||
{ "title": "Java MOC", "url": "/20250402185735-java_moc.html" },
|
||||
...
|
||||
]
|
||||
"label": "Articles",
|
||||
"children": [],
|
||||
"files": [{ "title": "...", "url": "/Articles/....html" }]
|
||||
},
|
||||
...
|
||||
{
|
||||
"label": "uncategorised",
|
||||
"files": [ ... ]
|
||||
}
|
||||
]
|
||||
],
|
||||
"exclude": [".packages"]
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
EXCLUDE_DIRS = {".packages", ".git", ".cursor"}
|
||||
EXCLUDE_FILES = {"index.org", "all-files.org"}
|
||||
MOC_PINNED_TITLES = {"brain moc"}
|
||||
PINNED_URL_SUFFIX = "brain_moc.html"
|
||||
|
||||
|
||||
def should_skip(file_path: Path, org_dir: Path) -> bool:
|
||||
"""Return True if this org file should be excluded from the sidebar."""
|
||||
try:
|
||||
relative = file_path.relative_to(org_dir)
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
if file_path.name in EXCLUDE_FILES:
|
||||
return True
|
||||
|
||||
for part in relative.parts[:-1]:
|
||||
if part in EXCLUDE_DIRS or part.startswith("."):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
"""Extract title, filetags, and id from an Org file."""
|
||||
title_pattern = re.compile(r"^#\+title:\s*(.+)", re.IGNORECASE | re.MULTILINE)
|
||||
tags_pattern = re.compile(r"^#\+filetags:\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)
|
||||
title_match = title_pattern.search(content)
|
||||
tags_match = tags_pattern.search(content)
|
||||
id_match = id_pattern.search(content)
|
||||
|
||||
tags_raw = tags_match.group(1).strip() if tags_match else ""
|
||||
tags = {t.strip(":").lower() for t in re.findall(r":[^:\s]+:", tags_raw)}
|
||||
|
||||
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,
|
||||
"title": title_match.group(1).strip() if title_match else "Untitled",
|
||||
"tags": tags,
|
||||
"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...")
|
||||
def is_moc(meta: dict) -> bool:
|
||||
return "moc" in meta.get("tags", set())
|
||||
|
||||
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))
|
||||
]
|
||||
def build_mocs(org_dir: Path) -> list[dict]:
|
||||
"""Collect MOC notes tagged with :moc:."""
|
||||
mocs = []
|
||||
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
|
||||
for file_path in org_files:
|
||||
meta = get_org_metadata(file_path)
|
||||
if not meta:
|
||||
for file_path in org_dir.rglob("*.org"):
|
||||
if re.search(r"/\.#[^/]+\.org$", str(file_path)):
|
||||
continue
|
||||
if should_skip(file_path, org_dir):
|
||||
continue
|
||||
|
||||
meta = get_org_metadata(file_path)
|
||||
if not meta or not is_moc(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",
|
||||
}
|
||||
url = f"/{relative}.html"
|
||||
title = meta["title"]
|
||||
pinned = (
|
||||
title.strip().lower() in MOC_PINNED_TITLES
|
||||
or url.endswith(PINNED_URL_SUFFIX)
|
||||
)
|
||||
mocs.append({"title": title, "url": url, "pinned": pinned})
|
||||
|
||||
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}")
|
||||
mocs.sort(key=lambda m: (not m["pinned"], m["title"].lower()))
|
||||
return mocs
|
||||
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -135,8 +116,6 @@ def build_tree(org_dir: Path) -> list:
|
||||
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": []}
|
||||
@@ -148,13 +127,14 @@ def build_tree(org_dir: Path) -> list:
|
||||
insert_node(node["children"], parts[1:], entry)
|
||||
|
||||
tree = []
|
||||
root_files = []
|
||||
|
||||
org_files = [
|
||||
f for f in org_dir.rglob("*.org")
|
||||
if not re.search(r"/\.#[^/]+\.org$", str(f))
|
||||
]
|
||||
for file_path in org_dir.rglob("*.org"):
|
||||
if re.search(r"/\.#[^/]+\.org$", str(file_path)):
|
||||
continue
|
||||
if should_skip(file_path, org_dir):
|
||||
continue
|
||||
|
||||
for file_path in org_files:
|
||||
meta = get_org_metadata(file_path)
|
||||
if not meta:
|
||||
continue
|
||||
@@ -169,10 +149,16 @@ def build_tree(org_dir: Path) -> list:
|
||||
"url": f"/{relative.with_suffix('')}.html",
|
||||
}
|
||||
|
||||
folder_parts = relative.parts[:-1] # ignore filename
|
||||
insert_node(tree, list(folder_parts), entry)
|
||||
folder_parts = relative.parts[:-1]
|
||||
if not folder_parts:
|
||||
root_files.append(entry)
|
||||
else:
|
||||
insert_node(tree, list(folder_parts), entry)
|
||||
|
||||
if root_files:
|
||||
root_files.sort(key=lambda f: f["title"].lower())
|
||||
tree.insert(0, {"label": "Garden", "children": [], "files": root_files})
|
||||
|
||||
# Sort everything
|
||||
def sort_tree(nodes):
|
||||
nodes.sort(key=lambda n: n["label"].lower())
|
||||
for node in nodes:
|
||||
@@ -180,14 +166,54 @@ def build_tree(org_dir: Path) -> list:
|
||||
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"
|
||||
|
||||
def count_nodes(nodes):
|
||||
total_files = 0
|
||||
total_folders = 0
|
||||
|
||||
if not nodes:
|
||||
return 0, 0
|
||||
|
||||
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 generate_sidebar_tree(org_dir: Path, out_file: Path) -> None:
|
||||
"""Generate sidebar-tree.json with MOC hubs and folder tree."""
|
||||
print(" Generating sidebar-tree.json...")
|
||||
|
||||
org_dir = org_dir.expanduser().resolve()
|
||||
out_file = out_file.expanduser()
|
||||
|
||||
output = {
|
||||
"mocs": build_mocs(org_dir),
|
||||
"tree": build_tree(org_dir),
|
||||
"exclude": sorted(EXCLUDE_DIRS),
|
||||
}
|
||||
|
||||
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 "
|
||||
f"({len(output['mocs'])} mocs, {folder_count} folders, {files_count} files)"
|
||||
)
|
||||
print(f" → {out_file}")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user