Put org web adapter in git

This commit is contained in:
SpaceTurth
2026-02-16 09:06:28 -07:00
commit 1229d19857
10 changed files with 1400 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
venv
__pycache__

146
README.md Normal file
View File

@ -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

2
config.yaml Normal file
View File

@ -0,0 +1,2 @@
bind_addr: 10.54.0.3
bind_port: 8001

597
main.py Normal file
View File

@ -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"<a class='org-link' href='{safe_url}' target='_blank' rel='noopener noreferrer'>{safe_url}</a>")
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"<a class='org-link' href='{safe_href}'>{html.escape(link_text)}</a>")
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"<h{level}>{title}</h{level}>")
continue
if not stripped:
html_lines.append("<div class='spacer'></div>")
else:
rendered_line = render_line_with_links(line, source_relative_path, known_paths, id_to_path)
html_lines.append(f"<p>{rendered_line}</p>")
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 = "<p>No .org files were found in this directory.</p>"
nav = ""
backlinks_html = "<p class='backlinks-empty'>Open a note to see backlinks.</p>"
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"<a class='file-link {active}' data-search='{searchable}' "
f"data-backlinks='{backlinks_count}' data-created-ts='{created_key_attr}' href='{safe_href}'>"
f"<span class='file-title' title='{full_title}'>{safe_title}</span>"
f"<span class='file-path' title='{full_path}'>{safe_path}</span>"
"</a>"
)
nav = "\n".join(nav_items)
mode_toggle = (
f"<a class='mode-link' href='{html.escape(note_href(selected.relative_path, False), quote=True)}'>Preview</a>"
if edit_mode
else f"<a class='mode-link' href='{html.escape(note_href(selected.relative_path, True), quote=True)}'>Edit</a>"
)
status_html = ""
if status_message:
safe_message = html.escape(status_message)
safe_level = "error" if status_level == "error" else "success"
status_html = f"<p class='status status-{safe_level}'>{safe_message}</p>"
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"<a class='backlink-item' href='{safe_href}'>"
f"<span class='file-title' title='{full_title}'>{safe_title}</span>"
f"<span class='file-path' title='{full_path}'>{safe_path}</span>"
"</a>"
)
backlinks_html = "\n".join(backlink_items)
else:
backlinks_html = "<p class='backlinks-empty'>No notes link to this note yet.</p>"
if edit_mode:
body = (
f"<h2>Editing {html.escape(selected.relative_path)}</h2>"
f"<div class='toolbar'>{mode_toggle}</div>"
f"{status_html}"
"<form class='editor-form' method='post' action='/edit'>"
f"<input type='hidden' name='file' value='{html.escape(selected.relative_path, quote=True)}'>"
f"<textarea class='editor-box' name='content'>{html.escape(selected.content)}</textarea>"
"<button class='submit-btn' type='submit'>Submit</button>"
"</form>"
)
else:
body = (
f"<h2>{html.escape(selected.relative_path)}</h2>"
f"<div class='toolbar'>{mode_toggle}</div>"
f"{status_html}"
f"<article class='org-content'>{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path)}</article>"
)
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()

7
makefile Normal file
View File

@ -0,0 +1,7 @@
swapin:
mv notes old_notes
ln -s ~/Documents/zet notes
swapout:
rm notes
mv old_notes notes

1
notes Symbolic link
View File

@ -0,0 +1 @@
/home/turth/Documents/zet

19
old_notes/example.org Normal file
View File

@ -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.

84
old_notes/to_watch.org Normal file
View File

@ -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]

385
static/style.css Normal file
View File

@ -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;
}
}

157
templates/index.html Normal file
View File

