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"{title}") + continue + + if not stripped: + html_lines.append("
") + else: + rendered_line = render_line_with_links(line, source_relative_path, known_paths, id_to_path) + 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 = "" + 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 = "" + + if edit_mode: + body = ( + f"

Editing {html.escape(selected.relative_path)}

" + f"
{mode_toggle}
" + f"{status_html}" + "
" + f"" + f"" + "" + "
" + ) + else: + body = ( + f"

{html.escape(selected.relative_path)}

" + f"
{mode_toggle}
" + f"{status_html}" + f"
{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path)}
" + ) + + template = TEMPLATE_PATH.read_text(encoding="utf-8") + return ( + template.replace("{{NAV_ITEMS}}", nav) + .replace("{{MAIN_CONTENT}}", body) + .replace("{{BACKLINKS}}", backlinks_html) + ) + + +def make_handler(base_dir: Path): + class OrgRequestHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + parsed = urlparse(self.path) + + if parsed.path.startswith("/static/"): + self.serve_static(parsed.path) + return + + if parsed.path != "/": + self.send_error(404, "Not found") + return + + params = parse_qs(parsed.query) + selected_path = params.get("file", [None])[0] + edit_mode = params.get("edit", ["0"])[0] == "1" + saved = params.get("saved", ["0"])[0] == "1" + error = params.get("error", [""])[0] + status_message = None + status_level = "success" + if saved: + status_message = "Saved successfully." + elif error == "missing": + status_message = "Select a valid .org file first." + status_level = "error" + elif error == "write": + 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, + edit_mode=edit_mode, + status_message=status_message, + status_level=status_level, + ) + encoded = html_page.encode("utf-8") + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def do_POST(self) -> None: + parsed = urlparse(self.path) + if parsed.path != "/edit": + self.send_error(404, "Not found") + return + + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length).decode("utf-8", errors="replace") + params = parse_qs(body) + selected_path = params.get("file", [None])[0] + new_content = params.get("content", [""])[0] + + org_files = scan_org_files(base_dir) + selected = find_org_file(org_files, selected_path) + if selected is None: + self.redirect_with_query("/", {"edit": "1", "error": "missing"}) + return + + try: + selected.path.write_text(new_content, encoding="utf-8") + except OSError: + self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "error": "write"}) + return + + self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "saved": "1"}) + + def redirect_with_query(self, path: str, params: dict[str, str]) -> None: + location = f"{path}?{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() + if STATIC_DIR.resolve() not in candidate.parents and candidate != STATIC_DIR.resolve(): + self.send_error(403, "Forbidden") + return + if not candidate.is_file(): + self.send_error(404, "Not found") + return + + data = candidate.read_bytes() + content_type = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream" + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt: str, *args) -> None: + return + + return OrgRequestHandler + + +def serve(base_dir: Path, host: str, port: int, open_browser: bool = True) -> None: + handler_cls = make_handler(base_dir) + + class ReusableHTTPServer(socketserver.ThreadingMixIn, HTTPServer): + daemon_threads = True + allow_reuse_address = True + + server = ReusableHTTPServer((host, port), handler_cls) + actual_port = server.server_address[1] + url = f"http://{host}:{actual_port}/" + print(f"Serving org files from {base_dir}") + print(f"Open {url}") + + if open_browser: + threading.Timer(0.4, lambda: webbrowser.open(url)).start() + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.") + finally: + server.server_close() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Serve local .org files in a browser-friendly web view.") + parser.add_argument( + "--config", + default=str(DEFAULT_CONFIG_PATH), + help=f"Path to config YAML file (default: {DEFAULT_CONFIG_PATH})", + ) + parser.add_argument("--dir", default="notes", help="Directory to scan for .org files (default: notes)") + parser.add_argument( + "--host", + default=None, + help="Host/IP to bind the web server (overrides config bind_addr)", + ) + parser.add_argument( + "--port", + type=int, + default=None, + help="Port to bind (overrides config bind_port)", + ) + parser.add_argument("--no-browser", action="store_true", help="Don't auto-open the browser") + return parser.parse_args() + + +def _strip_quotes(value: str) -> str: + if len(value) >= 2 and ((value[0] == value[-1] == "'") or (value[0] == value[-1] == '"')): + return value[1:-1] + return value + + +def load_runtime_config(config_path: Path) -> dict[str, str | int]: + config: dict[str, str] = {} + if config_path.exists(): + text = config_path.read_text(encoding="utf-8") + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + if not key: + continue + if "#" in value: + value = value.split("#", 1)[0].strip() + config[key] = _strip_quotes(value) + + bind_addr = config.get("bind_addr", "127.0.0.1") + bind_port_raw = config.get("bind_port", "8000") + try: + bind_port = int(bind_port_raw) + except ValueError as exc: + raise ValueError(f"Invalid bind_port in {config_path}: {bind_port_raw!r}") from exc + if not 1 <= bind_port <= 65535: + raise ValueError(f"bind_port out of range in {config_path}: {bind_port}") + + return {"bind_addr": bind_addr, "bind_port": bind_port} + + +def main() -> None: + args = parse_args() + config = load_runtime_config(Path(args.config)) + base_dir = Path(args.dir).resolve() + host = args.host if args.host is not None else str(config["bind_addr"]) + port = args.port if args.port is not None else int(config["bind_port"]) + serve(base_dir, host, port, open_browser=not args.no_browser) + + +if __name__ == "__main__": + main() diff --git a/makefile b/makefile new file mode 100644 index 0000000..6aafe01 --- /dev/null +++ b/makefile @@ -0,0 +1,7 @@ +swapin: + mv notes old_notes + ln -s ~/Documents/zet notes + +swapout: + rm notes + mv old_notes notes diff --git a/notes b/notes new file mode 120000 index 0000000..1a0f346 --- /dev/null +++ b/notes @@ -0,0 +1 @@ +/home/turth/Documents/zet \ No newline at end of file diff --git a/old_notes/example.org b/old_notes/example.org new file mode 100644 index 0000000..212deba --- /dev/null +++ b/old_notes/example.org @@ -0,0 +1,19 @@ +#+TITLE: Example Notes +#+AUTHOR: Org Web Adapter Stuff + +* Today +- Build a tiny server to browse Org files. +- Make sure it works on mobile. + +* Ideas +** Reader Mode +Show a clean layout with headings and content. + +** Next Step +Add editing endpoints and save-back support. +Related list: [[file:to_watch.org][To Watch]] +Related by ID: [[id:f2db82e0-c545-40fe-9458-55f8fe1bcb4b][To Watch (ID)]] + +* Scratch +This paragraph demonstrates normal text. +A second line stays as plain text for now. diff --git a/old_notes/to_watch.org b/old_notes/to_watch.org new file mode 100644 index 0000000..c77d3ae --- /dev/null +++ b/old_notes/to_watch.org @@ -0,0 +1,84 @@ +:PROPERTIES: +:ID: f2db82e0-c545-40fe-9458-55f8fe1bcb4b +:END: +#+title: To Watch +[2023-05-07 Sun 22:59] + +* To Watch + +** TODO Watch "Das Boot" + [2023-06-19 Mon 14:46] + +** TODO Watch "Eternal Sunshine of the Spotless Mind" + [2023-06-21 Wed 16:15] + +** TODO Watch "In the mood for love" + [2023-09-09 Sat 21:06] + +** TODO Watch "Isle of Dogs" + [2023-09-30 Sat 10:20] + +** TODO Watch "Whisper of the Heart" + [2023-10-15 Sun 18:50] + +** TODO Watch "Kiki's delivery service" + [2023-10-15 Sun 18:50] + +** TODO Watch netflix "Life" documentary + [2023-11-07 Tue 10:11] + +** TODO Watch "Pirates of the Carribean" + [2023-12-31 Sun 17:51] + +** TODO Watch "Good Will Hunting" + [2024-01-05 Fri 11:32] + +** TODO Watch "Ben Hur" + [2024-01-29 Mon 19:51] + +** TODO Watch "Office Space" + [2024-02-04 Sun 13:00] + +** TODO Watch a Hitchcock movie + [2024-03-20 Wed 21:01] + +** TODO Watch "the edge" + [2024-05-04 Sat 11:20] + +** TODO Watch "Monk" + [2024-05-21 Tue 15:48] + +** TODO Watch "Taxi Driver" + [2024-06-07 Fri 11:26] + +** TODO Watch "Ranking of Kings" + [2024-06-10 Mon 20:06] + +** TODO Watch "Mushi-shi" + [2024-06-27 Thu 08:10] + +** TODO Watch "secret level" on prime + [2025-02-02 Sun 22:19] + +** TODO Watch "The Fugitive" + [2025-06-23 Mon 10:18] + +** TODO Watch "Castle in the Sky" + [2025-06-23 Mon 10:20] + +** TODO Watch "Grave of the fireflies" + [2025-06-23 Mon 10:20] + +** TODO Watch "Christmas Fury" + [2025-11-24 Mon 17:14] + +** TODO Watch White Fang + [2025-12-29 Mon 15:24] + https://www.imdb.com/title/tt5222768/ +** DONE Watch Jurassic Park + CLOSED: [2026-02-15 Sun 22:28] + [2026-01-26 Mon 17:56] + +** TODO Watch "Hackers" + [2026-02-15 Sun 22:28] + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..853c19d --- /dev/null +++ b/static/style.css @@ -0,0 +1,385 @@ +:root { + --bg: #f7f5ef; + --bg-grad-a: #f5efe2; + --bg-grad-b: #eaf6f4; + --header-bg: #fffcf5; + --nav-bg: #f5f0e4; + --panel: #fffdfa; + --line: #ddd6c6; + --ink: #1f2a36; + --ink-soft: #4a5a6b; + --accent: #0f766e; + --accent-soft: #d2f0ec; + --shadow: rgba(0, 0, 0, 0.08); +} + +body[data-theme="dark"] { + --bg: #0f1720; + --bg-grad-a: #131d29; + --bg-grad-b: #0b1320; + --header-bg: #141e2a; + --nav-bg: #121a25; + --panel: #17202a; + --line: #2d3a47; + --ink: #e8f0f7; + --ink-soft: #afbfce; + --accent: #4fd1c5; + --accent-soft: #1d3b40; + --shadow: rgba(0, 0, 0, 0.38); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; + color: var(--ink); + background: linear-gradient(135deg, var(--bg-grad-a) 0%, var(--bg) 45%, var(--bg-grad-b) 100%); +} + +header { + padding: 1rem; + border-bottom: 1px solid var(--line); + background: var(--header-bg); + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; +} + +header h1 { + margin: 0; + font-size: 1.1rem; + letter-spacing: 0.01em; +} + +#theme-toggle { + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--ink); + padding: 0.35rem 0.7rem; + font-size: 0.85rem; + cursor: pointer; +} + +#theme-toggle:hover { + border-color: var(--accent); +} + +.layout { + display: grid; + grid-template-columns: minmax(200px, 300px) 1fr minmax(220px, 300px); + height: calc(100vh - 57px); + overflow: hidden; +} + +nav { + border-right: 1px solid var(--line); + background: var(--nav-bg); + padding: 0.75rem; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +#file-list { + display: grid; + gap: 0.5rem; + align-content: start; + grid-auto-rows: max-content; + flex: 1; + min-height: 0; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; +} + +.search-wrap { + display: grid; + gap: 0.35rem; + margin-bottom: 0.8rem; +} + +.shuffle-btn { + width: 100%; + margin: 0; + border: 1px solid var(--line); + border-radius: 0.45rem; + background: var(--panel); + color: var(--ink); + padding: 0.45rem 0.55rem; + font-size: 0.86rem; + cursor: pointer; +} + +.shuffle-btn:hover { + border-color: var(--accent); +} + +.nav-actions { + display: grid; + gap: 0.5rem; + margin: 0 0 0.8rem; +} + +.search-label { + font-size: 0.8rem; + color: var(--ink-soft); +} + +.search-input { + width: 100%; + border: 1px solid var(--line); + border-radius: 0.45rem; + background: var(--panel); + color: var(--ink); + padding: 0.45rem 0.55rem; + font-size: 0.92rem; +} + +.search-input:focus { + outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); + outline-offset: 1px; +} + +.file-link { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + margin-bottom: 0.5rem; + padding: 0.6rem 0.7rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + color: var(--ink-soft); + text-decoration: none; + background: var(--panel); +} + +.file-title { + display: block; + font-weight: 600; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-path { + display: block; + font-size: 0.78rem; + margin-top: 0.1rem; + color: var(--ink-soft); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-link:hover { + border-color: var(--accent); + color: var(--ink); +} + +.file-link.active { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--ink); + font-weight: 600; +} + +main { + padding: 1.2rem; + overflow: auto; +} + +.backlinks-pane { + border-left: 1px solid var(--line); + background: var(--nav-bg); + padding: 0.75rem; + overflow: auto; +} + +.backlinks-pane h3 { + margin: 0 0 0.7rem; + font-size: 0.92rem; + color: var(--ink-soft); +} + +.backlinks-list { + display: grid; + gap: 0.5rem; +} + +.backlink-item { + display: block; + padding: 0.55rem 0.65rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--panel); + text-decoration: none; +} + +.backlink-item:hover { + border-color: var(--accent); +} + +.backlinks-empty { + margin: 0; + padding: 0.45rem 0.2rem; + color: var(--ink-soft); +} + +main h2 { + margin-top: 0; + font-size: 1.05rem; + color: var(--ink-soft); +} + +.toolbar { + margin: 0 0 0.8rem; +} + +.mode-link { + display: inline-block; + padding: 0.35rem 0.65rem; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--ink); + text-decoration: none; + font-size: 0.85rem; +} + +.mode-link:hover { + border-color: var(--accent); +} + +.status { + margin: 0 0 0.8rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--panel); +} + +.status-success { + border-color: var(--accent); +} + +.status-error { + border-color: #c84a52; +} + +.editor-form { + display: grid; + gap: 0.7rem; +} + +.editor-box { + width: 100%; + min-height: 58vh; + resize: vertical; + padding: 0.8rem; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--panel); + color: var(--ink); + font: 500 0.96rem/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.submit-btn { + justify-self: start; + border: 1px solid var(--accent); + border-radius: 0.55rem; + background: var(--accent); + color: #ffffff; + padding: 0.5rem 0.95rem; + font-weight: 600; + cursor: pointer; +} + +.submit-btn:hover { + filter: brightness(1.08); +} + +.org-content { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 0.75rem; + padding: 1rem; + line-height: 1.5; + box-shadow: 0 8px 20px var(--shadow); +} + +.org-content h2, +.org-content h3, +.org-content h4, +.org-content h5, +.org-content h6 { + margin: 1.1rem 0 0.4rem; + color: var(--accent); +} + +.org-content p { + margin: 0.35rem 0; +} + +.org-link { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.org-link:hover { + filter: brightness(1.12); +} + +.spacer { + height: 0.5rem; +} + +@media (max-width: 800px) { + .layout { + grid-template-columns: 1fr; + height: auto; + min-height: calc(100vh - 57px); + overflow: visible; + } + + nav { + border-right: none; + border-bottom: 1px solid var(--line); + display: flex; + } + + #file-list { + max-height: calc((5 * 4.25rem) + (4 * 0.5rem)); + overflow-y: auto; + overflow-x: hidden; + } + + .backlinks-pane { + border-left: none; + border-top: 1px solid var(--line); + } + + .file-link { + margin-bottom: 0; + } + + .editor-box { + min-height: 48vh; + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..065f5e9 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,157 @@ + + + + + + Org Web Viewer + + + +
+

Org Web Viewer

+ +
+
+ +
{{MAIN_CONTENT}}
+ +
+ + + + +