diff --git a/README.md b/README.md
index 743bcc9..209f269 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/main.py b/main.py
index 15e13ec..f227359 100644
--- a/main.py
+++ b/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]+?)(? 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"\1", text)
+ text = _VERBATIM_RE.sub(r"\1", text)
+ text = _BOLD_RE.sub(r"\1 ", text)
+ text = _ITALIC_RE.sub(r"\1 ", text)
+ text = _UNDERLINE_RE.sub(r"\1 ", text)
+ text = _STRIKE_RE.sub(r"\1", 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"{html.escape(keyword)} {html.escape(rest)}"
+ rendered = f"{html.escape(keyword)} {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"{html.escape(link_text)} ")
+ 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"{html.escape(link_text)} ")
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\n"
+ )
+ 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"
{html.escape(k)} {html.escape(v)} "
+ for k, v in property_items
+ ]
+ emit(f"{''.join(dl_items)} ")
+ 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"{html.escape(key)} "
- f"{html.escape(value)} "
- )
- html_lines.append(
- "" + "".join(dl_items) + " "
- )
- 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"{html.escape(title_match.group(1))} ")
+ emit(f"{html.escape(title_match.group(1))} ")
+ 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"{html.escape(block_content)} ")
+ elif block_kind == "BEGIN_EXAMPLE":
+ emit(f"{html.escape(block_content)} ")
+ elif block_kind == "BEGIN_QUOTE":
+ quote_rendered = "\n".join(f"{render_line(l)}
" for l in block_lines if l.strip())
+ emit(f"{quote_rendered} ")
+ # 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] = [""]
+
+ if has_header:
+ table_html.append("")
+ for cell in rows[0][1]:
+ table_html.append(f"{cell} ")
+ table_html.append(" ")
+ body_rows = rows[2:]
+ else:
+ body_rows = rows
+
+ table_html.append("")
+ for is_sep, cells in body_rows:
+ if is_sep:
+ continue
+ table_html.append("")
+ for cell in cells:
+ table_html.append(f"{cell} ")
+ table_html.append(" ")
+ table_html.append("
")
+ 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"{rendered_title} ")
+ 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"",
+ (
+ f""
+ f"{toggle_glyph} "
+ f"{rendered_title} "
+ f" "
+ ),
+ ]
if heading_tags:
tag_spans = "".join(
f"{html.escape(t)} "
for t in heading_tags
)
- html_lines.append(f"{tag_spans}
")
+ heading_lines.append(f"{tag_spans}
")
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"Start work "
@@ -471,14 +630,95 @@ def render_org_to_html(
f"data-file='{safe_file}' data-heading='{safe_heading}' "
f"data-state='DONE'>End work"
)
+ heading_lines.append("")
+ section_stack.append((level, heading_lines, []))
+ i += 1
continue
- if not stripped:
- html_lines.append("
")
- else:
- rendered_line = render_line_with_links(line, source_relative_path, known_paths, id_to_path, path_to_slug)
- html_lines.append(f"
{rendered_line}
")
+ # Check Horizontal Rule
+ if _HR_RE.match(stripped):
+ emit("
")
+ 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"
{item} " 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("
")
+ 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"
{render_line(paragraph_text)}
")
+
+ 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
' item under a top-level '* Inbox' section.
+ """Add a '** TODO ' 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"
diff --git a/static/style.css b/static/style.css
index b96f58b..55b4e2a 100644
--- a/static/style.css
+++ b/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;
}
diff --git a/static/sw.js b/static/sw.js
index afe4e71..4c1e0e7 100644
--- a/static/sw.js
+++ b/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) =>
diff --git a/templates/index.html b/templates/index.html
index 128031e..3b5870f 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -11,7 +11,7 @@
-
+
@@ -356,6 +356,24 @@
});
})();
+