Add slug-based URLs for notes
This commit is contained in:
129
main.py
129
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"<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()
|
||||
|
||||
Reference in New Issue
Block a user