@ -0,0 +1,157 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Org Web Viewer</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>Org Web Viewer</h1>
<button id="theme-toggle" type="button" aria-label="Toggle dark mode">Dark mode</button>
</header>
<div class="layout">
<nav>
<label class="search-wrap" for="title-search">
<span class="search-label">Search titles</span>
<input id="title-search" class="search-input" type="search" placeholder="Type to filter files">
</label>
<div class="nav-actions">
<button id="shuffle-notes" class="shuffle-btn" type="button">Shuffle notes</button>
<button id="sort-backlinks" class="shuffle-btn" type="button">Sort by backlinks</button>
<button id="sort-created" class="shuffle-btn" type="button">Sort by created date</button>
<button id="jump-current" class="shuffle-btn" type="button">Jump to current note</button>
</div>
<div id="file-list">{{NAV_ITEMS}}</div>
</nav>
<main>{{MAIN_CONTENT}}</main>
<aside class="backlinks-pane">
<h3>Backlinks</h3>
<div class="backlinks-list">{{BACKLINKS}}</div>
</aside>
</div>
<script>
window.MathJax = {
tex: {
inlineMath: [["$", "$"]],
displayMath: []
},
options: {
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"]
}
};
</script>
<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
<script>
(function () {
const key = "org_web_viewer_theme";
const body = document.body;
const btn = document.getElementById("theme-toggle");
function applyTheme(theme) {
if (theme === "dark") {
body.setAttribute("data-theme", "dark");
btn.textContent = "Light mode";
} else {
body.removeAttribute("data-theme");
btn.textContent = "Dark mode";
}
}
const saved = localStorage.getItem(key);
applyTheme(saved === "dark" ? "dark" : "light");
btn.addEventListener("click", function () {
const next = body.getAttribute("data-theme") === "dark" ? "light" : "dark";
localStorage.setItem(key, next);
applyTheme(next);
});
const searchInput = document.getElementById("title-search");
const fileList = document.getElementById("file-list");
const shuffleNotesBtn = document.getElementById("shuffle-notes");
const sortBacklinksBtn = document.getElementById("sort-backlinks");
const sortCreatedBtn = document.getElementById("sort-created");
const jumpCurrentBtn = document.getElementById("jump-current");
const fileLinks = Array.from(document.querySelectorAll("#file-list .file-link"));
const activeFileLink = document.querySelector("#file-list .file-link.active");
searchInput.addEventListener("input", function () {
const query = searchInput.value.trim().toLowerCase();
for (const link of fileLinks) {
const haystack = link.getAttribute("data-search") || "";
link.style.display = haystack.includes(query) ? "" : "none";
}
});
function reorderLinks(links) {
const fragment = document.createDocumentFragment();
for (const link of links) {
fragment.appendChild(link);
}
fileList.appendChild(fragment);
}
jumpCurrentBtn.addEventListener("click", function () {
if (!activeFileLink) {
return;
}
activeFileLink.scrollIntoView({ block: "nearest", inline: "nearest" });
});
shuffleNotesBtn.addEventListener("click", function () {
const links = Array.from(fileList.querySelectorAll(".file-link"));
for (let i = links.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = links[i];
links[i] = links[j];
links[j] = tmp;
}
reorderLinks(links);
});
sortBacklinksBtn.addEventListener("click", function () {
const links = Array.from(fileList.querySelectorAll(".file-link"));
links.sort(function (a, b) {
const aCount = Number.parseInt(a.getAttribute("data-backlinks") || "0", 10);
const bCount = Number.parseInt(b.getAttribute("data-backlinks") || "0", 10);
if (aCount !== bCount) {
return bCount - aCount;
}
const aKey = a.getAttribute("data-search") || "";
const bKey = b.getAttribute("data-search") || "";
return aKey.localeCompare(bKey);
});
reorderLinks(links);
});
sortCreatedBtn.addEventListener("click", function () {
const links = Array.from(fileList.querySelectorAll(".file-link"));
links.sort(function (a, b) {
const aRaw = a.getAttribute("data-created-ts") || "";
const bRaw = b.getAttribute("data-created-ts") || "";
const aMissing = aRaw === "";
const bMissing = bRaw === "";
if (aMissing && !bMissing) {
return -1;
}
if (!aMissing && bMissing) {
return 1;
}
if (!aMissing && !bMissing) {
const aTs = Number.parseInt(aRaw, 10);
const bTs = Number.parseInt(bRaw, 10);
if (aTs !== bTs) {
return aTs - bTs;
}
}
const aKey = a.getAttribute("data-search") || "";
const bKey = b.getAttribute("data-search") || "";
return aKey.localeCompare(bKey);
});
reorderLinks(links);
});
})();
</script>
</body>
</html>