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

129
main.py
View File

@ -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"<a class='org-link' href='{safe_href}'>{html.escape(link_text)}</a>")
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("<div class='spacer'></div>")
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>")
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"<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
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 = ""
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"<div class='toolbar'>{mode_toggle}</div>"
f"{status_html}"
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")
@ -819,11 +856,12 @@ def build_backlinks_page(org_files: Iterable[OrgFile], selected_path: str | None
items_html = "<p>No .org files found.</p>"
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()