Add pinned today, webhook support and style changes
This commit is contained in:
405
main.py
405
main.py
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
@ -10,10 +11,12 @@ import socketserver
|
||||
import threading
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -23,6 +26,7 @@ class OrgFile:
|
||||
title: str
|
||||
file_id: str | None
|
||||
content: str
|
||||
mtime: float = 0.0
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
@ -34,6 +38,16 @@ 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}))?[\]>]")
|
||||
PROPERTY_LINE_RE = re.compile(r"^\s*:([A-Za-z_][A-Za-z0-9_-]*):\s+(.+?)\s*$")
|
||||
INLINE_TIMESTAMP_RE = re.compile(
|
||||
r"(?P<bracket>[<\[])"
|
||||
r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
|
||||
r"(?:\s+(?P<dayname>\w{2,3}))?"
|
||||
r"(?:\s+(?P<hour>\d{2}):(?P<minute>\d{2})(?:-(?P<end_hour>\d{2}):(?P<end_minute>\d{2}))?)?"
|
||||
r"[>\]]"
|
||||
)
|
||||
TODO_KEYWORDS = {"TODO", "STRT", "DONE", "WAIT"}
|
||||
TODO_KEYWORD_RE = re.compile(r"^(" + "|".join(TODO_KEYWORDS) + r")\b\s*(.*)", re.DOTALL)
|
||||
DEFAULT_CONFIG_PATH = ROOT_DIR / "config.yaml"
|
||||
|
||||
|
||||
@ -59,10 +73,11 @@ def scan_org_files(base_dir: Path) -> list[OrgFile]:
|
||||
title=extract_org_title(content, file_path.stem),
|
||||
file_id=extract_org_id(content),
|
||||
content=content,
|
||||
mtime=file_path.stat().st_mtime,
|
||||
)
|
||||
)
|
||||
|
||||
org_files.sort(key=lambda f: f.relative_path.lower())
|
||||
org_files.sort(key=lambda f: f.mtime, reverse=True)
|
||||
return org_files
|
||||
|
||||
|
||||
@ -220,6 +235,75 @@ def truncate_label(text: str, max_chars: int = 32) -> str:
|
||||
return text[: max_chars - 3] + "..."
|
||||
|
||||
|
||||
_MONTH_ABBR = [
|
||||
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
]
|
||||
|
||||
|
||||
def _format_timestamp(m: re.Match[str]) -> str:
|
||||
"""Turn an org timestamp match into a styled <time> element."""
|
||||
year = m.group("year")
|
||||
month = int(m.group("month"))
|
||||
day = int(m.group("day"))
|
||||
dayname = m.group("dayname") or ""
|
||||
hour = m.group("hour")
|
||||
minute = m.group("minute")
|
||||
end_hour = m.group("end_hour")
|
||||
end_minute = m.group("end_minute")
|
||||
is_active = m.group("bracket") == "<"
|
||||
|
||||
month_str = _MONTH_ABBR[month] if 1 <= month <= 12 else m.group("month")
|
||||
date_part = f"{month_str} {day}, {year}"
|
||||
if dayname:
|
||||
date_part = f"{dayname} {date_part}"
|
||||
|
||||
time_part = ""
|
||||
if hour is not None:
|
||||
time_part = f"{hour}:{minute}"
|
||||
if end_hour is not None:
|
||||
time_part += f"–{end_hour}:{end_minute}"
|
||||
|
||||
iso_date = f"{year}-{m.group('month')}-{m.group('day')}"
|
||||
if hour is not None:
|
||||
iso_date += f"T{hour}:{minute}"
|
||||
|
||||
cls = "org-timestamp" if is_active else "org-timestamp org-timestamp-inactive"
|
||||
inner = f"<span class='org-ts-date'>{html.escape(date_part)}</span>"
|
||||
if time_part:
|
||||
inner += f"<span class='org-ts-time'>{html.escape(time_part)}</span>"
|
||||
|
||||
return f"<time class='{cls}' datetime='{iso_date}'>{inner}</time>"
|
||||
|
||||
|
||||
_ESCAPED_TS_RE = re.compile(
|
||||
r"(?P<bracket>[\x00\[])"
|
||||
r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
|
||||
r"(?:\s+(?P<dayname>\w{2,3}))?"
|
||||
r"(?:\s+(?P<hour>\d{2}):(?P<minute>\d{2})(?:-(?P<end_hour>\d{2}):(?P<end_minute>\d{2}))?)?"
|
||||
r"[\x01\]]"
|
||||
)
|
||||
|
||||
|
||||
def _replace_timestamps(escaped_html: str) -> str:
|
||||
"""Find org timestamps in already-escaped text and wrap them in <time> tags.
|
||||
|
||||
Because the text has been HTML-escaped, angle brackets appear as < / >.
|
||||
We temporarily swap them to sentinel bytes so the regex can match uniformly.
|
||||
"""
|
||||
result = escaped_html.replace("<", "\x00").replace(">", "\x01")
|
||||
|
||||
def _sub(m: re.Match[str]) -> str:
|
||||
raw = m.group(0).replace("\x00", "<").replace("\x01", ">")
|
||||
ts_match = INLINE_TIMESTAMP_RE.match(raw)
|
||||
if ts_match:
|
||||
return _format_timestamp(ts_match)
|
||||
return m.group(0).replace("\x00", "<").replace("\x01", ">")
|
||||
|
||||
result = _ESCAPED_TS_RE.sub(_sub, result)
|
||||
return result.replace("\x00", "<").replace("\x01", ">")
|
||||
|
||||
|
||||
def render_plain_text_with_links(text: str) -> str:
|
||||
rendered_parts: list[str] = []
|
||||
cursor = 0
|
||||
@ -233,7 +317,30 @@ def render_plain_text_with_links(text: str) -> str:
|
||||
cursor = end
|
||||
if cursor < len(text):
|
||||
rendered_parts.append(html.escape(text[cursor:]))
|
||||
return "".join(rendered_parts)
|
||||
return _replace_timestamps("".join(rendered_parts))
|
||||
|
||||
|
||||
def _render_heading_with_todo(heading_text: str) -> tuple[str, str | None, str, list[str]]:
|
||||
"""Parse a heading for TODO keyword and tags.
|
||||
|
||||
Returns (rendered_html, keyword_or_None, bare_heading_text, tags_list).
|
||||
"""
|
||||
# Strip tags from the end first
|
||||
tag_match = _ORG_TAG_RE.search(heading_text)
|
||||
tags: list[str] = []
|
||||
text_without_tags = heading_text
|
||||
if tag_match:
|
||||
tags = [t for t in tag_match.group(1).split(":") if t]
|
||||
text_without_tags = heading_text[: tag_match.start()].strip()
|
||||
|
||||
m = TODO_KEYWORD_RE.match(text_without_tags)
|
||||
if m:
|
||||
keyword = m.group(1)
|
||||
rest = m.group(2).strip()
|
||||
css_class = f"org-todo org-todo-{keyword.lower()}"
|
||||
rendered = f"<span class='{css_class}'>{html.escape(keyword)}</span> {html.escape(rest)}"
|
||||
return rendered, keyword, rest, tags
|
||||
return html.escape(text_without_tags), None, text_without_tags.strip(), tags
|
||||
|
||||
|
||||
def render_line_with_links(
|
||||
@ -266,21 +373,72 @@ def render_line_with_links(
|
||||
|
||||
|
||||
def render_org_to_html(
|
||||
content: str, source_relative_path: str, known_paths: set[str], id_to_path: dict[str, str]
|
||||
content: str, source_relative_path: str, known_paths: set[str], id_to_path: dict[str, str],
|
||||
show_webhook: bool = False,
|
||||
) -> str:
|
||||
"""Very small org-ish renderer: headings become section titles, body keeps line breaks."""
|
||||
html_lines: list[str] = []
|
||||
in_properties = False
|
||||
property_items: list[tuple[str, str]] = []
|
||||
|
||||
for raw_line in content.splitlines():
|
||||
lines = content.splitlines()
|
||||
for raw_line in lines:
|
||||
line = raw_line.rstrip("\n")
|
||||
stripped = line.lstrip()
|
||||
|
||||
if re.match(r"^\s*:PROPERTIES:\s*$", stripped, re.IGNORECASE):
|
||||
in_properties = True
|
||||
property_items = []
|
||||
continue
|
||||
|
||||
if in_properties:
|
||||
if re.match(r"^\s*:END:\s*$", stripped, re.IGNORECASE):
|
||||
in_properties = False
|
||||
if property_items:
|
||||
dl_items = []
|
||||
for key, value in property_items:
|
||||
dl_items.append(
|
||||
f"<dt>{html.escape(key)}</dt>"
|
||||
f"<dd>{html.escape(value)}</dd>"
|
||||
)
|
||||
html_lines.append(
|
||||
"<dl class='org-properties'>" + "".join(dl_items) + "</dl>"
|
||||
)
|
||||
continue
|
||||
prop_match = PROPERTY_LINE_RE.match(stripped)
|
||||
if prop_match:
|
||||
property_items.append((prop_match.group(1), prop_match.group(2)))
|
||||
continue
|
||||
|
||||
if re.match(r"^\s*#\+filetags:\s", stripped, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
title_match = TITLE_RE.match(stripped)
|
||||
if title_match:
|
||||
html_lines.append(f"<h1>{html.escape(title_match.group(1))}</h1>")
|
||||
continue
|
||||
|
||||
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}>")
|
||||
heading_text = stripped[stars + 1 :]
|
||||
rendered_title, keyword, bare_text, heading_tags = _render_heading_with_todo(heading_text)
|
||||
html_lines.append(f"<h{level}>{rendered_title}</h{level}>")
|
||||
if heading_tags:
|
||||
tag_spans = "".join(
|
||||
f"<span class='org-tag'>{html.escape(t)}</span>"
|
||||
for t in heading_tags
|
||||
)
|
||||
html_lines.append(f"<div class='org-tags'>{tag_spans}</div>")
|
||||
if keyword == "TODO" and show_webhook:
|
||||
safe_file = html.escape(source_relative_path, quote=True)
|
||||
safe_heading = html.escape(bare_text, quote=True)
|
||||
html_lines.append(
|
||||
f"<button class='webhook-send-btn' type='button' "
|
||||
f"data-file='{safe_file}' data-heading='{safe_heading}'>"
|
||||
f"Start work</button>"
|
||||
)
|
||||
continue
|
||||
|
||||
if not stripped:
|
||||
@ -301,12 +459,161 @@ def find_org_file(org_files: Iterable[OrgFile], relative_path: str | None) -> Or
|
||||
return None
|
||||
|
||||
|
||||
_HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
|
||||
_ORG_TAG_RE = re.compile(r"\s+:([\w@:]+):\s*$")
|
||||
_DRAWER_RE = re.compile(r"^\s*:(\w+):\s*$")
|
||||
_DRAWER_END_RE = re.compile(r"^\s*:END:\s*$", re.IGNORECASE)
|
||||
_PROP_KV_RE = re.compile(r"^\s*:([A-Za-z_][\w-]*):\s+(.+?)\s*$")
|
||||
_TS_INLINE_RE = re.compile(r"[<\[](\d{4}-\d{2}-\d{2}[^\]>]*)[\]>]")
|
||||
_NOTE_TAKEN_RE = re.compile(
|
||||
r"^\s*[-+]\s+Note taken on\s+\[([^\]]+)\]\s*(?:\\\\)?\s*$"
|
||||
)
|
||||
|
||||
|
||||
def extract_heading_data(content: str, heading_text: str) -> dict | None:
|
||||
"""Find a heading in *content* by its text and extract webhook-ready data."""
|
||||
lines = content.splitlines()
|
||||
target_idx: int | None = None
|
||||
target_level = 0
|
||||
|
||||
for idx, raw_line in enumerate(lines):
|
||||
hm = _HEADING_RE.match(raw_line)
|
||||
if hm:
|
||||
stars = len(hm.group(1))
|
||||
rest = hm.group(2)
|
||||
kw_match = TODO_KEYWORD_RE.match(rest)
|
||||
bare_title = kw_match.group(2) if kw_match else rest
|
||||
tag_match = _ORG_TAG_RE.search(bare_title)
|
||||
bare_title_no_tags = bare_title[: tag_match.start()] if tag_match else bare_title
|
||||
if bare_title_no_tags.strip() == heading_text.strip():
|
||||
target_idx = idx
|
||||
target_level = stars
|
||||
break
|
||||
|
||||
if target_idx is None:
|
||||
return None
|
||||
|
||||
heading_line = lines[target_idx]
|
||||
hm = _HEADING_RE.match(heading_line)
|
||||
full_rest = hm.group(2) if hm else ""
|
||||
|
||||
kw_match = TODO_KEYWORD_RE.match(full_rest)
|
||||
state = kw_match.group(1) if kw_match else ""
|
||||
description = kw_match.group(2) if kw_match else full_rest
|
||||
|
||||
tag_match = _ORG_TAG_RE.search(description)
|
||||
tags: list[str] = []
|
||||
if tag_match:
|
||||
tags = [t for t in tag_match.group(1).split(":") if t]
|
||||
description = description[: tag_match.start()].strip()
|
||||
|
||||
sub_lines = lines[target_idx + 1 :]
|
||||
end = len(sub_lines)
|
||||
for i, sl in enumerate(sub_lines):
|
||||
sm = _HEADING_RE.match(sl)
|
||||
if sm and len(sm.group(1)) <= target_level:
|
||||
end = i
|
||||
break
|
||||
sub_lines = sub_lines[:end]
|
||||
|
||||
properties: dict[str, str] = {}
|
||||
drawers: dict[str, list[str]] = {}
|
||||
body_lines: list[str] = []
|
||||
timestamps: list[str] = []
|
||||
in_drawer: str | None = None
|
||||
drawer_lines: list[str] = []
|
||||
|
||||
for sl in sub_lines:
|
||||
stripped = sl.strip()
|
||||
if in_drawer is not None:
|
||||
if _DRAWER_END_RE.match(stripped):
|
||||
if in_drawer == "PROPERTIES":
|
||||
for dl in drawer_lines:
|
||||
pm = _PROP_KV_RE.match(dl)
|
||||
if pm:
|
||||
properties[pm.group(1)] = pm.group(2)
|
||||
else:
|
||||
drawers[in_drawer] = list(drawer_lines)
|
||||
in_drawer = None
|
||||
drawer_lines = []
|
||||
else:
|
||||
drawer_lines.append(sl)
|
||||
continue
|
||||
|
||||
dm = _DRAWER_RE.match(stripped)
|
||||
if dm:
|
||||
in_drawer = dm.group(1).upper()
|
||||
drawer_lines = []
|
||||
continue
|
||||
|
||||
for ts in _TS_INLINE_RE.findall(sl):
|
||||
timestamps.append(ts)
|
||||
|
||||
body_lines.append(sl)
|
||||
|
||||
emacs_id = properties.get("ID", "")
|
||||
notes = _extract_notes(body_lines)
|
||||
|
||||
return {
|
||||
"description": description,
|
||||
"labels": tags,
|
||||
"state": "STRT",
|
||||
"timestamps": timestamps,
|
||||
"notes": notes,
|
||||
"drawers": drawers,
|
||||
"emacs_id": emacs_id,
|
||||
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"source": "orgweb",
|
||||
"properties": properties,
|
||||
"body": "\n".join(body_lines).strip(),
|
||||
}
|
||||
|
||||
|
||||
def _parse_org_ts_to_iso(raw: str) -> str:
|
||||
"""Convert an org timestamp body like '2026-02-12 Thu 16:00' to ISO 8601."""
|
||||
m = re.match(
|
||||
r"(\d{4})-(\d{2})-(\d{2})(?:\s+\w{2,3})?(?:\s+(\d{2}):(\d{2}))?", raw
|
||||
)
|
||||
if not m:
|
||||
return raw.strip()
|
||||
parts = [m.group(1), "-", m.group(2), "-", m.group(3)]
|
||||
if m.group(4) is not None:
|
||||
parts += ["T", m.group(4), ":", m.group(5)]
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _extract_notes(body_lines: list[str]) -> list[dict[str, str]]:
|
||||
"""Extract '- Note taken on [TIMESTAMP]' entries and their content from body lines."""
|
||||
notes: list[dict[str, str]] = []
|
||||
i = 0
|
||||
while i < len(body_lines):
|
||||
nm = _NOTE_TAKEN_RE.match(body_lines[i])
|
||||
if nm:
|
||||
timestamp = _parse_org_ts_to_iso(nm.group(1))
|
||||
i += 1
|
||||
# skip one optional blank line after the note header
|
||||
if i < len(body_lines) and not body_lines[i].strip():
|
||||
i += 1
|
||||
content_lines: list[str] = []
|
||||
while i < len(body_lines):
|
||||
if _NOTE_TAKEN_RE.match(body_lines[i]):
|
||||
break
|
||||
content_lines.append(body_lines[i])
|
||||
i += 1
|
||||
content = "\n".join(content_lines).strip()
|
||||
notes.append({"timestamp": timestamp, "content": content})
|
||||
else:
|
||||
i += 1
|
||||
return notes
|
||||
|
||||
|
||||
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",
|
||||
show_webhook: bool = False,
|
||||
) -> str:
|
||||
org_files = list(org_files)
|
||||
|
||||
@ -333,9 +640,11 @@ def build_index_page(
|
||||
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)
|
||||
modified_ts = str(int(f.mtime))
|
||||
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"data-backlinks='{backlinks_count}' data-created-ts='{created_key_attr}' "
|
||||
f"data-modified-ts='{modified_ts}' 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>"
|
||||
@ -387,7 +696,7 @@ def build_index_page(
|
||||
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>"
|
||||
f"<article class='org-content'>{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, show_webhook=show_webhook)}</article>"
|
||||
)
|
||||
|
||||
template = TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
@ -398,7 +707,7 @@ def build_index_page(
|
||||
)
|
||||
|
||||
|
||||
def make_handler(base_dir: Path):
|
||||
def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = ""):
|
||||
class OrgRequestHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
@ -434,6 +743,7 @@ def make_handler(base_dir: Path):
|
||||
edit_mode=edit_mode,
|
||||
status_message=status_message,
|
||||
status_level=status_level,
|
||||
show_webhook=bool(webhook_url and webhook_token),
|
||||
)
|
||||
encoded = html_page.encode("utf-8")
|
||||
|
||||
@ -445,6 +755,9 @@ def make_handler(base_dir: Path):
|
||||
|
||||
def do_POST(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/webhook":
|
||||
self.handle_webhook()
|
||||
return
|
||||
if parsed.path != "/edit":
|
||||
self.send_error(404, "Not found")
|
||||
return
|
||||
@ -469,6 +782,58 @@ def make_handler(base_dir: Path):
|
||||
|
||||
self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "saved": "1"})
|
||||
|
||||
def handle_webhook(self) -> None:
|
||||
if not webhook_url:
|
||||
self.send_json_response(400, {"error": "No webhook_url configured"})
|
||||
return
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(content_length).decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self.send_json_response(400, {"error": "Invalid JSON"})
|
||||
return
|
||||
|
||||
file_path = payload.get("file", "")
|
||||
heading_text = payload.get("heading", "")
|
||||
if not file_path or not heading_text:
|
||||
self.send_json_response(400, {"error": "Missing file or heading"})
|
||||
return
|
||||
|
||||
org_files = scan_org_files(base_dir)
|
||||
org_file = find_org_file(org_files, file_path)
|
||||
if org_file is None:
|
||||
self.send_json_response(404, {"error": "File not found"})
|
||||
return
|
||||
|
||||
data = extract_heading_data(org_file.content, heading_text)
|
||||
if data is None:
|
||||
self.send_json_response(404, {"error": "Heading not found"})
|
||||
return
|
||||
|
||||
print(json.dumps(data))
|
||||
encoded_data = json.dumps(data).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if webhook_token:
|
||||
headers["Authorization"] = f"Token {webhook_token}"
|
||||
|
||||
try:
|
||||
req = Request(webhook_url, data=encoded_data, headers=headers, method="POST")
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
resp_body = resp.read().decode("utf-8", errors="replace")
|
||||
self.send_json_response(200, {"ok": True, "response": resp_body})
|
||||
except Exception as exc:
|
||||
self.send_json_response(502, {"error": f"Webhook failed: {exc}"})
|
||||
|
||||
def send_json_response(self, status: int, data: dict) -> None:
|
||||
encoded = json.dumps(data).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def redirect_with_query(self, path: str, params: dict[str, str]) -> None:
|
||||
location = f"{path}?{urlencode(params)}"
|
||||
self.send_response(303)
|
||||
@ -500,8 +865,9 @@ def make_handler(base_dir: Path):
|
||||
return OrgRequestHandler
|
||||
|
||||
|
||||
def serve(base_dir: Path, host: str, port: int, open_browser: bool = True) -> None:
|
||||
handler_cls = make_handler(base_dir)
|
||||
def serve(base_dir: Path, host: str, port: int, open_browser: bool = True,
|
||||
webhook_url: str = "", webhook_token: str = "") -> None:
|
||||
handler_cls = make_handler(base_dir, webhook_url=webhook_url, webhook_token=webhook_token)
|
||||
|
||||
class ReusableHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
@ -581,7 +947,15 @@ def load_runtime_config(config_path: Path) -> dict[str, str | int]:
|
||||
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}
|
||||
webhook_url = os.environ.get("ORGWEB_WEBHOOK_URL", "")
|
||||
webhook_token = os.environ.get("ORGWEB_WEBHOOK_TOKEN", "")
|
||||
|
||||
return {
|
||||
"bind_addr": bind_addr,
|
||||
"bind_port": bind_port,
|
||||
"webhook_url": webhook_url,
|
||||
"webhook_token": webhook_token,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@ -590,7 +964,12 @@ def main() -> None:
|
||||
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)
|
||||
serve(
|
||||
base_dir, host, port,
|
||||
open_browser=not args.no_browser,
|
||||
webhook_url=str(config.get("webhook_url", "")),
|
||||
webhook_token=str(config.get("webhook_token", "")),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
188
static/style.css
188
static/style.css
@ -323,6 +323,7 @@ main h2 {
|
||||
box-shadow: 0 8px 20px var(--shadow);
|
||||
}
|
||||
|
||||
.org-content h1,
|
||||
.org-content h2,
|
||||
.org-content h3,
|
||||
.org-content h4,
|
||||
@ -332,6 +333,11 @@ main h2 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.org-content h1 {
|
||||
margin-top: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.org-content p {
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
@ -346,6 +352,188 @@ main h2 {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.org-properties {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.org-properties dt {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
border-radius: 0.35rem 0 0 0.35rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-properties dd {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: var(--ink-soft);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-left: none;
|
||||
border-radius: 0 0.35rem 0.35rem 0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin: 0 0.6rem 0 0;
|
||||
}
|
||||
|
||||
.org-timestamp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-timestamp-inactive {
|
||||
opacity: 0.7;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.org-ts-date {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.org-ts-time {
|
||||
color: var(--ink-soft);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.org-todo {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.org-todo-todo {
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .org-todo-todo {
|
||||
color: #fbbf24;
|
||||
background: #422006;
|
||||
border-color: #92400e;
|
||||
}
|
||||
|
||||
.org-todo-strt {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
border: 1px solid #93c5fd;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .org-todo-strt {
|
||||
color: #60a5fa;
|
||||
background: #172554;
|
||||
border-color: #1e3a5f;
|
||||
}
|
||||
|
||||
.org-todo-done {
|
||||
color: #15803d;
|
||||
background: #dcfce7;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .org-todo-done {
|
||||
color: #4ade80;
|
||||
background: #052e16;
|
||||
border-color: #14532d;
|
||||
}
|
||||
|
||||
.org-todo-wait {
|
||||
color: #9333ea;
|
||||
background: #f3e8ff;
|
||||
border: 1px solid #d8b4fe;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .org-todo-wait {
|
||||
color: #c084fc;
|
||||
background: #2e1065;
|
||||
border-color: #581c87;
|
||||
}
|
||||
|
||||
.org-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin: 0.15rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.org-tag {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.webhook-send-btn {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--panel);
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
margin: 0.15rem 0 0.4rem;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.webhook-send-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.webhook-send-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.webhook-send-btn.webhook-sent {
|
||||
color: #15803d;
|
||||
border-color: #86efac;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .webhook-send-btn.webhook-sent {
|
||||
color: #4ade80;
|
||||
border-color: #14532d;
|
||||
background: #052e16;
|
||||
}
|
||||
|
||||
.webhook-send-btn.webhook-error {
|
||||
color: #c84a52;
|
||||
border-color: #c84a52;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
<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="sort-modified" class="shuffle-btn" type="button">Sort by modified date</button>
|
||||
<button id="jump-current" class="shuffle-btn" type="button">Jump to current note</button>
|
||||
</div>
|
||||
<div id="file-list">{{NAV_ITEMS}}</div>
|
||||
@ -73,6 +74,7 @@
|
||||
const shuffleNotesBtn = document.getElementById("shuffle-notes");
|
||||
const sortBacklinksBtn = document.getElementById("sort-backlinks");
|
||||
const sortCreatedBtn = document.getElementById("sort-created");
|
||||
const sortModifiedBtn = document.getElementById("sort-modified");
|
||||
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");
|
||||
@ -151,6 +153,58 @@
|
||||
});
|
||||
reorderLinks(links);
|
||||
});
|
||||
|
||||
sortModifiedBtn.addEventListener("click", function () {
|
||||
const links = Array.from(fileList.querySelectorAll(".file-link"));
|
||||
links.sort(function (a, b) {
|
||||
const aTs = Number.parseInt(a.getAttribute("data-modified-ts") || "0", 10);
|
||||
const bTs = Number.parseInt(b.getAttribute("data-modified-ts") || "0", 10);
|
||||
if (aTs !== bTs) {
|
||||
return bTs - aTs;
|
||||
}
|
||||
const aKey = a.getAttribute("data-search") || "";
|
||||
const bKey = b.getAttribute("data-search") || "";
|
||||
return aKey.localeCompare(bKey);
|
||||
});
|
||||
reorderLinks(links);
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
document.addEventListener("click", function (e) {
|
||||
const btn = e.target.closest(".webhook-send-btn");
|
||||
if (!btn) return;
|
||||
const file = btn.getAttribute("data-file");
|
||||
const heading = btn.getAttribute("data-heading");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Sending\u2026";
|
||||
fetch("/webhook", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ file: file, heading: heading }),
|
||||
})
|
||||
.then(function (resp) { return resp.json(); })
|
||||
.then(function (data) {
|
||||
if (data.ok) {
|
||||
btn.textContent = "Sent!";
|
||||
btn.classList.add("webhook-sent");
|
||||
} else {
|
||||
btn.textContent = "Error: " + (data.error || "unknown");
|
||||
btn.classList.add("webhook-error");
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
btn.textContent = "Failed";
|
||||
btn.classList.add("webhook-error");
|
||||
})
|
||||
.finally(function () {
|
||||
setTimeout(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Start work";
|
||||
btn.classList.remove("webhook-sent", "webhook-error");
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user