#!/usr/bin/env python3 from __future__ import annotations import argparse import html import json import mimetypes import os import re import socketserver import threading import webbrowser from dataclasses import dataclass 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.request import Request, urlopen @dataclass class OrgFile: path: Path relative_path: str title: str file_id: str | None content: str mtime: float = 0.0 pinned: bool = False 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}))?[\]>]") PROPERTY_LINE_RE = re.compile(r"^\s*:([A-Za-z_][A-Za-z0-9_-]*):\s+(.+?)\s*$") INLINE_TIMESTAMP_RE = re.compile( r"(?P[<\[])" r"(?P\d{4})-(?P\d{2})-(?P\d{2})" r"(?:\s+(?P\w{2,3}))?" r"(?:\s+(?P\d{2}):(?P\d{2})(?:-(?P\d{2}):(?P\d{2}))?)?" r"[>\]]" ) TODO_KEYWORDS = {"TODO", "STRT", "DONE", "WAIT"} TODO_KEYWORD_RE = re.compile(r"^(" + "|".join(TODO_KEYWORDS) + r")\b\s*(.*)", re.DOTALL) 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, mtime=file_path.stat().st_mtime, ) ) org_files.sort(key=lambda f: f.mtime, reverse=True) if os.environ.get("ORGWEB_PIN_TODAY_FILE", "").lower() in ("1", "true", "yes"): today = datetime.now().strftime("%Y-%m-%d") pinned = [] rest = [] for f in org_files: if today in f.relative_path or today in f.title: f.pinned = True pinned.append(f) else: rest.append(f) org_files = pinned + rest 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] + "..." _MONTH_ABBR = [ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ] def _format_timestamp(m: re.Match[str]) -> str: """Turn an org timestamp match into a styled