#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import mimetypes
import os
import re
import socketserver
import threading
import webbrowser
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Iterable
from urllib.parse import parse_qs, urlencode, urlparse
@dataclass
class OrgFile:
path: Path
relative_path: str
title: str
file_id: str | None
content: str
ROOT_DIR = Path(__file__).resolve().parent
TEMPLATE_PATH = ROOT_DIR / "templates" / "index.html"
STATIC_DIR = ROOT_DIR / "static"
TITLE_RE = re.compile(r"^\s*#\+TITLE:\s*(.+?)\s*$", flags=re.IGNORECASE)
ID_RE = re.compile(r"^\s*:ID:\s*(\S+)\s*$", flags=re.IGNORECASE)
ORG_LINK_RE = re.compile(r"\[\[([^\]]+)\](?:\[([^\]]*)\])?\]")
URL_RE = re.compile(r"https?://[^\s<>'\"()\[\]]+")
CREATED_LINE_RE = re.compile(r"^\s*(?:#\+)?CREATED:\s*(.+?)\s*$", flags=re.IGNORECASE)
ORG_TIMESTAMP_RE = re.compile(r"[\[<](\d{4})-(\d{2})-(\d{2})(?:\s+\w{3})?(?:\s+(\d{2}):(\d{2}))?[\]>]")
DEFAULT_CONFIG_PATH = ROOT_DIR / "config.yaml"
def scan_org_files(base_dir: Path) -> list[OrgFile]:
"""Recursively find .org files under base_dir and return their contents."""
org_files: list[OrgFile] = []
for root, dirs, files in os.walk(base_dir):
dirs[:] = [d for d in dirs if d not in {".git", ".venv", "venv", "__pycache__"}]
root_path = Path(root)
for filename in files:
if filename.lower().endswith(".org"):
file_path = root_path / filename
try:
content = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = file_path.read_text(encoding="utf-8", errors="replace")
org_files.append(
OrgFile(
path=file_path,
relative_path=normalize_relative_path(str(file_path.relative_to(base_dir))),
title=extract_org_title(content, file_path.stem),
file_id=extract_org_id(content),
content=content,
)
)
org_files.sort(key=lambda f: f.relative_path.lower())
return org_files
def extract_org_title(content: str, fallback: str) -> str:
for line in content.splitlines():
match = TITLE_RE.match(line)
if match:
return match.group(1).strip()
return fallback
def extract_org_id(content: str) -> str | None:
for line in content.splitlines():
match = ID_RE.match(line)
if match:
return match.group(1).strip()
return None
def normalize_relative_path(path_value: str) -> str:
normalized = os.path.normpath(path_value.replace("\\", "/"))
if normalized == ".":
return ""
return normalized.lstrip("./")
def extract_org_link_targets(content: str) -> list[str]:
targets: list[str] = []
for match in ORG_LINK_RE.finditer(content):
targets.append(match.group(1).strip())
return targets
def resolve_link_target(
source_relative_path: str,
raw_target: str,
known_paths: set[str],
id_to_path: dict[str, str],
) -> str | None:
target = raw_target.strip()
if not target:
return None
if "::" in target:
target = target.split("::", 1)[0]
if "#" in target:
target = target.split("#", 1)[0]
if target.lower().startswith("id:"):
id_value = target[3:].strip().lower()
return id_to_path.get(id_value)
if target.startswith("file:"):
target = target[5:]
elif "://" in target:
return None
elif ":" in target and not target.endswith(".org"):
return None
source_dir = os.path.dirname(source_relative_path)
if target.startswith("/"):
candidate = normalize_relative_path(target.lstrip("/"))
else:
candidate = normalize_relative_path(os.path.join(source_dir, target))
if not candidate:
return None
if candidate in known_paths:
return candidate
if not candidate.endswith(".org"):
with_suffix = f"{candidate}.org"
if with_suffix in known_paths:
return with_suffix
return None
def find_backlinks(org_files: Iterable[OrgFile], selected_path: str) -> list[OrgFile]:
files = list(org_files)
known_paths = {f.relative_path for f in files}
id_to_path = {f.file_id.lower(): f.relative_path for f in files if f.file_id}
backlinks: list[OrgFile] = []
seen_sources: set[str] = set()
for source in files:
if source.relative_path == selected_path:
continue
link_targets = extract_org_link_targets(source.content)
for raw_target in link_targets:
resolved = resolve_link_target(source.relative_path, raw_target, known_paths, id_to_path)
if resolved == selected_path and source.relative_path not in seen_sources:
backlinks.append(source)
seen_sources.add(source.relative_path)
break
backlinks.sort(key=lambda f: (f.title.lower(), f.relative_path.lower()))
return backlinks
def build_backlink_counts(org_files: Iterable[OrgFile]) -> dict[str, int]:
files = list(org_files)
known_paths = {f.relative_path for f in files}
id_to_path = {f.file_id.lower(): f.relative_path for f in files if f.file_id}
counts = {f.relative_path: 0 for f in files}
for source in files:
seen_targets: set[str] = set()
for raw_target in extract_org_link_targets(source.content):
resolved = resolve_link_target(source.relative_path, raw_target, known_paths, id_to_path)
if not resolved or resolved == source.relative_path or resolved in seen_targets:
continue
if resolved in counts:
counts[resolved] += 1
seen_targets.add(resolved)
return counts
def _timestamp_match_to_sort_key(match: re.Match[str]) -> int | None:
try:
year = int(match.group(1))
month = int(match.group(2))
day = int(match.group(3))
hour = int(match.group(4) or "0")
minute = int(match.group(5) or "0")
except ValueError:
return None
return year * 100000000 + month * 1000000 + day * 10000 + hour * 100 + minute
def extract_created_sort_key(content: str) -> int | None:
for line in content.splitlines():
created_match = CREATED_LINE_RE.match(line)
if created_match:
ts_match = ORG_TIMESTAMP_RE.search(created_match.group(1))
if ts_match:
key = _timestamp_match_to_sort_key(ts_match)
if key is not None:
return key
first_ts = ORG_TIMESTAMP_RE.search(content)
if first_ts:
return _timestamp_match_to_sort_key(first_ts)
return None
def note_href(relative_path: str, edit_mode: bool) -> str:
params = {"file": relative_path}
if edit_mode:
params["edit"] = "1"
return "/?" + urlencode(params)
def truncate_label(text: str, max_chars: int = 32) -> str:
if len(text) <= max_chars:
return text
if max_chars <= 3:
return "." * max_chars
return text[: max_chars - 3] + "..."
def render_plain_text_with_links(text: str) -> str:
rendered_parts: list[str] = []
cursor = 0
for match in URL_RE.finditer(text):
start, end = match.span()
if start > cursor:
rendered_parts.append(html.escape(text[cursor:start]))
url = match.group(0)
safe_url = html.escape(url, quote=True)
rendered_parts.append(f"{safe_url}")
cursor = end
if cursor < len(text):
rendered_parts.append(html.escape(text[cursor:]))
return "".join(rendered_parts)
def render_line_with_links(
line: str, source_relative_path: str, known_paths: set[str], id_to_path: dict[str, str]
) -> str:
rendered_parts: list[str] = []
cursor = 0
for match in ORG_LINK_RE.finditer(line):
start, end = match.span()
if start > cursor:
rendered_parts.append(render_plain_text_with_links(line[cursor:start]))
raw_target = match.group(1).strip()
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)
link_text = label if label else raw_target
rendered_parts.append(f"{html.escape(link_text)}")
else:
rendered_parts.append(render_plain_text_with_links(match.group(0)))
cursor = end
if cursor < len(line):
rendered_parts.append(render_plain_text_with_links(line[cursor:]))
return "".join(rendered_parts)
def render_org_to_html(
content: str, source_relative_path: str, known_paths: set[str], id_to_path: dict[str, str]
) -> str:
"""Very small org-ish renderer: headings become section titles, body keeps line breaks."""
html_lines: list[str] = []
for raw_line in content.splitlines():
line = raw_line.rstrip("\n")
stripped = line.lstrip()
if stripped.startswith("*"):
stars = len(stripped) - len(stripped.lstrip("*"))
if stars > 0 and len(stripped) > stars and stripped[stars] == " ":
level = min(stars + 1, 6)
title = html.escape(stripped[stars + 1 :])
html_lines.append(f"
{rendered_line}
") return "\n".join(html_lines) def find_org_file(org_files: Iterable[OrgFile], relative_path: str | None) -> OrgFile | None: if relative_path is None: return None for org_file in org_files: if org_file.relative_path == relative_path: return org_file return None def build_index_page( org_files: Iterable[OrgFile], selected_path: str | None = None, edit_mode: bool = False, status_message: str | None = None, status_level: str = "success", ) -> str: org_files = list(org_files) if not org_files: body = "No .org files were found in this directory.
" nav = "" backlinks_html = "Open a note to see backlinks.
" else: 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} 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_title = html.escape(truncate_label(f.title)) safe_path = html.escape(truncate_label(f.relative_path)) full_title = html.escape(f.title, quote=True) full_path = html.escape(f.relative_path, quote=True) searchable = html.escape(f"{f.title} {f.relative_path}".lower(), quote=True) backlinks_count = backlink_counts.get(f.relative_path, 0) created_key = extract_created_sort_key(f.content) created_key_attr = "" if created_key is None else str(created_key) nav_items.append( f"" f"{safe_title}" f"{safe_path}" "" ) nav = "\n".join(nav_items) mode_toggle = ( f"Preview" if edit_mode else f"Edit" ) status_html = "" if status_message: safe_message = html.escape(status_message) safe_level = "error" if status_level == "error" else "success" status_html = f"{safe_message}
" if backlinks: backlink_items = [] for source in backlinks: safe_href = html.escape(note_href(source.relative_path, edit_mode), 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) full_path = html.escape(source.relative_path, quote=True) backlink_items.append( f"" f"{safe_title}" f"{safe_path}" "" ) backlinks_html = "\n".join(backlink_items) else: backlinks_html = "No notes link to this note yet.
" if edit_mode: body = ( f"