From 1229d198573f4bebe29b622e0edff0f84c63ebab Mon Sep 17 00:00:00 2001
From: SpaceTurth <101376682+SpaceTurth@users.noreply.github.com>
Date: Mon, 16 Feb 2026 09:06:28 -0700
Subject: [PATCH] Put org web adapter in git
---
.gitignore | 2 +
README.md | 146 ++++++++++
config.yaml | 2 +
main.py | 597 +++++++++++++++++++++++++++++++++++++++++
makefile | 7 +
notes | 1 +
old_notes/example.org | 19 ++
old_notes/to_watch.org | 84 ++++++
static/style.css | 385 ++++++++++++++++++++++++++
templates/index.html | 157 +++++++++++
10 files changed, 1400 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 config.yaml
create mode 100644 main.py
create mode 100644 makefile
create mode 120000 notes
create mode 100644 old_notes/example.org
create mode 100644 old_notes/to_watch.org
create mode 100644 static/style.css
create mode 100644 templates/index.html
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d75edea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+venv
+__pycache__
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bf1416e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,146 @@
+# Org Web Adapter
+
+A lightweight local web app for browsing and editing Org files.
+
+The app is implemented as a single Python server (`main.py`) plus one HTML template (`templates/index.html`) and one stylesheet (`static/style.css`). It scans a notes directory for `.org` files and renders a 3-pane UI:
+
+- left sidebar: note list + search/sort controls
+- center pane: note content (preview or edit)
+- right sidebar: backlinks to the current note
+
+## Architecture
+
+### High-level flow
+
+1. `main.py` starts an HTTP server.
+2. On each page request (`GET /`), it rescans the notes directory for `.org` files.
+3. It resolves links/backlinks and builds HTML fragments.
+4. It injects those fragments into `templates/index.html` placeholders:
+ - `{{NAV_ITEMS}}`
+ - `{{MAIN_CONTENT}}`
+ - `{{BACKLINKS}}`
+5. Browser JS in `templates/index.html` handles client-side interactions (search, shuffle, sorting, jump-to-current, theme toggle).
+
+### Server-side components (`main.py`)
+
+- File discovery and parsing:
+ - `scan_org_files(...)` recursively finds `.org` files.
+ - Extracts title from `#+TITLE:` and ID from `:ID:`.
+- Org link/backlink handling:
+ - `resolve_link_target(...)` supports `file:...` and `id:...` links.
+ - `find_backlinks(...)` computes notes linking to the selected note.
+ - `build_backlink_counts(...)` computes backlink totals for sorting.
+- Rendering:
+ - `render_org_to_html(...)` converts headings (`*`, `**`, ...) and paragraphs to simple HTML.
+ - `render_line_with_links(...)` converts org links in text to clickable app links where resolvable.
+ - `truncate_label(...)` caps sidebar labels to 32 chars with `...`.
+- Editing:
+ - `POST /edit` updates a selected `.org` file and redirects back with status flags.
+- Static files:
+ - `serve_static(...)` serves files under `static/` with path traversal protection.
+
+### Frontend components
+
+- `templates/index.html`:
+ - Base layout markup.
+ - Sidebar controls.
+ - Small JS controller for filtering/sorting/shuffling nav links.
+ - MathJax initialization for inline `$...$` rendering.
+- `static/style.css`:
+ - 3-column desktop grid and stacked mobile layout.
+ - Independent scroll regions for note list and backlinks.
+ - Mobile note-list cap (about 5 notes visible before scrolling).
+
+## Configuration
+
+Startup config is read from `config.yaml` by default.
+
+Current config:
+
+```yaml
+bind_addr: 10.54.0.3
+bind_port: 8001
+```
+
+Supported keys:
+
+- `bind_addr`: host/IP to bind
+- `bind_port`: TCP port (must be `1..65535`)
+
+Notes:
+
+- If `config.yaml` is missing, defaults are `127.0.0.1:8000`.
+- CLI flags `--host` and `--port` override config values.
+- You can choose a different config file with `--config /path/to/config.yaml`.
+
+## Running
+
+Basic run:
+
+```bash
+python3 main.py
+```
+
+Useful options:
+
+```bash
+python3 main.py --dir notes
+python3 main.py --host 127.0.0.1 --port 9000
+python3 main.py --config ./config.yaml
+python3 main.py --no-browser
+```
+
+## Features
+
+### Note browsing
+
+- Recursive `.org` file discovery.
+- Sidebar note list with active-note highlighting.
+- Title/path search filter.
+
+### Sidebar ordering controls
+
+- Shuffle notes.
+- Sort by backlink count (descending).
+- Sort by created date (ascending).
+ - Notes without timestamps are treated as older than notes with timestamps.
+- Jump to current note button.
+
+### Backlinks
+
+- Right sidebar lists notes linking to the current note.
+- Supports both `file:` and `id:` link resolution.
+
+### Editing
+
+- Toggle Preview/Edit for the selected note.
+- Save changes from browser to disk.
+- Inline status messages for save success/errors.
+
+### Math rendering
+
+- Inline math rendering via MathJax using `$...$` delimiters.
+
+### UI behavior
+
+- Light/dark theme toggle (persisted in `localStorage`).
+- Desktop: independent scrollable sidebars.
+- Mobile: notes list capped with its own scroll area.
+- Sidebar text truncation with ellipsis and tooltip for full text.
+
+## Project layout
+
+- `main.py`: server, parsing, rendering, routing.
+- `templates/index.html`: page template + UI behavior JS.
+- `static/style.css`: styling and responsive layout.
+- `config.yaml`: bind config.
+- `notes/`: notes directory (can be a symlink).
+- `old_notes/`: alternate local notes snapshot.
+
+## Limitations
+
+- Not a full Org parser; rendering is intentionally simple.
+- Notes are rescanned on each request (simple and fresh, but not optimized for huge note sets).
+- Math rendering depends on loading MathJax from CDN.
+
+codex resume 019c650e-61d5-73c0-82b6-9872fba7c71e
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..884a2e9
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,2 @@
+bind_addr: 10.54.0.3
+bind_port: 8001
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..4885cd0
--- /dev/null
+++ b/main.py
@@ -0,0 +1,597 @@
+#!/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"