Add slug-based URLs for notes

This commit is contained in:
2026-08-04 09:30:47 -04:00
parent 99ffddc078
commit 7817336754
2 changed files with 104 additions and 33 deletions

View File

@ -24,7 +24,7 @@ The app is implemented as a single Python server (`main.py`) plus one HTML templ
## How's it work? ## How's it work?
1. `main.py` starts an HTTP server. 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/<slug>`), it rescans the notes directory for `.org` files.
3. It resolves links/backlinks and builds HTML fragments. 3. It resolves links/backlinks and builds HTML fragments.
4. It injects those fragments into `templates/index.html` placeholders: 4. It injects those fragments into `templates/index.html` placeholders:
- `{{NAV_ITEMS}}` - `{{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`) ## Server-side components (`main.py`)
- URLs:
- `/` renders the index (defaults to the most recent note).
- `/n/<slug>` renders a single note, where `<slug>` is the note's `:ID:` value when present, otherwise its relative path (percent-encoded as one segment, e.g. `/n/sub%2Fnote.org`).
- `/n/<slug>?edit=1` switches to edit mode for that note.
- `/n/<slug>/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: - File discovery and parsing:
- `scan_org_files(...)` recursively finds `.org` files. - `scan_org_files(...)` recursively finds `.org` files.
- Extracts title from `#+TITLE:` and ID from `:ID:`. - Extracts title from `#+TITLE:` and ID from `:ID:`.

129
main.py
View File

@ -15,7 +15,7 @@ from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path from pathlib import Path
from typing import Iterable 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 from urllib.request import Request, urlopen
@ -246,11 +246,12 @@ def extract_created_sort_key(content: str) -> int | None:
return None return None
def note_href(relative_path: str, edit_mode: bool) -> str: def note_href(relative_path: str, edit_mode: bool, path_to_slug: dict[str, str]) -> str:
params = {"file": relative_path} slug = path_to_slug.get(relative_path, relative_path)
href = "/n/" + quote(slug, safe="")
if edit_mode: if edit_mode:
params["edit"] = "1" href += "?edit=1"
return "/?" + urlencode(params) return href
def truncate_label(text: str, max_chars: int = 32) -> str: 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( 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: ) -> str:
rendered_parts: list[str] = [] rendered_parts: list[str] = []
cursor = 0 cursor = 0
@ -384,7 +386,7 @@ def render_line_with_links(
label = (match.group(2) or "").strip() label = (match.group(2) or "").strip()
resolved = resolve_link_target(source_relative_path, raw_target, known_paths, id_to_path) resolved = resolve_link_target(source_relative_path, raw_target, known_paths, id_to_path)
if resolved: 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 link_text = label if label else raw_target
rendered_parts.append(f"<a class='org-link' href='{safe_href}'>{html.escape(link_text)}</a>") rendered_parts.append(f"<a class='org-link' href='{safe_href}'>{html.escape(link_text)}</a>")
else: else:
@ -400,6 +402,7 @@ def render_line_with_links(
def render_org_to_html( def render_org_to_html(
content: str, source_relative_path: str, known_paths: set[str], id_to_path: dict[str, str], 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, show_webhook: bool = False,
) -> str: ) -> str:
"""Very small org-ish renderer: headings become section titles, body keeps line breaks.""" """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: if not stripped:
html_lines.append("<div class='spacer'></div>") html_lines.append("<div class='spacer'></div>")
else: 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"<p>{rendered_line}</p>") html_lines.append(f"<p>{rendered_line}</p>")
return "\n".join(html_lines) return "\n".join(html_lines)
@ -488,6 +491,39 @@ def find_org_file(org_files: Iterable[OrgFile], relative_path: str | None) -> Or
return None 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+(.*)$") _HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
_ORG_TAG_RE = re.compile(r"\s+:([\w@:]+):\s*$") _ORG_TAG_RE = re.compile(r"\s+:([\w@:]+):\s*$")
_DRAWER_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] selected = find_org_file(org_files, selected_path) or org_files[0]
known_paths = {f.relative_path for f in org_files} 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} 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) backlinks = find_backlinks(org_files, selected.relative_path)
backlink_counts = build_backlink_counts(org_files) backlink_counts = build_backlink_counts(org_files)
nav_items = [] nav_items = []
for f in org_files: for f in org_files:
active = "active" if f.relative_path == selected.relative_path else "" 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_title = html.escape(truncate_label(f.title))
safe_path = html.escape(truncate_label(f.relative_path)) safe_path = html.escape(truncate_label(f.relative_path))
full_title = html.escape(f.title, quote=True) full_title = html.escape(f.title, quote=True)
@ -749,9 +786,9 @@ def build_index_page(
nav = "\n".join(nav_items) nav = "\n".join(nav_items)
mode_toggle = ( mode_toggle = (
f"<a class='mode-link' href='{html.escape(note_href(selected.relative_path, False), quote=True)}'>Preview</a>" f"<a class='mode-link' href='{html.escape(note_href(selected.relative_path, False, path_to_slug), quote=True)}'>Preview</a>"
if edit_mode if edit_mode
else f"<a class='mode-link' href='{html.escape(note_href(selected.relative_path, True), quote=True)}'>Edit</a>" else f"<a class='mode-link' href='{html.escape(note_href(selected.relative_path, True, path_to_slug), quote=True)}'>Edit</a>"
) )
status_html = "" status_html = ""
if status_message: if status_message:
@ -762,7 +799,7 @@ def build_index_page(
if backlinks: if backlinks:
backlink_items = [] backlink_items = []
for source in backlinks: 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_title = html.escape(truncate_label(source.title))
safe_path = html.escape(truncate_label(source.relative_path)) safe_path = html.escape(truncate_label(source.relative_path))
full_title = html.escape(source.title, quote=True) full_title = html.escape(source.title, quote=True)
@ -801,7 +838,7 @@ def build_index_page(
f"<div class='toolbar'>{mode_toggle}</div>" f"<div class='toolbar'>{mode_toggle}</div>"
f"{status_html}" f"{status_html}"
f"{capture_form}" f"{capture_form}"
f"<article class='org-content'>{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, show_webhook=show_webhook)}</article>" f"<article class='org-content'>{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, path_to_slug, show_webhook=show_webhook)}</article>"
) )
template = TEMPLATE_PATH.read_text(encoding="utf-8") 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 = "<p>No .org files found.</p>" items_html = "<p>No .org files found.</p>"
else: else:
selected = find_org_file(org_files, selected_path) or org_files[0] 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) backlinks = find_backlinks(org_files, selected.relative_path)
if backlinks: if backlinks:
backlink_items = [] backlink_items = []
for source in backlinks: 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_title = html.escape(source.title)
safe_path = html.escape(source.relative_path) safe_path = html.escape(source.relative_path)
full_title = html.escape(source.title, quote=True) 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): class OrgRequestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: def do_GET(self) -> None:
parsed = urlparse(self.path) parsed = urlparse(self.path)
path = parsed.path
if parsed.path.startswith("/static/"): if path.startswith("/static/"):
self.serve_static(parsed.path) self.serve_static(path)
return return
if parsed.path == "/sw.js": if path == "/sw.js":
self.serve_service_worker() self.serve_service_worker()
return return
if parsed.path != "/": org_files = scan_org_files(base_dir)
self.send_error(404, "Not found")
return
params = parse_qs(parsed.query) 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: backlinks_match = re.fullmatch(r"/n/([^/]+)/backlinks", path)
org_files = scan_org_files(base_dir) if backlinks_match:
html_page = build_backlinks_page(org_files, selected_path=selected_path) _, 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") encoded = html_page.encode("utf-8")
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8") 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) self.wfile.write(encoded)
return 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" edit_mode = params.get("edit", ["0"])[0] == "1"
saved = params.get("saved", ["0"])[0] == "1" saved = params.get("saved", ["0"])[0] == "1"
error = params.get("error", [""])[0] 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_message = "Could not write this file."
status_level = "error" status_level = "error"
org_files = scan_org_files(base_dir)
html_page = build_index_page( html_page = build_index_page(
org_files, org_files,
selected_path=selected_path, 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] new_content = params.get("content", [""])[0]
org_files = scan_org_files(base_dir) org_files = scan_org_files(base_dir)
path_to_slug, _, _ = build_slugs(org_files)
selected = find_org_file(org_files, selected_path) selected = find_org_file(org_files, selected_path)
if selected is None: if selected is None:
self.redirect_with_query("/", {"edit": "1", "error": "missing"}) 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: try:
selected.path.write_text(new_content, encoding="utf-8") selected.path.write_text(new_content, encoding="utf-8")
except OSError: 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 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: def handle_capture(self) -> None:
content_length = int(self.headers.get("Content-Length", "0")) 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() text = params.get("text", [""])[0].strip()
org_files = scan_org_files(base_dir) org_files = scan_org_files(base_dir)
path_to_slug, _, _ = build_slugs(org_files)
selected = find_org_file(org_files, selected_path) selected = find_org_file(org_files, selected_path)
if selected is None: if selected is None:
self.redirect_with_query("/", {"error": "missing"}) self.redirect_with_query("/", {"error": "missing"})
return return
if not text: 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 return
updated = add_to_inbox(selected.content, text) updated = add_to_inbox(selected.content, text)
try: try:
selected.path.write_text(updated, encoding="utf-8") selected.path.write_text(updated, encoding="utf-8")
except OSError: 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 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: def handle_webhook(self) -> None:
if not webhook_url: 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.send_header("Content-Length", "0")
self.end_headers() 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: def serve_static(self, request_path: str) -> None:
rel_path = request_path[len("/static/") :] rel_path = request_path[len("/static/") :]
candidate = (STATIC_DIR / rel_path).resolve() candidate = (STATIC_DIR / rel_path).resolve()