222 lines
5.9 KiB
Python
Executable File
222 lines
5.9 KiB
Python
Executable File
"""
|
|
generate_sidebar_tree.py
|
|
|
|
Generates sidebar-tree.json from the org-roam directory.
|
|
|
|
Output shape:
|
|
{
|
|
"mocs": [
|
|
{ "title": "Brain MOC", "url": "/20241210233721-brain_moc.html", "pinned": true },
|
|
...
|
|
],
|
|
"tree": [
|
|
{
|
|
"label": "Articles",
|
|
"children": [],
|
|
"files": [{ "title": "...", "url": "/Articles/....html" }]
|
|
},
|
|
...
|
|
],
|
|
"exclude": [".packages"]
|
|
}
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
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, 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)
|
|
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",
|
|
"tags": tags,
|
|
"id": id_match.group(1).strip() if id_match else None,
|
|
}
|
|
|
|
|
|
def is_moc(meta: dict) -> bool:
|
|
return "moc" in meta.get("tags", set())
|
|
|
|
|
|
def build_mocs(org_dir: Path) -> list[dict]:
|
|
"""Collect MOC notes tagged with :moc:."""
|
|
mocs = []
|
|
|
|
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
|
|
|
|
try:
|
|
relative = file_path.relative_to(org_dir).with_suffix("")
|
|
except ValueError:
|
|
continue
|
|
|
|
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})
|
|
|
|
mocs.sort(key=lambda m: (not m["pinned"], m["title"].lower()))
|
|
return mocs
|
|
|
|
|
|
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]
|
|
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 = []
|
|
root_files = []
|
|
|
|
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:
|
|
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]
|
|
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})
|
|
|
|
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 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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|