From 78173367548f2b4723e87762ad7e09337ab03610 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Tue, 4 Aug 2026 09:30:47 -0400 Subject: [PATCH] Add slug-based URLs for notes --- README.md | 8 +++- main.py | 129 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 104 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8aaccf6..743bcc9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The app is implemented as a single Python server (`main.py`) plus one HTML templ ## How's it work? 1. `main.py` starts an HTTP server. -2. On each page request (`GET /`), it rescans the notes directory for `.org` files. +2. On each page request (e.g. `GET /` or `GET /n/`), it rescans the notes directory for `.org` files. 3. It resolves links/backlinks and builds HTML fragments. 4. It injects those fragments into `templates/index.html` placeholders: - `{{NAV_ITEMS}}` @@ -34,6 +34,12 @@ The app is implemented as a single Python server (`main.py`) plus one HTML templ ## Server-side components (`main.py`) +- URLs: + - `/` renders the index (defaults to the most recent note). + - `/n/` renders a single note, where `` is the note's `:ID:` value when present, otherwise its relative path (percent-encoded as one segment, e.g. `/n/sub%2Fnote.org`). + - `/n/?edit=1` switches to edit mode for that note. + - `/n//backlinks` returns a standalone backlinks pane for embedding. + - `POST /capture`, `POST /edit`, and `POST /webhook` handle their respective actions and redirect back to the note's URL. - File discovery and parsing: - `scan_org_files(...)` recursively finds `.org` files. - Extracts title from `#+TITLE:` and ID from `:ID:`. diff --git a/main.py b/main.py index af83411..15e13ec 100644 --- a/main.py +++ b/main.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from typing import Iterable -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse from urllib.request import Request, urlopen @@ -246,11 +246,12 @@ def extract_created_sort_key(content: str) -> int | None: return None -def note_href(relative_path: str, edit_mode: bool) -> str: - params = {"file": relative_path} +def note_href(relative_path: str, edit_mode: bool, path_to_slug: dict[str, str]) -> str: + slug = path_to_slug.get(relative_path, relative_path) + href = "/n/" + quote(slug, safe="") if edit_mode: - params["edit"] = "1" - return "/?" + urlencode(params) + href += "?edit=1" + return href def truncate_label(text: str, max_chars: int = 32) -> str: @@ -370,7 +371,8 @@ def _render_heading_with_todo(heading_text: str) -> tuple[str, str | None, str, def render_line_with_links( - line: str, source_relative_path: str, known_paths: set[str], id_to_path: dict[str, str] + line: str, source_relative_path: str, known_paths: set[str], id_to_path: dict[str, str], + path_to_slug: dict[str, str], ) -> str: rendered_parts: list[str] = [] cursor = 0 @@ -384,7 +386,7 @@ def render_line_with_links( label = (match.group(2) or "").strip() resolved = resolve_link_target(source_relative_path, raw_target, known_paths, id_to_path) if resolved: - safe_href = html.escape(note_href(resolved, False), quote=True) + safe_href = html.escape(note_href(resolved, False, path_to_slug), quote=True) link_text = label if label else raw_target rendered_parts.append(f"{html.escape(link_text)}") else: @@ -400,6 +402,7 @@ def render_line_with_links( def render_org_to_html( content: str, source_relative_path: str, known_paths: set[str], id_to_path: dict[str, str], + path_to_slug: dict[str, str], show_webhook: bool = False, ) -> str: """Very small org-ish renderer: headings become section titles, body keeps line breaks.""" @@ -473,7 +476,7 @@ def render_org_to_html( if not stripped: html_lines.append("
") else: - rendered_line = render_line_with_links(line, source_relative_path, known_paths, id_to_path) + rendered_line = render_line_with_links(line, source_relative_path, known_paths, id_to_path, path_to_slug) html_lines.append(f"

{rendered_line}

