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"") + 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"") + table_html.append("") + table_html.append("
{cell}
{cell}
") + 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"" + 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" " @@ -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}") + 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 @@ }); })(); +