flask-construct-ui 0.9.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flask_construct_ui/__init__.py +82 -0
- flask_construct_ui/auth.py +290 -0
- flask_construct_ui/base.py +1259 -0
- flask_construct_ui/components.py +812 -0
- flask_construct_ui/crud.py +1641 -0
- flask_construct_ui/csrf.py +39 -0
- flask_construct_ui/data.py +257 -0
- flask_construct_ui/data_sqlalchemy.py +304 -0
- flask_construct_ui/forms.py +130 -0
- flask_construct_ui/headless.py +110 -0
- flask_construct_ui/icons.py +158 -0
- flask_construct_ui/l10n/en/main.flt +82 -0
- flask_construct_ui/l10n/sv/main.flt +82 -0
- flask_construct_ui/language.py +100 -0
- flask_construct_ui/navbar.py +54 -0
- flask_construct_ui/navigation.py +58 -0
- flask_construct_ui/orgmode.py +338 -0
- flask_construct_ui/pagination.py +39 -0
- flask_construct_ui/py.typed +0 -0
- flask_construct_ui/sanitize.py +25 -0
- flask_construct_ui/schema.py +507 -0
- flask_construct_ui/static/css/prosemirror-bundle.min.css +1 -0
- flask_construct_ui/static/css/unpoly.min.css +6 -0
- flask_construct_ui/static/js/construct-core.min.js +1 -0
- flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
- flask_construct_ui/static/js/unpoly.min.js +1 -0
- flask_construct_ui/static/themes/whitelabel/css/whitelabel.css +720 -0
- flask_construct_ui/static/themes/whitelabel/designs/adminlte.css +94 -0
- flask_construct_ui/static/themes/whitelabel/designs/sandstone.css +64 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/LICENSE.txt +21 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/bootstrap-icons.min.css +5 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/LICENSE.txt +202 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols.css +26 -0
- flask_construct_ui/templates/structure/base.html +5 -0
- flask_construct_ui/templates/structure/flashes.html +7 -0
- flask_construct_ui/templates/structure/public.html +18 -0
- flask_construct_ui/templates/themes/whitelabel/components/_form_field.html +69 -0
- flask_construct_ui/templates/themes/whitelabel/components/button.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/components/calendar.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/calendarmini.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/card.html +25 -0
- flask_construct_ui/templates/themes/whitelabel/components/chart.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/form.html +61 -0
- flask_construct_ui/templates/themes/whitelabel/components/grid_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/horizontal_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/live.html +40 -0
- flask_construct_ui/templates/themes/whitelabel/components/main.html +4 -0
- flask_construct_ui/templates/themes/whitelabel/components/markup.html +1 -0
- flask_construct_ui/templates/themes/whitelabel/components/rich_text.html +19 -0
- flask_construct_ui/templates/themes/whitelabel/components/table.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/tabs.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/text.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/components/vertical_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_macros.html +962 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_many_related_options.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_child_form.html +98 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_child_list.html +136 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_options.html +15 -0
- flask_construct_ui/templates/themes/whitelabel/crud/create.html +45 -0
- flask_construct_ui/templates/themes/whitelabel/crud/edit.html +125 -0
- flask_construct_ui/templates/themes/whitelabel/crud/listindex.html +238 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_child_view.html +33 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_form.html +125 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_list.html +140 -0
- flask_construct_ui/templates/themes/whitelabel/crud/tab_view.html +38 -0
- flask_construct_ui/templates/themes/whitelabel/errors/forbidden.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/navbar/notifications.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/navbar/search_results.html +31 -0
- flask_construct_ui/templates/themes/whitelabel/style/_flashes.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/style/base_full.html +181 -0
- flask_construct_ui/templates/themes/whitelabel/style/base_partial.html +16 -0
- flask_construct_ui/validation.py +422 -0
- flask_construct_ui-0.9.5.dist-info/METADATA +60 -0
- flask_construct_ui-0.9.5.dist-info/RECORD +80 -0
- flask_construct_ui-0.9.5.dist-info/WHEEL +5 -0
- flask_construct_ui-0.9.5.dist-info/licenses/LICENSE +165 -0
- flask_construct_ui-0.9.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class SearchResult:
|
|
8
|
+
title: str
|
|
9
|
+
url: str
|
|
10
|
+
description: str = ""
|
|
11
|
+
icon: str = ""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Notice:
|
|
16
|
+
title: str
|
|
17
|
+
message: str = ""
|
|
18
|
+
url: str = ""
|
|
19
|
+
icon: str = ""
|
|
20
|
+
read: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NavbarFeature:
|
|
24
|
+
"""Common base class for all features that mount on the navbar."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GlobalSearch(NavbarFeature):
|
|
30
|
+
"""Subclass and override ``search`` to provide cross-app search.
|
|
31
|
+
|
|
32
|
+
The framework registers a ``GET /_construct/search?q=...`` route (relative
|
|
33
|
+
to the construct URL prefix) and calls ``search(query)`` for each request.
|
|
34
|
+
Results are rendered using the theme's ``navbar/search_results.html``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def search(self, query: str) -> list[SearchResult]:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Notifications(NavbarFeature):
|
|
42
|
+
"""Subclass and override ``get_notices`` to drive the notification bell.
|
|
43
|
+
|
|
44
|
+
``count`` returns the number of unread notices; themes use this for the
|
|
45
|
+
badge. ``get_notices`` is called once per render — cache inside the method
|
|
46
|
+
at ``g``-level if the source is expensive.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def get_notices(self) -> list[Notice]:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def count(self) -> int:
|
|
54
|
+
return sum(1 for n in self.get_notices() if not n.read)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Post-save navigation: the ``next`` request parameter.
|
|
2
|
+
|
|
3
|
+
CRUD success redirects (top-level and RCRUD) resolve their target through
|
|
4
|
+
:func:`resolve_next`, so a link that leads into a form can say where saving,
|
|
5
|
+
deleting or cancelling should land:
|
|
6
|
+
|
|
7
|
+
- ``?next=<app-relative path>`` — return there afterwards. The list view's
|
|
8
|
+
create/edit/delete links pass the current list URL (including filters and
|
|
9
|
+
pagination) automatically, so form round-trips preserve list state.
|
|
10
|
+
- ``?next=:record`` — go to the saved record's edit view. Useful for
|
|
11
|
+
"create, then continue editing". Falls back to the default when no record
|
|
12
|
+
exists (delete, or a failed create).
|
|
13
|
+
- no ``next`` — the pre-existing behavior (back to the list view).
|
|
14
|
+
|
|
15
|
+
Only app-relative paths are honored: anything that does not start with a
|
|
16
|
+
single ``/`` (or contains control characters) is ignored, so a crafted link
|
|
17
|
+
cannot turn the redirect into an open redirect. Page-level policies plug in
|
|
18
|
+
via ``CrudPageBase.get_success_url`` instead of the per-link parameter.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from flask import request
|
|
22
|
+
|
|
23
|
+
#: Reserved ``next`` value meaning "the record that was just saved".
|
|
24
|
+
RECORD_TARGET = ":record"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_safe_next(url):
|
|
28
|
+
"""True when ``url`` is a safe, app-relative redirect target."""
|
|
29
|
+
if not url or not url.startswith("/"):
|
|
30
|
+
return False
|
|
31
|
+
# "//host" and "/\host" are treated as protocol-relative by browsers.
|
|
32
|
+
if url.startswith("//") or url.startswith("/\\"):
|
|
33
|
+
return False
|
|
34
|
+
if any(ord(c) < 0x20 for c in url):
|
|
35
|
+
return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def requested_next():
|
|
40
|
+
"""The raw ``next`` parameter from the query string or form body."""
|
|
41
|
+
return request.values.get("next")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_next(default, record_url=None):
|
|
45
|
+
"""Resolve the redirect target for a completed form action.
|
|
46
|
+
|
|
47
|
+
``default`` is used when no usable ``next`` was supplied. ``record_url``
|
|
48
|
+
is the saved record's edit view, substituted for the ``:record``
|
|
49
|
+
sentinel; pass ``None`` when there is no record to go to.
|
|
50
|
+
"""
|
|
51
|
+
nxt = requested_next()
|
|
52
|
+
if not nxt:
|
|
53
|
+
return default
|
|
54
|
+
if nxt == RECORD_TARGET:
|
|
55
|
+
return record_url or default
|
|
56
|
+
if is_safe_next(nxt):
|
|
57
|
+
return nxt
|
|
58
|
+
return default
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Org-mode HTML rendering utility on top of orgparse.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import html
|
|
7
|
+
from orgparse import loads
|
|
8
|
+
|
|
9
|
+
RE_UNORDERED = re.compile(r"^(\s*)([-+*])\s+(.*)$")
|
|
10
|
+
RE_ORDERED = re.compile(r"^(\s*)(\d+)\.\s+(.*)$")
|
|
11
|
+
RE_CHECKBOX = re.compile(r"^\[([ xX-])\]\s*(.*)$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def escape_html(text: str) -> str:
|
|
15
|
+
return html.escape(text)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_inline_formatting(text: str) -> str:
|
|
19
|
+
"""Parse Org-mode inline formatting into HTML."""
|
|
20
|
+
text = escape_html(text)
|
|
21
|
+
|
|
22
|
+
# 1. Links: [[url][label]] or [[url]]
|
|
23
|
+
text = re.sub(r"\[\[([^\]]+)\]\[([^\]]+)\]\]", r'<a href="\1">\2</a>', text)
|
|
24
|
+
text = re.sub(r"\[\[([^\]]+)\]\]", r'<a href="\1">\1</a>', text)
|
|
25
|
+
|
|
26
|
+
# 2. Code/Verbatim
|
|
27
|
+
text = re.sub(
|
|
28
|
+
r'(^|[\s(),.:;\'"!?\-\[\]{}<>])=([^\s=](?:[^=]*?[^\s=])?)=(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
|
|
29
|
+
r"\1<code>\2</code>",
|
|
30
|
+
text,
|
|
31
|
+
)
|
|
32
|
+
text = re.sub(
|
|
33
|
+
r'(^|[\s(),.:;\'"!?\-\[\]{}<>])~([^\s~](?:[^~]*?[^\s~])?)~(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
|
|
34
|
+
r"\1<code>\2</code>",
|
|
35
|
+
text,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# 3. Bold *text*
|
|
39
|
+
text = re.sub(
|
|
40
|
+
r'(^|[\s(),.:;\'"!?\-\[\]{}<>])\*([^\s\*](?:[^\*]*?[^\s\*])?)\*(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
|
|
41
|
+
r"\1<strong>\2</strong>",
|
|
42
|
+
text,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# 4. Italic /text/
|
|
46
|
+
text = re.sub(
|
|
47
|
+
r'(^|[\s(),.:;\'"!?\-\[\]{}<>])/([^\s/](?:[^/]*?[^\s/])?)/(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
|
|
48
|
+
r"\1<em>\2</em>",
|
|
49
|
+
text,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# 5. Underline _text_
|
|
53
|
+
text = re.sub(
|
|
54
|
+
r'(^|[\s(),.:;\'"!?\-\[\]{}<>])_([^\s_](?:[^_]*?[^\s_])?)_(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
|
|
55
|
+
r"\1<u>\2</u>",
|
|
56
|
+
text,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# 6. Strikethrough +text+
|
|
60
|
+
text = re.sub(
|
|
61
|
+
r'(^|[\s(),.:;\'"!?\-\[\]{}<>])\+([^\s\+](?:[^\+]*?[^\s\+])?)\+(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
|
|
62
|
+
r"\1<del>\2</del>",
|
|
63
|
+
text,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_list(list_lines: list[str]) -> str:
|
|
70
|
+
"""Parse list lines into HTML lists (ul/ol) supporting nested elements and checkboxes."""
|
|
71
|
+
items = []
|
|
72
|
+
current_item = None
|
|
73
|
+
|
|
74
|
+
for line in list_lines:
|
|
75
|
+
m_ul = RE_UNORDERED.match(line)
|
|
76
|
+
m_ol = RE_ORDERED.match(line)
|
|
77
|
+
if m_ul:
|
|
78
|
+
indent = len(m_ul.group(1))
|
|
79
|
+
content = m_ul.group(3)
|
|
80
|
+
current_item = {"indent": indent, "type": "ul", "lines": [content]}
|
|
81
|
+
items.append(current_item)
|
|
82
|
+
elif m_ol:
|
|
83
|
+
indent = len(m_ol.group(1))
|
|
84
|
+
content = m_ol.group(3)
|
|
85
|
+
current_item = {"indent": indent, "type": "ol", "lines": [content]}
|
|
86
|
+
items.append(current_item)
|
|
87
|
+
else:
|
|
88
|
+
if current_item is not None and line.strip():
|
|
89
|
+
current_item["lines"].append(line.strip())
|
|
90
|
+
|
|
91
|
+
for item in items:
|
|
92
|
+
full_content = " ".join(item["lines"])
|
|
93
|
+
m_chk = RE_CHECKBOX.match(full_content)
|
|
94
|
+
if m_chk:
|
|
95
|
+
chk_val = m_chk.group(1)
|
|
96
|
+
rest = m_chk.group(2)
|
|
97
|
+
# Use unicode checkboxes (☐/☑) so they render nicely without form input stripping
|
|
98
|
+
box = "☑" if chk_val.upper() == "X" else "☐"
|
|
99
|
+
item["html_content"] = (
|
|
100
|
+
f'<span class="org-checkbox">{box}</span> {parse_inline_formatting(rest)}'
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
item["html_content"] = parse_inline_formatting(full_content)
|
|
104
|
+
|
|
105
|
+
if not items:
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
html_out = []
|
|
109
|
+
stack = []
|
|
110
|
+
|
|
111
|
+
for item in items:
|
|
112
|
+
indent = item["indent"]
|
|
113
|
+
ltype = item["type"]
|
|
114
|
+
|
|
115
|
+
while stack and stack[-1][0] > indent:
|
|
116
|
+
_, last_type = stack.pop()
|
|
117
|
+
html_out.append(f"</li></{last_type}>")
|
|
118
|
+
|
|
119
|
+
if not stack or stack[-1][0] < indent:
|
|
120
|
+
stack.append((indent, ltype))
|
|
121
|
+
html_out.append(f"<{ltype}><li>")
|
|
122
|
+
elif stack[-1][0] == indent:
|
|
123
|
+
if stack[-1][1] != ltype:
|
|
124
|
+
_, last_type = stack.pop()
|
|
125
|
+
html_out.append(f"</li></{last_type}>")
|
|
126
|
+
stack.append((indent, ltype))
|
|
127
|
+
html_out.append(f"<{ltype}><li>")
|
|
128
|
+
else:
|
|
129
|
+
html_out.append("</li><li>")
|
|
130
|
+
|
|
131
|
+
html_out.append(item["html_content"])
|
|
132
|
+
|
|
133
|
+
while stack:
|
|
134
|
+
_, last_type = stack.pop()
|
|
135
|
+
html_out.append(f"</li></{last_type}>")
|
|
136
|
+
|
|
137
|
+
return "".join(html_out)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render_table(table_lines: list[str]) -> str:
|
|
141
|
+
"""Parse table lines into an HTML table."""
|
|
142
|
+
parsed_rows = []
|
|
143
|
+
is_separator = lambda s: all(c in "|-+ \t" for c in s)
|
|
144
|
+
has_header = False
|
|
145
|
+
|
|
146
|
+
for line in table_lines:
|
|
147
|
+
if is_separator(line):
|
|
148
|
+
if len(parsed_rows) > 0:
|
|
149
|
+
has_header = True
|
|
150
|
+
continue
|
|
151
|
+
cells = [c.strip() for c in line.split("|")[1:-1]]
|
|
152
|
+
parsed_rows.append(cells)
|
|
153
|
+
|
|
154
|
+
if not parsed_rows:
|
|
155
|
+
return ""
|
|
156
|
+
|
|
157
|
+
html_out = ['<table class="org-table">']
|
|
158
|
+
for idx, row in enumerate(parsed_rows):
|
|
159
|
+
is_h = has_header and idx == 0
|
|
160
|
+
html_out.append("<tr>")
|
|
161
|
+
for cell in row:
|
|
162
|
+
tag = "th" if is_h else "td"
|
|
163
|
+
html_out.append(f"<{tag}>{parse_inline_formatting(cell)}</{tag}>")
|
|
164
|
+
html_out.append("</tr>")
|
|
165
|
+
html_out.append("</table>")
|
|
166
|
+
return "\n".join(html_out)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_body_to_html(body_text: str) -> str:
|
|
170
|
+
"""Process node body text block-by-block and convert to HTML."""
|
|
171
|
+
if not body_text.strip():
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
lines = body_text.splitlines()
|
|
175
|
+
blocks = []
|
|
176
|
+
i = 0
|
|
177
|
+
n = len(lines)
|
|
178
|
+
|
|
179
|
+
while i < n:
|
|
180
|
+
line = lines[i]
|
|
181
|
+
stripped = line.strip()
|
|
182
|
+
|
|
183
|
+
if not stripped:
|
|
184
|
+
i += 1
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Code block
|
|
188
|
+
if stripped.upper().startswith("#+BEGIN_SRC"):
|
|
189
|
+
parts = stripped.split()
|
|
190
|
+
lang = parts[1].lower() if len(parts) > 1 else ""
|
|
191
|
+
src_lines = []
|
|
192
|
+
i += 1
|
|
193
|
+
while i < n and not lines[i].strip().upper().startswith("#+END_SRC"):
|
|
194
|
+
src_lines.append(lines[i])
|
|
195
|
+
i += 1
|
|
196
|
+
blocks.append(
|
|
197
|
+
f'<pre><code class="language-{lang}">'
|
|
198
|
+
+ escape_html("\n".join(src_lines))
|
|
199
|
+
+ "</code></pre>"
|
|
200
|
+
)
|
|
201
|
+
i += 1
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Blockquote
|
|
205
|
+
if stripped.upper().startswith("#+BEGIN_QUOTE"):
|
|
206
|
+
quote_lines = []
|
|
207
|
+
i += 1
|
|
208
|
+
while i < n and not lines[i].strip().upper().startswith("#+END_QUOTE"):
|
|
209
|
+
quote_lines.append(lines[i])
|
|
210
|
+
i += 1
|
|
211
|
+
inner_html = parse_body_to_html("\n".join(quote_lines))
|
|
212
|
+
blocks.append(f"<blockquote>\n{inner_html}\n</blockquote>")
|
|
213
|
+
i += 1
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
# Example
|
|
217
|
+
if stripped.upper().startswith("#+BEGIN_EXAMPLE"):
|
|
218
|
+
ex_lines = []
|
|
219
|
+
i += 1
|
|
220
|
+
while i < n and not lines[i].strip().upper().startswith("#+END_EXAMPLE"):
|
|
221
|
+
ex_lines.append(lines[i])
|
|
222
|
+
i += 1
|
|
223
|
+
blocks.append(
|
|
224
|
+
'<pre class="example">' + escape_html("\n".join(ex_lines)) + "</pre>"
|
|
225
|
+
)
|
|
226
|
+
i += 1
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# Lists
|
|
230
|
+
m_ul = RE_UNORDERED.match(line)
|
|
231
|
+
m_ol = RE_ORDERED.match(line)
|
|
232
|
+
if m_ul or m_ol:
|
|
233
|
+
list_lines = []
|
|
234
|
+
while i < n:
|
|
235
|
+
curr_line = lines[i]
|
|
236
|
+
curr_stripped = curr_line.strip()
|
|
237
|
+
if not curr_stripped:
|
|
238
|
+
next_idx = i + 1
|
|
239
|
+
while next_idx < n and not lines[next_idx].strip():
|
|
240
|
+
next_idx += 1
|
|
241
|
+
if next_idx < n and (
|
|
242
|
+
RE_UNORDERED.match(lines[next_idx])
|
|
243
|
+
or RE_ORDERED.match(lines[next_idx])
|
|
244
|
+
):
|
|
245
|
+
list_lines.append(curr_line)
|
|
246
|
+
i += 1
|
|
247
|
+
continue
|
|
248
|
+
else:
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
is_item = RE_UNORDERED.match(curr_line) or RE_ORDERED.match(curr_line)
|
|
252
|
+
is_indented = curr_line.startswith(" ") or curr_line.startswith("\t")
|
|
253
|
+
if is_item or is_indented:
|
|
254
|
+
list_lines.append(curr_line)
|
|
255
|
+
i += 1
|
|
256
|
+
else:
|
|
257
|
+
break
|
|
258
|
+
blocks.append(render_list(list_lines))
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Tables
|
|
262
|
+
if stripped.startswith("|"):
|
|
263
|
+
table_lines = []
|
|
264
|
+
while i < n and lines[i].strip().startswith("|"):
|
|
265
|
+
table_lines.append(lines[i].strip())
|
|
266
|
+
i += 1
|
|
267
|
+
blocks.append(render_table(table_lines))
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# Paragraphs
|
|
271
|
+
para_lines = []
|
|
272
|
+
while i < n:
|
|
273
|
+
curr_line = lines[i]
|
|
274
|
+
curr_stripped = curr_line.strip()
|
|
275
|
+
if not curr_stripped:
|
|
276
|
+
break
|
|
277
|
+
if (
|
|
278
|
+
curr_stripped.upper().startswith("#+BEGIN_")
|
|
279
|
+
or curr_stripped.startswith("|")
|
|
280
|
+
or RE_UNORDERED.match(curr_line)
|
|
281
|
+
or RE_ORDERED.match(curr_line)
|
|
282
|
+
):
|
|
283
|
+
break
|
|
284
|
+
para_lines.append(curr_stripped)
|
|
285
|
+
i += 1
|
|
286
|
+
|
|
287
|
+
para_text = " ".join(para_lines)
|
|
288
|
+
blocks.append(f"<p>{parse_inline_formatting(para_text)}</p>")
|
|
289
|
+
|
|
290
|
+
return "\n".join(blocks)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def render_node(node) -> str:
|
|
294
|
+
"""Recursively render a parsed OrgNode tree to HTML."""
|
|
295
|
+
html_out = []
|
|
296
|
+
|
|
297
|
+
if node.level > 0:
|
|
298
|
+
heading_parts = []
|
|
299
|
+
if node.todo:
|
|
300
|
+
heading_parts.append(
|
|
301
|
+
f'<span class="org-todo org-todo-{node.todo.lower()}">{node.todo}</span>'
|
|
302
|
+
)
|
|
303
|
+
if node.priority:
|
|
304
|
+
heading_parts.append(f'<span class="org-priority">[{node.priority}]</span>')
|
|
305
|
+
|
|
306
|
+
raw_heading = node.get_heading(format="raw")
|
|
307
|
+
heading_parts.append(parse_inline_formatting(raw_heading))
|
|
308
|
+
|
|
309
|
+
if node.shallow_tags:
|
|
310
|
+
tags_html = " ".join(
|
|
311
|
+
f'<span class="org-tag">{escape_html(t)}</span>'
|
|
312
|
+
for t in node.shallow_tags
|
|
313
|
+
)
|
|
314
|
+
heading_parts.append(f'<span class="org-tags">{tags_html}</span>')
|
|
315
|
+
|
|
316
|
+
heading_html = " ".join(heading_parts)
|
|
317
|
+
level_tag = f"h{min(node.level, 6)}"
|
|
318
|
+
html_out.append(f"<{level_tag}>{heading_html}</{level_tag}>")
|
|
319
|
+
|
|
320
|
+
# Use the raw body — node.body normalises away inline link markup
|
|
321
|
+
# (`[[url][label]]` collapses to just `label`), which would strip every
|
|
322
|
+
# link before our own inline parser ever sees it.
|
|
323
|
+
raw_body = node.get_body(format="raw")
|
|
324
|
+
if raw_body:
|
|
325
|
+
html_out.append(parse_body_to_html(raw_body))
|
|
326
|
+
|
|
327
|
+
for child in node.children:
|
|
328
|
+
html_out.append(render_node(child))
|
|
329
|
+
|
|
330
|
+
return "\n".join(html_out)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def org_to_html(org_text: str) -> str:
|
|
334
|
+
"""Convert Org-mode raw text to HTML using orgparse and custom recursive generation."""
|
|
335
|
+
if not org_text:
|
|
336
|
+
return ""
|
|
337
|
+
root = loads(org_text)
|
|
338
|
+
return render_node(root)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from flask import current_app, request
|
|
3
|
+
|
|
4
|
+
# --- Pagination -------------------------------------------------------------
|
|
5
|
+
# Every list-returning query carries an (offset, count) limit. The page size
|
|
6
|
+
# a view declares is clamped to CONSTRUCT_MAX_PAGE_SIZE so neither a
|
|
7
|
+
# misconfigured view nor a crafted request can force an unbounded scan; the
|
|
8
|
+
# data layer applies the same cap as defense in depth (data.effective_limit).
|
|
9
|
+
DEFAULT_PAGE_SIZE = 10
|
|
10
|
+
MAX_PAGE_SIZE = 100
|
|
11
|
+
# Upper bound for the related-field typeahead dropdowns; results narrow as
|
|
12
|
+
# the user types, so they never need a full page-sized result set.
|
|
13
|
+
RELATED_SEARCH_LIMIT = 50
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_pagination(page_size=None):
|
|
17
|
+
"""Return ``(page, per_page, limit)`` for the current request.
|
|
18
|
+
|
|
19
|
+
``page`` comes from the query string, floored at 1 (a negative offset is
|
|
20
|
+
a server error on some backends). ``per_page`` is the view's ``page_size``
|
|
21
|
+
(or ``CONSTRUCT_DEFAULT_PAGE_SIZE``) silently clamped to
|
|
22
|
+
``CONSTRUCT_MAX_PAGE_SIZE``.
|
|
23
|
+
"""
|
|
24
|
+
if page_size is None:
|
|
25
|
+
page_size = current_app.config.get(
|
|
26
|
+
"CONSTRUCT_DEFAULT_PAGE_SIZE", DEFAULT_PAGE_SIZE
|
|
27
|
+
)
|
|
28
|
+
try:
|
|
29
|
+
page_size = int(page_size)
|
|
30
|
+
except (TypeError, ValueError):
|
|
31
|
+
page_size = DEFAULT_PAGE_SIZE
|
|
32
|
+
max_page_size = current_app.config.get("CONSTRUCT_MAX_PAGE_SIZE", MAX_PAGE_SIZE)
|
|
33
|
+
try:
|
|
34
|
+
max_page_size = int(max_page_size)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
max_page_size = MAX_PAGE_SIZE
|
|
37
|
+
per_page = min(max(1, page_size), max_page_size)
|
|
38
|
+
page = max(1, request.args.get("page", 1, type=int))
|
|
39
|
+
return page, per_page, ((page - 1) * per_page, per_page)
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Central HTML sanitization policy.
|
|
3
|
+
|
|
4
|
+
Every place the library converts stored/user-supplied content to HTML and
|
|
5
|
+
marks it safe (the ``markdown`` template filter, ``MarkupComponent``,
|
|
6
|
+
calendar cell content) must pass the HTML through :func:`sanitize_html`
|
|
7
|
+
first. Developer-authored template HTML does not go through here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import nh3
|
|
11
|
+
|
|
12
|
+
# nh3's defaults (the ammonia allowlist) strip <script>, event handlers,
|
|
13
|
+
# javascript: URLs and style attributes while keeping formatting tags,
|
|
14
|
+
# lists, tables, links and images — a good match for ProseMirror output.
|
|
15
|
+
ALLOWED_TAGS = nh3.ALLOWED_TAGS | {"img"}
|
|
16
|
+
ALLOWED_ATTRIBUTES = {
|
|
17
|
+
**nh3.ALLOWED_ATTRIBUTES,
|
|
18
|
+
"img": {"src", "alt", "title"},
|
|
19
|
+
"*": {"class"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sanitize_html(html: str) -> str:
|
|
24
|
+
"""Return ``html`` reduced to the allowlisted tags and attributes."""
|
|
25
|
+
return nh3.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
|