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