Add collapsible distinct org headers and top-of-file Inbox
This commit is contained in:
361
main.py
361
main.py
@ -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 = "▸" if starts_collapsed else "▾"
|
||||
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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user