Add collapsible distinct org headers and top-of-file Inbox

This commit is contained in:
2026-08-07 21:11:35 -04:00
parent 7817336754
commit fa57387008
6 changed files with 694 additions and 55 deletions

361
main.py
View File

@ -331,6 +331,31 @@ def _replace_timestamps(escaped_html: str) -> str:
return result.replace("\x00", "<").replace("\x01", ">")
_CODE_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))=(?!\s)([\s\S]+?)(?<!\s)=(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_VERBATIM_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))~(?!\s)([\s\S]+?)(?<!\s)~(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_BOLD_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))\*(?!\s)([\s\S]+?)(?<!\s)\*(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_ITALIC_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))/(?!\s)([\s\S]+?)(?<!\s)/(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_UNDERLINE_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))_(?!\s)([\s\S]+?)(?<!\s)_(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_STRIKE_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))\+(?!\s)([\s\S]+?)(?<!\s)\+(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
def apply_inline_formatting(html_str: str) -> str:
"""Apply org inline text formatting (=code=, ~verbatim~, *bold*, /italic/, _underline_, +strike+) to text segments outside HTML tags."""
parts = re.split(r"(<[^>]+>)", html_str)
for i in range(0, len(parts), 2):
text = parts[i]
if not text:
continue
text = _CODE_RE.sub(r"<code class='org-code'>\1</code>", text)
text = _VERBATIM_RE.sub(r"<code class='org-code'>\1</code>", text)
text = _BOLD_RE.sub(r"<strong>\1</strong>", text)
text = _ITALIC_RE.sub(r"<em>\1</em>", text)
text = _UNDERLINE_RE.sub(r"<u>\1</u>", text)
text = _STRIKE_RE.sub(r"<del>\1</del>", text)
parts[i] = text
return "".join(parts)
def render_plain_text_with_links(text: str) -> str:
rendered_parts: list[str] = []
cursor = 0
@ -347,11 +372,16 @@ def render_plain_text_with_links(text: str) -> str:
return _replace_timestamps("".join(rendered_parts))
def _render_heading_with_todo(heading_text: str) -> tuple[str, str | None, str, list[str]]:
def _render_heading_with_todo(
heading_text: str, render_line_fn: Callable[[str], str] | None = None
) -> 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).
"""
if render_line_fn is None:
render_line_fn = html.escape
# Strip tags from the end first
tag_match = _ORG_TAG_RE.search(heading_text)
tags: list[str] = []
@ -365,9 +395,9 @@ def _render_heading_with_todo(heading_text: str) -> tuple[str, str | None, str,
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)}"
rendered = f"<span class='{css_class}'>{html.escape(keyword)}</span> {render_line_fn(rest)}"
return rendered, keyword, rest, tags
return html.escape(text_without_tags), None, text_without_tags.strip(), tags
return render_line_fn(text_without_tags), None, text_without_tags.strip(), tags
def render_line_with_links(
@ -389,15 +419,30 @@ def render_line_with_links(
safe_href = html.escape(note_href(resolved, False, path_to_slug), 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>")
elif raw_target.startswith(("http://", "https://", "mailto:")) or "://" in raw_target:
safe_href = html.escape(raw_target, quote=True)
link_text = label if label else raw_target
rendered_parts.append(f"<a class='org-link' href='{safe_href}' target='_blank' rel='noopener noreferrer'>{html.escape(link_text)}</a>")
else:
rendered_parts.append(render_plain_text_with_links(match.group(0)))
link_text = label if label else raw_target
rendered_parts.append(html.escape(link_text))
cursor = end
if cursor < len(line):
rendered_parts.append(render_plain_text_with_links(line[cursor:]))
return "".join(rendered_parts)
full_html = "".join(rendered_parts)
return apply_inline_formatting(full_html)
_BLOCK_START_RE = re.compile(r"^\s*#\+(BEGIN_SRC|BEGIN_EXAMPLE|BEGIN_QUOTE|BEGIN_EXPORT)\b\s*(.*)$", re.IGNORECASE)
_BLOCK_END_RE = re.compile(r"^\s*#\+(END_SRC|END_EXAMPLE|END_QUOTE|END_EXPORT)\b", re.IGNORECASE)
_TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
_TABLE_SEP_RE = re.compile(r"^\s*\|[-+=|]+\|\s*$")
_LIST_ITEM_RE = re.compile(r"^\s*(?:([-+])|(\d+)[\.\)])\s+(.*)$")
_HR_RE = re.compile(r"^\s*-{5,}\s*$")
_COMMENT_OR_KEYWORD_RE = re.compile(r"^\s*#(?:[+A-Za-z_]|\s)")
def render_org_to_html(
@ -405,65 +450,179 @@ def render_org_to_html(
path_to_slug: dict[str, str],
show_webhook: bool = False,
) -> str:
"""Very small org-ish renderer: headings become section titles, body keeps line breaks."""
"""Render org mode content to HTML, supporting headings, lists, tables, blocks, links, and inline markup."""
lines = content.splitlines()
html_lines: list[str] = []
section_stack: list[tuple[int, list[str], list[str]]] = []
i = 0
n = len(lines)
in_properties = False
property_items: list[tuple[str, str]] = []
lines = content.splitlines()
for raw_line in lines:
line = raw_line.rstrip("\n")
stripped = line.lstrip()
def render_line(l: str) -> str:
return render_line_with_links(l, source_relative_path, known_paths, id_to_path, path_to_slug)
def current_container() -> list[str]:
return html_lines if not section_stack else section_stack[-1][2]
def emit(*parts: str) -> None:
current_container().extend(parts)
def close_sections(min_level: int) -> None:
"""Close open sections whose heading level is >= min_level."""
while section_stack and section_stack[-1][0] >= min_level:
level, heading_lines, body_lines = section_stack.pop()
section_html = (
"\n".join(heading_lines)
+ "\n"
+ "\n".join(body_lines)
+ "\n</div>\n</section>"
)
current_container().append(section_html)
while i < n:
line = lines[i]
stripped = line.strip()
# Check PROPERTIES drawer
if re.match(r"^\s*:PROPERTIES:\s*$", stripped, re.IGNORECASE):
in_properties = True
property_items = []
i += 1
while i < n:
s = lines[i].strip()
if re.match(r"^\s*:END:\s*$", s, re.IGNORECASE):
in_properties = False
if property_items:
dl_items = [
f"<dt>{html.escape(k)}</dt><dd>{html.escape(v)}</dd>"
for k, v in property_items
]
emit(f"<dl class='org-properties'>{''.join(dl_items)}</dl>")
i += 1
break
prop_match = PROPERTY_LINE_RE.match(s)
if prop_match:
property_items.append((prop_match.group(1), prop_match.group(2)))
i += 1
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):
i += 1
continue
# Check TITLE
title_match = TITLE_RE.match(stripped)
if title_match:
html_lines.append(f"<h1>{html.escape(title_match.group(1))}</h1>")
emit(f"<h1>{html.escape(title_match.group(1))}</h1>")
i += 1
continue
# Check Code/Example/Quote/Export blocks
block_match = _BLOCK_START_RE.match(stripped)
if block_match:
block_kind = block_match.group(1).upper()
block_arg = block_match.group(2).strip()
block_lines: list[str] = []
i += 1
while i < n:
if _BLOCK_END_RE.match(lines[i].strip()):
i += 1
break
block_lines.append(lines[i])
i += 1
block_content = "\n".join(block_lines)
if block_kind == "BEGIN_SRC":
lang_cls = f" class='language-{html.escape(block_arg.lower())}'" if block_arg else ""
emit(f"<pre class='org-src-block'><code{lang_cls}>{html.escape(block_content)}</code></pre>")
elif block_kind == "BEGIN_EXAMPLE":
emit(f"<pre class='org-example'><code>{html.escape(block_content)}</code></pre>")
elif block_kind == "BEGIN_QUOTE":
quote_rendered = "\n".join(f"<p>{render_line(l)}</p>" for l in block_lines if l.strip())
emit(f"<blockquote class='org-quote'>{quote_rendered}</blockquote>")
# EXPORT is ignored
continue
# Check Table
if _TABLE_ROW_RE.match(line):
table_lines: list[str] = []
while i < n and _TABLE_ROW_RE.match(lines[i]):
table_lines.append(lines[i])
i += 1
rows: list[tuple[bool, list[str]]] = []
for tl in table_lines:
if _TABLE_SEP_RE.match(tl):
rows.append((True, []))
else:
parts = [c.strip() for c in tl.strip().split("|")]
if parts and parts[0] == "":
parts.pop(0)
if parts and parts[-1] == "":
parts.pop()
rows.append((False, [render_line(c) for c in parts]))
has_header = len(rows) >= 2 and not rows[0][0] and rows[1][0]
table_html: list[str] = ["<table class='org-table'>"]
if has_header:
table_html.append("<thead><tr>")
for cell in rows[0][1]:
table_html.append(f"<th>{cell}</th>")
table_html.append("</tr></thead>")
body_rows = rows[2:]
else:
body_rows = rows
table_html.append("<tbody>")
for is_sep, cells in body_rows:
if is_sep:
continue
table_html.append("<tr>")
for cell in cells:
table_html.append(f"<td>{cell}</td>")
table_html.append("</tr>")
table_html.append("</tbody></table>")
emit("".join(table_html))
continue
# Check Headings (* at column 0 or star + space)
if stripped.startswith("*"):
stars = len(stripped) - len(stripped.lstrip("*"))
if stars > 0 and len(stripped) > stars and stripped[stars] == " ":
level = min(stars + 1, 6)
close_sections(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}>")
rendered_title, keyword, bare_text, heading_tags = _render_heading_with_todo(
heading_text, render_line_fn=render_line
)
starts_collapsed = level >= 3
section_class = "org-section" + (" collapsed" if starts_collapsed else "")
toggle_glyph = "&#x25B8;" if starts_collapsed else "&#x25BE;"
toggle_expanded = "false" if starts_collapsed else "true"
toggle_title = "Expand section" if starts_collapsed else "Collapse section"
heading_lines: list[str] = [
f"<section class='{section_class}' data-level='{level}'>",
(
f"<h{level} class='org-heading' data-level='{level}'>"
f"<button class='org-collapse-toggle' type='button' aria-expanded='{toggle_expanded}' "
f"title='{toggle_title}'>{toggle_glyph}</button>"
f"<span class='org-heading-text'>{rendered_title}</span>"
f"</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>")
heading_lines.append(f"<div class='org-tags'>{tag_spans}</div>")
if keyword and show_webhook:
safe_file = html.escape(source_relative_path, quote=True)
safe_heading = html.escape(bare_text, quote=True)
html_lines.append(
heading_lines.append(
f"<button class='webhook-send-btn' type='button' "
f"data-file='{safe_file}' data-heading='{safe_heading}' "
f"data-state='STRT'>Start work</button> "
@ -471,14 +630,95 @@ def render_org_to_html(
f"data-file='{safe_file}' data-heading='{safe_heading}' "
f"data-state='DONE'>End work</button>"
)
heading_lines.append("<div class='org-section-body'>")
section_stack.append((level, heading_lines, []))
i += 1
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, path_to_slug)
html_lines.append(f"<p>{rendered_line}</p>")
# Check Horizontal Rule
if _HR_RE.match(stripped):
emit("<hr class='org-hr'>")
i += 1
continue
# Check Lists (Unordered & Ordered)
list_match = _LIST_ITEM_RE.match(line)
if list_match:
is_ordered = list_match.group(2) is not None
list_items: list[str] = []
tag_name = "ol" if is_ordered else "ul"
while i < n:
lm = _LIST_ITEM_RE.match(lines[i])
if lm:
item_ordered = lm.group(2) is not None
if item_ordered != is_ordered:
break
item_text = lm.group(3)
i += 1
while (
i < n
and lines[i].strip()
and not _LIST_ITEM_RE.match(lines[i])
and not lines[i].startswith("*")
and not _BLOCK_START_RE.match(lines[i].strip())
and not _TABLE_ROW_RE.match(lines[i])
):
item_text += " " + lines[i].strip()
i += 1
list_items.append(render_line(item_text))
else:
break
items_html = "".join(f"<li>{item}</li>" for item in list_items)
emit(f"<{tag_name} class='org-list'>{items_html}</{tag_name}>")
continue
# Skip comment or unrecognized directive lines (#+CREATED:, #+filetags:, # ...)
if _COMMENT_OR_KEYWORD_RE.match(stripped):
i += 1
continue
# Empty lines
if not stripped:
emit("<div class='spacer'></div>")
i += 1
continue
# Regular Paragraph (group consecutive soft-wrapped lines so
# multi-line org emphasis like *bold spanning lines* can match)
paragraph_lines: list[str] = [line]
i += 1
while i < n:
nxt = lines[i]
nxt_stripped = nxt.strip()
if not nxt_stripped:
break
if _BLOCK_START_RE.match(nxt_stripped):
break
if _TABLE_ROW_RE.match(nxt):
break
if _LIST_ITEM_RE.match(nxt):
break
if _HR_RE.match(nxt_stripped):
break
if _COMMENT_OR_KEYWORD_RE.match(nxt_stripped):
break
if TITLE_RE.match(nxt_stripped):
break
if re.match(r"^\s*:PROPERTIES:\s*$", nxt_stripped, re.IGNORECASE):
break
if nxt_stripped.startswith("*"):
stars = len(nxt_stripped) - len(nxt_stripped.lstrip("*"))
if stars > 0 and len(nxt_stripped) > stars and nxt_stripped[stars] == " ":
break
paragraph_lines.append(nxt)
i += 1
paragraph_text = "\n".join(p.rstrip() for p in paragraph_lines)
emit(f"<p>{render_line(paragraph_text)}</p>")
close_sections(1)
return "\n".join(html_lines)
@ -638,9 +878,12 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "", sta
def add_to_inbox(content: str, text: str) -> str:
"""Append a '** TODO <text>' item under a top-level '* Inbox' section.
"""Add a '** TODO <text>' item under a top-level '* Inbox' section.
Creates the Inbox section at the bottom of the file if it does not exist.
The Inbox is kept as the first top-level heading in the file: if it is
missing it is created there (right after any file preamble such as
#+TITLE or #+CREATED), and if it already exists further down it is
moved to the top before the new task is added.
"""
text = text.strip()
if not text:
@ -650,22 +893,38 @@ def add_to_inbox(content: str, text: str) -> str:
top_level_re = re.compile(r"^\*\s")
lines = content.rstrip("\n").splitlines()
def first_top_level() -> int:
for idx, line in enumerate(lines):
if top_level_re.match(line):
return idx
return len(lines)
def section_end(start: int) -> int:
for idx in range(start + 1, len(lines)):
if top_level_re.match(lines[idx]):
return idx
return len(lines)
inbox_idx: int | None = None
for idx, line in enumerate(lines):
if inbox_re.match(line):
inbox_idx = idx
if inbox_idx is None:
lines.append("* Inbox")
inbox_idx = len(lines) - 1
end = len(lines)
for idx in range(inbox_idx + 1, len(lines)):
if top_level_re.match(lines[idx]):
end = idx
break
lines.insert(end, new_task)
insert_at = first_top_level()
if inbox_idx is None:
lines.insert(insert_at, "* Inbox")
inbox_idx = insert_at
elif inbox_idx != insert_at:
end = section_end(inbox_idx)
inbox_block = lines[inbox_idx:end]
del lines[inbox_idx:end]
lines[insert_at:insert_at] = inbox_block
inbox_idx = insert_at
lines.insert(section_end(inbox_idx), new_task)
return "\n".join(lines) + "\n"