") return "\n".join(html_lines) @@ -488,6 +491,39 @@ def find_org_file(org_files: Iterable[OrgFile], relative_path: str | None) -> Or return None +def build_slugs(org_files: Iterable[OrgFile]) -> tuple[dict[str, str], dict[str, str], dict[str, str]]: + """Return (path_to_slug, slug_to_path, id_lower_to_path) maps. + + Slugs prefer the file's :ID: value; files without an ID (or with a + duplicate ID) fall back to their relative path. id_lower_to_path maps + lowercased IDs to paths for case-insensitive lookup. + """ + path_to_slug: dict[str, str] = {} + slug_to_path: dict[str, str] = {} + id_lower_to_path: dict[str, str] = {} + used_lower: set[str] = set() + for f in org_files: + if f.file_id and f.file_id.lower() not in used_lower: + slug = f.file_id + else: + slug = f.relative_path + if slug in slug_to_path: + continue + used_lower.add(slug.lower()) + path_to_slug[f.relative_path] = slug + slug_to_path[slug] = f.relative_path + if f.file_id: + id_lower_to_path[f.file_id.lower()] = f.relative_path + return path_to_slug, slug_to_path, id_lower_to_path + + +def resolve_slug(slug: str, slug_to_path: dict[str, str], id_lower_to_path: dict[str, str]) -> str | None: + """Resolve a URL slug back to a relative path (exact, then case-insensitive ID).""" + if slug in slug_to_path: + return slug_to_path[slug] + return id_lower_to_path.get(slug.lower()) + + _HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") _ORG_TAG_RE = re.compile(r"\s+:([\w@:]+):\s*$") _DRAWER_RE = re.compile(r"^\s*:(\w+):\s*$") @@ -716,13 +752,14 @@ def build_index_page( selected = find_org_file(org_files, selected_path) or org_files[0] known_paths = {f.relative_path for f in org_files} id_to_path = {f.file_id.lower(): f.relative_path for f in org_files if f.file_id} + path_to_slug, _, _ = build_slugs(org_files) backlinks = find_backlinks(org_files, selected.relative_path) backlink_counts = build_backlink_counts(org_files) nav_items = [] for f in org_files: active = "active" if f.relative_path == selected.relative_path else "" - safe_href = html.escape(note_href(f.relative_path, False), quote=True) + safe_href = html.escape(note_href(f.relative_path, False, path_to_slug), quote=True) safe_title = html.escape(truncate_label(f.title)) safe_path = html.escape(truncate_label(f.relative_path)) full_title = html.escape(f.title, quote=True) @@ -749,9 +786,9 @@ def build_index_page( nav = "\n".join(nav_items) mode_toggle = ( - f"Preview" + f"Preview" if edit_mode - else f"Edit" + else f"Edit" ) status_html = "" if status_message: @@ -762,7 +799,7 @@ def build_index_page( if backlinks: backlink_items = [] for source in backlinks: - safe_href = html.escape(note_href(source.relative_path, edit_mode), quote=True) + safe_href = html.escape(note_href(source.relative_path, edit_mode, path_to_slug), quote=True) safe_title = html.escape(truncate_label(source.title)) safe_path = html.escape(truncate_label(source.relative_path)) full_title = html.escape(source.title, quote=True) @@ -801,7 +838,7 @@ def build_index_page( f"
{mode_toggle}
" f"{status_html}" f"{capture_form}" - f"
{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, show_webhook=show_webhook)}
" + f"
{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, path_to_slug, show_webhook=show_webhook)}
" ) template = TEMPLATE_PATH.read_text(encoding="utf-8") @@ -819,11 +856,12 @@ def build_backlinks_page(org_files: Iterable[OrgFile], selected_path: str | None items_html = "

No .org files found.

