Add pinned today, webhook support and style changes

This commit is contained in:
2026-02-16 17:15:53 -05:00
parent bc4e6b8a56
commit c5ddf68ea1
3 changed files with 634 additions and 13 deletions

405
main.py
View File

@ -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 &lt; / &gt;.
We temporarily swap them to sentinel bytes so the regex can match uniformly.
"""
result = escaped_html.replace("&lt;", "\x00").replace("&gt;", "\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", "&lt;").replace("\x01", "&gt;")
result = _ESCAPED_TS_RE.sub(_sub, result)
return result.replace("\x00", "&lt;").replace("\x01", "&gt;")
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__":

View File

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

View File

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