Put org web adapter in git
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
venv
|
||||
__pycache__
|
||||
146
README.md
Normal file
146
README.md
Normal 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
2
config.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
bind_addr: 10.54.0.3
|
||||
bind_port: 8001
|
||||
597
main.py
Normal file
597
main.py
Normal 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
7
makefile
Normal file
@ -0,0 +1,7 @@
|
||||
swapin:
|
||||
mv notes old_notes
|
||||
ln -s ~/Documents/zet notes
|
||||
|
||||
swapout:
|
||||
rm notes
|
||||
mv old_notes notes
|
||||
19
old_notes/example.org
Normal file
19
old_notes/example.org
Normal 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
84
old_notes/to_watch.org
Normal 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
385
static/style.css
Normal 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
157
templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user