Add collapsible distinct org headers and top-of-file Inbox
This commit is contained in:
@ -127,6 +127,7 @@ python3 main.py --no-browser
|
||||
- Desktop: independent scrollable sidebars.
|
||||
- Mobile: notes list capped with its own scroll area.
|
||||
- Sidebar text truncation with ellipsis and tooltip for full text.
|
||||
- Distinct, level-styled org headers that collapse/expand their section (and all nested subsections) on click.
|
||||
|
||||
## Project layout
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
|
||||
160
static/style.css
160
static/style.css
@ -443,8 +443,80 @@ main h2 {
|
||||
}
|
||||
|
||||
.org-content h1 {
|
||||
margin-top: 0;
|
||||
font-size: 1.4rem;
|
||||
margin: 0 0 0.9rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 0.45rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent-soft) 25%, var(--panel));
|
||||
color: var(--ink);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.org-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin: 1.25rem 0 0.35rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.45rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent-soft) 30%, var(--panel));
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.org-content h2.org-heading { font-size: 1.15rem; }
|
||||
.org-content h3.org-heading { font-size: 0.95rem; }
|
||||
.org-content h4.org-heading { font-size: 0.9rem; }
|
||||
.org-content h5.org-heading,
|
||||
.org-content h6.org-heading { font-size: 0.85rem; }
|
||||
|
||||
.org-heading:hover {
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||
}
|
||||
|
||||
.org-heading-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.org-collapse-toggle {
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
padding: 0.15rem 0.2rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.org-collapse-toggle:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.org-section {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.org-section-body {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.org-section-body .org-section {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.org-section.collapsed > .org-section-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.org-section.collapsed > .org-heading {
|
||||
border-left-style: dashed;
|
||||
background: color-mix(in srgb, var(--accent-soft) 12%, var(--panel));
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.org-content p {
|
||||
@ -643,6 +715,90 @@ body[data-theme="dark"] .webhook-send-btn.webhook-sent {
|
||||
border-color: #c84a52;
|
||||
}
|
||||
|
||||
.org-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
background: color-mix(in srgb, var(--accent-soft) 45%, var(--panel));
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.1em 0.35em;
|
||||
}
|
||||
|
||||
.org-src-block,
|
||||
.org-example {
|
||||
background: var(--nav-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.org-src-block code,
|
||||
.org-example code {
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.org-quote {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-left: 4px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent-soft) 20%, var(--panel));
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.org-quote p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.org-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.85rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.org-table th,
|
||||
.org-table td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.org-table th {
|
||||
background: var(--nav-bg);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.org-table tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--nav-bg) 30%, var(--panel));
|
||||
}
|
||||
|
||||
.org-list {
|
||||
margin: 0.5rem 0 0.5rem 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.org-list li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.org-hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
15
static/sw.js
15
static/sw.js
@ -1,4 +1,4 @@
|
||||
const CACHE = "orgweb-v1";
|
||||
const CACHE = "orgweb-v2";
|
||||
const PRECACHE = [
|
||||
"/",
|
||||
"/static/style.css",
|
||||
@ -39,6 +39,19 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/static/style.css") {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((resp) => {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(
|
||||
(cached) =>
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<link rel="manifest" href="/static/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||
<link rel="icon" type="image/png" href="/static/icon-192.png">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@ -356,6 +356,24 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener("click", function (e) {
|
||||
if (e.target.closest("a")) return;
|
||||
var heading = e.target.closest(".org-heading");
|
||||
if (!heading) return;
|
||||
var section = heading.parentElement;
|
||||
if (!section || !section.classList.contains("org-section")) return;
|
||||
var collapsed = section.classList.toggle("collapsed");
|
||||
var btn = heading.querySelector(".org-collapse-toggle");
|
||||
if (btn) {
|
||||
btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
btn.title = collapsed ? "Expand section" : "Collapse section";
|
||||
btn.innerHTML = collapsed ? "▸" : "▾";
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", function () {
|
||||
|
||||
192
test_main.py
Normal file
192
test_main.py
Normal file
@ -0,0 +1,192 @@
|
||||
import unittest
|
||||
import main
|
||||
|
||||
|
||||
class TestOrgParser(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.known_paths = {"note1.org", "note2.org"}
|
||||
self.id_to_path = {"id-123": "note1.org"}
|
||||
self.path_to_slug = {"note1.org": "id-123", "note2.org": "note2.org"}
|
||||
|
||||
def render(self, content, show_webhook=False):
|
||||
return main.render_org_to_html(
|
||||
content=content,
|
||||
source_relative_path="source.org",
|
||||
known_paths=self.known_paths,
|
||||
id_to_path=self.id_to_path,
|
||||
path_to_slug=self.path_to_slug,
|
||||
show_webhook=show_webhook,
|
||||
)
|
||||
|
||||
def test_external_links(self):
|
||||
content = "See [[https://hungryroot.atlassian.net/browse/BE-7924][BE-7924]] for details."
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<a class='org-link' href='https://hungryroot.atlassian.net/browse/BE-7924' target='_blank' rel='noopener noreferrer'>BE-7924</a>", rendered)
|
||||
|
||||
def test_internal_links(self):
|
||||
content = "Check [[note1.org][Note 1]] or [[id:id-123][Note 1 ID]]."
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<a class='org-link' href='/n/id-123'>Note 1</a>", rendered)
|
||||
self.assertIn("<a class='org-link' href='/n/id-123'>Note 1 ID</a>", rendered)
|
||||
|
||||
def test_inline_formatting(self):
|
||||
content = "Added flat replacements (=author_id=, ~python~, *always present*, /italic text/, _underlined_, +deleted+)."
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<code class='org-code'>author_id</code>", rendered)
|
||||
self.assertIn("<code class='org-code'>python</code>", rendered)
|
||||
self.assertIn("<strong>always present</strong>", rendered)
|
||||
self.assertIn("<em>italic text</em>", rendered)
|
||||
self.assertIn("<u>underlined</u>", rendered)
|
||||
self.assertIn("<del>deleted</del>", rendered)
|
||||
|
||||
def test_code_blocks(self):
|
||||
content = "#+begin_src python\ndef hello():\n return 'world'\n#+end_src"
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<pre class='org-src-block'><code class='language-python'>def hello():\n return 'world'</code></pre>", rendered)
|
||||
|
||||
def test_tables(self):
|
||||
content = (
|
||||
"| Field | List default |\n"
|
||||
"|-------+--------------|\n"
|
||||
"| =author= | present |\n"
|
||||
)
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<table class='org-table'>", rendered)
|
||||
self.assertIn("<thead><tr><th>Field</th><th>List default</th></tr></thead>", rendered)
|
||||
self.assertIn("<tbody><tr><td><code class='org-code'>author</code></td><td>present</td></tr></tbody>", rendered)
|
||||
|
||||
def test_lists(self):
|
||||
content = "- =GET /api/v3/cookbooks/=\n- =GET /api/v3/cookbooks/<slug>/="
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<ul class='org-list'>", rendered)
|
||||
self.assertIn("<li><code class='org-code'>GET /api/v3/cookbooks/</code></li>", rendered)
|
||||
|
||||
ordered_content = "1. Item one\n2. Item two"
|
||||
ordered_rendered = self.render(ordered_content)
|
||||
self.assertIn("<ol class='org-list'>", ordered_rendered)
|
||||
self.assertIn("<li>Item one</li>", ordered_rendered)
|
||||
|
||||
def test_comments_ignored(self):
|
||||
content = "#+CREATED: [2026-08-04]\n# This is a comment\nReal text"
|
||||
rendered = self.render(content)
|
||||
self.assertNotIn("#+CREATED", rendered)
|
||||
self.assertNotIn("This is a comment", rendered)
|
||||
self.assertIn("<p>Real text</p>", rendered)
|
||||
|
||||
def test_headings_with_todo_and_tags(self):
|
||||
content = "* TODO Fix auth issue :bug:backend:"
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<h2", rendered)
|
||||
self.assertIn("<span class='org-todo org-todo-todo'>TODO</span>", rendered)
|
||||
self.assertIn("Fix auth issue", rendered)
|
||||
self.assertIn("<span class='org-tag'>bug</span>", rendered)
|
||||
self.assertIn("<span class='org-tag'>backend</span>", rendered)
|
||||
|
||||
def test_headings_build_collapsible_sections(self):
|
||||
content = (
|
||||
"#+TITLE: Doc title\n"
|
||||
"* Top\n"
|
||||
"Some content.\n"
|
||||
"** Sub\n"
|
||||
"Deeper content.\n"
|
||||
"* Sibling\n"
|
||||
"Sibling content.\n"
|
||||
)
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<h1>Doc title</h1>", rendered)
|
||||
self.assertIn("<section class='org-section' data-level='2'>", rendered)
|
||||
self.assertIn("<section class='org-section collapsed' data-level='3'>", rendered)
|
||||
self.assertIn("class='org-heading'", rendered)
|
||||
self.assertIn("class='org-collapse-toggle'", rendered)
|
||||
self.assertIn("class='org-section-body'", rendered)
|
||||
self.assertIn("<p>Some content.</p>", rendered)
|
||||
self.assertIn("<p>Deeper content.</p>", rendered)
|
||||
self.assertIn("<p>Sibling content.</p>", rendered)
|
||||
self.assertNotIn("<section class='org-section' data-level='2'></section>", rendered)
|
||||
|
||||
def test_level3_and_deeper_start_collapsed(self):
|
||||
content = (
|
||||
"* One\n"
|
||||
"** Sub\n"
|
||||
"Body A\n"
|
||||
"*** Deep\n"
|
||||
"Body B\n"
|
||||
"* Two\n"
|
||||
"Body C\n"
|
||||
)
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<section class='org-section' data-level='2'>", rendered)
|
||||
self.assertIn("<section class='org-section collapsed' data-level='3'>", rendered)
|
||||
self.assertIn("<section class='org-section collapsed' data-level='4'>", rendered)
|
||||
self.assertIn("aria-expanded='false'", rendered)
|
||||
self.assertIn(">▸</button>", rendered)
|
||||
|
||||
def test_nested_section_contains_deeper_heading(self):
|
||||
content = "* Top\n** Sub\nDeep text\n* End\nTail text"
|
||||
rendered = self.render(content)
|
||||
top = rendered.index("<section class='org-section' data-level='2'>")
|
||||
sub = rendered.index("<section class='org-section collapsed' data-level='3'>")
|
||||
end = rendered.index("<section class='org-section' data-level='2'>", top + 1)
|
||||
self.assertTrue(top < sub < end)
|
||||
|
||||
def test_heading_stars_capped_at_level_6(self):
|
||||
content = "****** Deep heading\nBody text"
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<h6 class='org-heading' data-level='6'>", rendered)
|
||||
self.assertIn("<p>Body text</p>", rendered)
|
||||
|
||||
def test_multiline_emphasis(self):
|
||||
content = '*The safe ordering is always "clients first, flag flips\nsecond." Until then.*'
|
||||
rendered = self.render(content)
|
||||
self.assertIn(
|
||||
"<strong>The safe ordering is always "clients first, flag flips\nsecond." Until then.</strong>",
|
||||
rendered,
|
||||
)
|
||||
|
||||
def test_code_with_internal_marker(self):
|
||||
content = "Request =?expand=pairing_ids,product_ids= on the list endpoint."
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<code class='org-code'>?expand=pairing_ids,product_ids</code>", rendered)
|
||||
|
||||
def test_soft_wrapped_paragraph_is_single_p(self):
|
||||
content = "First line of prose\nsecond line of prose\n\nNext paragraph"
|
||||
rendered = self.render(content)
|
||||
self.assertIn("<p>First line of prose\nsecond line of prose</p>", rendered)
|
||||
self.assertIn("<p>Next paragraph</p>", rendered)
|
||||
|
||||
def test_add_to_inbox_creates_inbox_at_top_after_preamble(self):
|
||||
content = "#+TITLE: Notes\n* Work\nSome text.\n"
|
||||
updated = main.add_to_inbox(content, "Call dentist")
|
||||
lines = updated.splitlines()
|
||||
self.assertEqual(lines[0], "#+TITLE: Notes")
|
||||
self.assertEqual(lines[1], "* Inbox")
|
||||
self.assertEqual(lines[2], "** TODO Call dentist")
|
||||
self.assertEqual(lines[3], "* Work")
|
||||
self.assertEqual(lines[4], "Some text.")
|
||||
|
||||
def test_add_to_inbox_appends_to_existing_inbox(self):
|
||||
content = "* Inbox\n** TODO Existing\n* Work\n"
|
||||
updated = main.add_to_inbox(content, "New task")
|
||||
lines = updated.splitlines()
|
||||
self.assertEqual(lines[0], "* Inbox")
|
||||
self.assertEqual(lines[1], "** TODO Existing")
|
||||
self.assertEqual(lines[2], "** TODO New task")
|
||||
self.assertEqual(lines[3], "* Work")
|
||||
|
||||
def test_add_to_inbox_moves_existing_inbox_to_top(self):
|
||||
content = "* Work\nSome text.\n* Inbox\n** TODO Old task\n"
|
||||
updated = main.add_to_inbox(content, "New task")
|
||||
lines = updated.splitlines()
|
||||
self.assertEqual(lines[0], "* Inbox")
|
||||
self.assertEqual(lines[1], "** TODO Old task")
|
||||
self.assertEqual(lines[2], "** TODO New task")
|
||||
self.assertEqual(lines[3], "* Work")
|
||||
self.assertEqual(lines[4], "Some text.")
|
||||
|
||||
def test_add_to_inbox_blank_text_is_noop(self):
|
||||
content = "* Inbox\n** TODO Keep\n"
|
||||
self.assertEqual(main.add_to_inbox(content, " "), content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user