" else: selected = find_org_file(org_files, selected_path) or org_files[0] + path_to_slug, _, _ = build_slugs(org_files) backlinks = find_backlinks(org_files, selected.relative_path) if backlinks: backlink_items = [] for source in backlinks: - safe_href = html.escape(note_href(source.relative_path, False), quote=True) + safe_href = html.escape(note_href(source.relative_path, False, path_to_slug), quote=True) safe_title = html.escape(source.title) safe_path = html.escape(source.relative_path) full_title = html.escape(source.title, quote=True) @@ -861,26 +899,27 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "") class OrgRequestHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: parsed = urlparse(self.path) + path = parsed.path - if parsed.path.startswith("/static/"): - self.serve_static(parsed.path) + if path.startswith("/static/"): + self.serve_static(path) return - if parsed.path == "/sw.js": + if path == "/sw.js": self.serve_service_worker() return - if parsed.path != "/": - self.send_error(404, "Not found") - return - + org_files = scan_org_files(base_dir) params = parse_qs(parsed.query) - selected_path = params.get("file", [None])[0] - embed_backlinks = params.get("embed_backlinks", ["0"])[0] in ("1", "true", "yes") - if embed_backlinks: - org_files = scan_org_files(base_dir) - html_page = build_backlinks_page(org_files, selected_path=selected_path) + backlinks_match = re.fullmatch(r"/n/([^/]+)/backlinks", path) + if backlinks_match: + _, slug_to_path, id_lower_to_path = build_slugs(org_files) + resolved = resolve_slug(unquote(backlinks_match.group(1)), slug_to_path, id_lower_to_path) + if resolved is None: + self.send_error(404, "Not found") + return + html_page = build_backlinks_page(org_files, selected_path=resolved) encoded = html_page.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") @@ -889,6 +928,23 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "") self.wfile.write(encoded) return + note_match = re.fullmatch(r"/n/([^/]+)", path) + if note_match: + _, slug_to_path, id_lower_to_path = build_slugs(org_files) + resolved = resolve_slug(unquote(note_match.group(1)), slug_to_path, id_lower_to_path) + if resolved is None: + self.send_error(404, "Not found") + return + self.render_index(org_files, params, selected_path=resolved) + return + + if path == "/": + self.render_index(org_files, params, selected_path=None) + return + + self.send_error(404, "Not found") + + def render_index(self, org_files: list[OrgFile], params: dict[str, list[str]], selected_path: str | None) -> None: edit_mode = params.get("edit", ["0"])[0] == "1" saved = params.get("saved", ["0"])[0] == "1" error = params.get("error", [""])[0] @@ -906,7 +962,6 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "") status_message = "Could not write this file." status_level = "error" - org_files = scan_org_files(base_dir) html_page = build_index_page( org_files, selected_path=selected_path, @@ -942,6 +997,7 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "") new_content = params.get("content", [""])[0] org_files = scan_org_files(base_dir) + path_to_slug, _, _ = build_slugs(org_files) selected = find_org_file(org_files, selected_path) if selected is None: self.redirect_with_query("/", {"edit": "1", "error": "missing"}) @@ -950,10 +1006,10 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "") try: selected.path.write_text(new_content, encoding="utf-8") except OSError: - self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "error": "write"}) + self.redirect_to_note(path_to_slug, selected.relative_path, {"edit": "1", "error": "write"}) return - self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "saved": "1"}) + self.redirect_to_note(path_to_slug, selected.relative_path, {"edit": "1", "saved": "1"}) def handle_capture(self) -> None: content_length = int(self.headers.get("Content-Length", "0")) @@ -963,22 +1019,23 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "") text = params.get("text", [""])[0].strip() org_files = scan_org_files(base_dir) + path_to_slug, _, _ = build_slugs(org_files) selected = find_org_file(org_files, selected_path) if selected is None: self.redirect_with_query("/", {"error": "missing"}) return if not text: - self.redirect_with_query("/", {"file": selected.relative_path, "error": "empty"}) + self.redirect_to_note(path_to_slug, selected.relative_path, {"error": "empty"}) return updated = add_to_inbox(selected.content, text) try: selected.path.write_text(updated, encoding="utf-8") except OSError: - self.redirect_with_query("/", {"file": selected.relative_path, "error": "write"}) + self.redirect_to_note(path_to_slug, selected.relative_path, {"error": "write"}) return - self.redirect_with_query("/", {"file": selected.relative_path, "saved": "1"}) + self.redirect_to_note(path_to_slug, selected.relative_path, {"saved": "1"}) def handle_webhook(self) -> None: if not webhook_url: @@ -1057,6 +1114,14 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "") self.send_header("Content-Length", "0") self.end_headers() + def redirect_to_note(self, path_to_slug: dict[str, str], relative_path: str, params: dict[str, str]) -> None: + slug = path_to_slug.get(relative_path, relative_path) + location = f"/n/{quote(slug, safe='')}?{urlencode(params)}" + self.send_response(303) + self.send_header("Location", location) + self.send_header("Content-Length", "0") + self.end_headers() + def serve_static(self, request_path: str) -> None: rel_path = request_path[len("/static/") :] candidate = (STATIC_DIR / rel_path).resolve()