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