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 argparse
|
||||||
import html
|
import html
|
||||||
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -10,10 +11,12 @@ import socketserver
|
|||||||
import threading
|
import threading
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -23,6 +26,7 @@ class OrgFile:
|
|||||||
title: str
|
title: str
|
||||||
file_id: str | None
|
file_id: str | None
|
||||||
content: str
|
content: str
|
||||||
|
mtime: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parent
|
ROOT_DIR = Path(__file__).resolve().parent
|
||||||
@ -34,6 +38,16 @@ ORG_LINK_RE = re.compile(r"\[\[([^\]]+)\](?:\[([^\]]*)\])?\]")
|
|||||||
URL_RE = re.compile(r"https?://[^\s<>'\"()\[\]]+")
|
URL_RE = re.compile(r"https?://[^\s<>'\"()\[\]]+")
|
||||||
CREATED_LINE_RE = re.compile(r"^\s*(?:#\+)?CREATED:\s*(.+?)\s*$", flags=re.IGNORECASE)
|
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}))?[\]>]")
|
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"
|
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),
|
title=extract_org_title(content, file_path.stem),
|
||||||
file_id=extract_org_id(content),
|
file_id=extract_org_id(content),
|
||||||
content=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
|
return org_files
|
||||||
|
|
||||||
|
|
||||||
@ -220,6 +235,75 @@ def truncate_label(text: str, max_chars: int = 32) -> str:
|
|||||||
return text[: max_chars - 3] + "..."
|
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:
|
def render_plain_text_with_links(text: str) -> str:
|
||||||
rendered_parts: list[str] = []
|
rendered_parts: list[str] = []
|
||||||
cursor = 0
|
cursor = 0
|
||||||
@ -233,7 +317,30 @@ def render_plain_text_with_links(text: str) -> str:
|
|||||||
cursor = end
|
cursor = end
|
||||||
if cursor < len(text):
|
if cursor < len(text):
|
||||||
rendered_parts.append(html.escape(text[cursor:]))
|
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(
|
def render_line_with_links(
|
||||||
@ -266,21 +373,72 @@ def render_line_with_links(
|
|||||||
|
|
||||||
|
|
||||||
def render_org_to_html(
|
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:
|
) -> str:
|
||||||
"""Very small org-ish renderer: headings become section titles, body keeps line breaks."""
|
"""Very small org-ish renderer: headings become section titles, body keeps line breaks."""
|
||||||
html_lines: list[str] = []
|
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")
|
line = raw_line.rstrip("\n")
|
||||||
stripped = line.lstrip()
|
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("*"):
|
if stripped.startswith("*"):
|
||||||
stars = len(stripped) - len(stripped.lstrip("*"))
|
stars = len(stripped) - len(stripped.lstrip("*"))
|
||||||
if stars > 0 and len(stripped) > stars and stripped[stars] == " ":
|
if stars > 0 and len(stripped) > stars and stripped[stars] == " ":
|
||||||
level = min(stars + 1, 6)
|
level = min(stars + 1, 6)
|
||||||
title = html.escape(stripped[stars + 1 :])
|
heading_text = stripped[stars + 1 :]
|
||||||
html_lines.append(f"<h{level}>{title}</h{level}>")
|
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
|
continue
|
||||||
|
|
||||||
if not stripped:
|
if not stripped:
|
||||||
@ -301,12 +459,161 @@ def find_org_file(org_files: Iterable[OrgFile], relative_path: str | None) -> Or
|
|||||||
return None
|
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(
|
def build_index_page(
|
||||||
org_files: Iterable[OrgFile],
|
org_files: Iterable[OrgFile],
|
||||||
selected_path: str | None = None,
|
selected_path: str | None = None,
|
||||||
edit_mode: bool = False,
|
edit_mode: bool = False,
|
||||||
status_message: str | None = None,
|
status_message: str | None = None,
|
||||||
status_level: str = "success",
|
status_level: str = "success",
|
||||||
|
show_webhook: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
org_files = list(org_files)
|
org_files = list(org_files)
|
||||||
|
|
||||||
@ -333,9 +640,11 @@ def build_index_page(
|
|||||||
backlinks_count = backlink_counts.get(f.relative_path, 0)
|
backlinks_count = backlink_counts.get(f.relative_path, 0)
|
||||||
created_key = extract_created_sort_key(f.content)
|
created_key = extract_created_sort_key(f.content)
|
||||||
created_key_attr = "" if created_key is None else str(created_key)
|
created_key_attr = "" if created_key is None else str(created_key)
|
||||||
|
modified_ts = str(int(f.mtime))
|
||||||
nav_items.append(
|
nav_items.append(
|
||||||
f"<a class='file-link {active}' data-search='{searchable}' "
|
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-title' title='{full_title}'>{safe_title}</span>"
|
||||||
f"<span class='file-path' title='{full_path}'>{safe_path}</span>"
|
f"<span class='file-path' title='{full_path}'>{safe_path}</span>"
|
||||||
"</a>"
|
"</a>"
|
||||||
@ -387,7 +696,7 @@ def build_index_page(
|
|||||||
f"<h2>{html.escape(selected.relative_path)}</h2>"
|
f"<h2>{html.escape(selected.relative_path)}</h2>"
|
||||||
f"<div class='toolbar'>{mode_toggle}</div>"
|
f"<div class='toolbar'>{mode_toggle}</div>"
|
||||||
f"{status_html}"
|
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")
|
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):
|
class OrgRequestHandler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
@ -434,6 +743,7 @@ def make_handler(base_dir: Path):
|
|||||||
edit_mode=edit_mode,
|
edit_mode=edit_mode,
|
||||||
status_message=status_message,
|
status_message=status_message,
|
||||||
status_level=status_level,
|
status_level=status_level,
|
||||||
|
show_webhook=bool(webhook_url and webhook_token),
|
||||||
)
|
)
|
||||||
encoded = html_page.encode("utf-8")
|
encoded = html_page.encode("utf-8")
|
||||||
|
|
||||||
@ -445,6 +755,9 @@ def make_handler(base_dir: Path):
|
|||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == "/webhook":
|
||||||
|
self.handle_webhook()
|
||||||
|
return
|
||||||
if parsed.path != "/edit":
|
if parsed.path != "/edit":
|
||||||
self.send_error(404, "Not found")
|
self.send_error(404, "Not found")
|
||||||
return
|
return
|
||||||
@ -469,6 +782,58 @@ def make_handler(base_dir: Path):
|
|||||||
|
|
||||||
self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "saved": "1"})
|
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:
|
def redirect_with_query(self, path: str, params: dict[str, str]) -> None:
|
||||||
location = f"{path}?{urlencode(params)}"
|
location = f"{path}?{urlencode(params)}"
|
||||||
self.send_response(303)
|
self.send_response(303)
|
||||||
@ -500,8 +865,9 @@ def make_handler(base_dir: Path):
|
|||||||
return OrgRequestHandler
|
return OrgRequestHandler
|
||||||
|
|
||||||
|
|
||||||
def serve(base_dir: Path, host: str, port: int, open_browser: bool = True) -> None:
|
def serve(base_dir: Path, host: str, port: int, open_browser: bool = True,
|
||||||
handler_cls = make_handler(base_dir)
|
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):
|
class ReusableHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
@ -581,7 +947,15 @@ def load_runtime_config(config_path: Path) -> dict[str, str | int]:
|
|||||||
if not 1 <= bind_port <= 65535:
|
if not 1 <= bind_port <= 65535:
|
||||||
raise ValueError(f"bind_port out of range in {config_path}: {bind_port}")
|
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:
|
def main() -> None:
|
||||||
@ -590,7 +964,12 @@ def main() -> None:
|
|||||||
base_dir = Path(args.dir).resolve()
|
base_dir = Path(args.dir).resolve()
|
||||||
host = args.host if args.host is not None else str(config["bind_addr"])
|
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"])
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
188
static/style.css
188
static/style.css
@ -323,6 +323,7 @@ main h2 {
|
|||||||
box-shadow: 0 8px 20px var(--shadow);
|
box-shadow: 0 8px 20px var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-content h1,
|
||||||
.org-content h2,
|
.org-content h2,
|
||||||
.org-content h3,
|
.org-content h3,
|
||||||
.org-content h4,
|
.org-content h4,
|
||||||
@ -332,6 +333,11 @@ main h2 {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-content h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.org-content p {
|
.org-content p {
|
||||||
margin: 0.35rem 0;
|
margin: 0.35rem 0;
|
||||||
}
|
}
|
||||||
@ -346,6 +352,188 @@ main h2 {
|
|||||||
filter: brightness(1.12);
|
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 {
|
.spacer {
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
<button id="shuffle-notes" class="shuffle-btn" type="button">Shuffle notes</button>
|
<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-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-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>
|
<button id="jump-current" class="shuffle-btn" type="button">Jump to current note</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="file-list">{{NAV_ITEMS}}</div>
|
<div id="file-list">{{NAV_ITEMS}}</div>
|
||||||
@ -73,6 +74,7 @@
|
|||||||
const shuffleNotesBtn = document.getElementById("shuffle-notes");
|
const shuffleNotesBtn = document.getElementById("shuffle-notes");
|
||||||
const sortBacklinksBtn = document.getElementById("sort-backlinks");
|
const sortBacklinksBtn = document.getElementById("sort-backlinks");
|
||||||
const sortCreatedBtn = document.getElementById("sort-created");
|
const sortCreatedBtn = document.getElementById("sort-created");
|
||||||
|
const sortModifiedBtn = document.getElementById("sort-modified");
|
||||||
const jumpCurrentBtn = document.getElementById("jump-current");
|
const jumpCurrentBtn = document.getElementById("jump-current");
|
||||||
const fileLinks = Array.from(document.querySelectorAll("#file-list .file-link"));
|
const fileLinks = Array.from(document.querySelectorAll("#file-list .file-link"));
|
||||||
const activeFileLink = document.querySelector("#file-list .file-link.active");
|
const activeFileLink = document.querySelector("#file-list .file-link.active");
|
||||||
@ -151,6 +153,58 @@
|
|||||||
});
|
});
|
||||||
reorderLinks(links);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user