Utensil's Zettelkasten-style forest of evergreen notes on math and tech. utensil.tngl.sh/forest/
0

Configure Feed

Select the types of activity you want to include in your feed.

forest / port_posts.py
3.9 kB 149 lines
1#!/usr/bin/env -S uv run 2# /// script 3# requires-python = ">=3.11" 4# dependencies = [] 5# /// 6 7""" 8Port forest post nodes (mdnote) to garden Markdown files in content/posts/. 9Handles both plain mdnote{title}{content} and mdnote{title}{verb>>>|content>>>} forms. 10""" 11 12import re 13from pathlib import Path 14 15 16NODES = [ 17 "uts-0167", 18 "uts-016A", 19 "uts-016B", 20 "uts-016C", 21 "uts-016E", 22 "uts-016J", 23 "uts-016K", 24 "uts-016L", 25 # uts-016M skipped: jj.md already exists in garden with complete content 26 "uts-016N", 27 "uts-016O", 28 "uts-016P", 29] 30 31 32def slugify(title: str, max_words: int = 6) -> str: 33 title = title.lower() 34 title = re.sub(r"[^\w\s-]", "", title) 35 title = re.sub(r"\s+", "-", title.strip()) 36 title = re.sub(r"-+", "-", title).strip("-") 37 parts = title.split("-") 38 if len(parts) > max_words: 39 title = "-".join(parts[:max_words]) 40 return title or "untitled" 41 42 43def extract_mdnote(text: str) -> tuple[str, str]: 44 m = re.search(r"\\mdnote\{([^}]+)\}\{", text) 45 if not m: 46 return "", "" 47 48 title = m.group(1) 49 rest = text[m.end():] 50 51 # verb-wrapped: \verb>>>|...\n>>>} 52 verb_m = re.match(r"\\verb>>>\|(.*?)>>>\}", rest, re.DOTALL) 53 if verb_m: 54 return title, verb_m.group(1).strip() 55 56 # plain content: brace-balanced extraction 57 depth = 1 58 i = 0 59 while i < len(rest) and depth > 0: 60 if rest[i] == "{": 61 depth += 1 62 elif rest[i] == "}": 63 depth -= 1 64 i += 1 65 66 return title, rest[: i - 1].strip() 67 68 69def parse_tags(text: str) -> list[str]: 70 return re.findall(r"\\tag\{([^}]+)\}", text) 71 72 73def parse_meta(text: str, key: str) -> str: 74 m = re.search(rf"\\meta\{{{re.escape(key)}\}}\{{([^}}]+)\}}", text) 75 return m.group(1) if m else "" 76 77 78def parse_field(text: str, field: str) -> str: 79 m = re.search(rf"\\{field}\{{([^}}]+)\}}", text) 80 return m.group(1) if m else "" 81 82 83def build_frontmatter(title: str, date: str, tags: list[str], aliases: str, external: str) -> str: 84 lines = ["---", f"title: {title}"] 85 if date: 86 lines.append(f"date: {date}") 87 if len(tags) == 1: 88 lines.append(f"tag: {tags[0]}") 89 elif tags: 90 lines.append("tags:") 91 for t in tags: 92 lines.append(f" - {t}") 93 lines.append(f"aliases: [{aliases}]") 94 lines.append(f'source: "https://utensil.github.io/forest/{aliases}/"') 95 if external: 96 lines.append(f'external: "{external}"') 97 lines.append("---") 98 return "\n".join(lines) 99 100 101def convert(tree_id: str, tree_text: str) -> tuple[str, str]: 102 title, content = extract_mdnote(tree_text) 103 if not title: 104 return "", "" 105 106 date = parse_field(tree_text, "date") 107 tags = parse_tags(tree_text) 108 external = parse_meta(tree_text, "external") 109 110 slug = slugify(title) 111 fm = build_frontmatter(title, date, tags, tree_id, external) 112 return slug, f"{fm}\n\n{content}\n" 113 114 115def main() -> None: 116 trees_dir = Path("/Users/utensil/projects/forest/trees") 117 output_dir = Path("/Users/utensil/projects/garden-posts-port/content/posts") 118 output_dir.mkdir(parents=True, exist_ok=True) 119 120 used_slugs: dict[str, str] = {} 121 converted = 0 122 123 for tree_id in NODES: 124 tree_file = trees_dir / f"{tree_id}.tree" 125 if not tree_file.exists(): 126 print(f" missing: {tree_id}") 127 continue 128 129 text = tree_file.read_text() 130 slug, md = convert(tree_id, text) 131 if not slug: 132 print(f" no mdnote: {tree_id}") 133 continue 134 135 base_slug = slug 136 if slug in used_slugs: 137 slug = f"{base_slug}-{tree_id[-3:]}" 138 used_slugs[slug] = tree_id 139 140 out = output_dir / f"{slug}.md" 141 out.write_text(md) 142 print(f" {tree_id} → posts/{slug}.md") 143 converted += 1 144 145 print(f"\nConverted {converted} nodes → {output_dir}") 146 147 148if __name__ == "__main__": 149 main()