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,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from flask import current_app
|
|
3
|
+
from . import schema
|
|
4
|
+
|
|
5
|
+
# --- Server-side form validation -------------------------------------------
|
|
6
|
+
# Structural checks (max length, type parseability, required, enum/set
|
|
7
|
+
# membership) are always enforced before anything is written. ``matches``
|
|
8
|
+
# rules are advisory by default; they are enforced when the field declares
|
|
9
|
+
# ``enforce=True`` or the app sets ``CONSTRUCT_ENFORCE_MATCHES = True``
|
|
10
|
+
# (a per-field ``enforce=False`` overrides the app default).
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FormData:
|
|
14
|
+
"""Attribute access over a processed form-data dict, optionally falling
|
|
15
|
+
back to an existing record — used as the ``row`` for per-row ``matches``
|
|
16
|
+
rules and to re-render forms with the submitted values after a
|
|
17
|
+
validation failure."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, values, fallback=None):
|
|
20
|
+
self._values = values
|
|
21
|
+
self._fallback = fallback
|
|
22
|
+
|
|
23
|
+
def __getattr__(self, name):
|
|
24
|
+
if name.startswith("_"):
|
|
25
|
+
raise AttributeError(name)
|
|
26
|
+
values = self.__dict__["_values"]
|
|
27
|
+
if name in values:
|
|
28
|
+
return values[name]
|
|
29
|
+
fallback = self.__dict__["_fallback"]
|
|
30
|
+
if fallback is not None:
|
|
31
|
+
return getattr(fallback, name, None)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_form_data(ds, form_fields, data, row=None, partial=False, skip=()):
|
|
36
|
+
"""Validate processed form data before create/update.
|
|
37
|
+
|
|
38
|
+
Returns ``{form input name: ValidationError}`` — empty when valid. With
|
|
39
|
+
``partial=True`` (updates) fields absent from ``data`` are treated as
|
|
40
|
+
unchanged rather than missing.
|
|
41
|
+
"""
|
|
42
|
+
enforce_default = bool(current_app.config.get("CONSTRUCT_ENFORCE_MATCHES", False))
|
|
43
|
+
errors = {}
|
|
44
|
+
list_items = data.get("list_items") or {}
|
|
45
|
+
for field, type_info in dict(form_fields).items():
|
|
46
|
+
if field in skip or type_info.readonly or not type_info.visible_form:
|
|
47
|
+
continue
|
|
48
|
+
if isinstance(type_info, schema.PK):
|
|
49
|
+
continue
|
|
50
|
+
if isinstance(type_info, schema.List):
|
|
51
|
+
actions = list_items.get(field) or {}
|
|
52
|
+
sub_schema = type_info.list_class._schema
|
|
53
|
+
for i, item in enumerate(actions.get("new") or []):
|
|
54
|
+
item_row = FormData(item)
|
|
55
|
+
for sub_field, sub_type in sub_schema.items():
|
|
56
|
+
if (
|
|
57
|
+
sub_type.field_type == "pk"
|
|
58
|
+
or sub_type.readonly
|
|
59
|
+
or not sub_type.visible_form
|
|
60
|
+
):
|
|
61
|
+
continue
|
|
62
|
+
err = sub_type.validate_submission(
|
|
63
|
+
item.get(sub_field),
|
|
64
|
+
row=item_row,
|
|
65
|
+
enforce_default=enforce_default,
|
|
66
|
+
)
|
|
67
|
+
if err:
|
|
68
|
+
err.label = sub_type.name
|
|
69
|
+
errors[f"{field}-new-{i}-{sub_field}"] = err
|
|
70
|
+
for item in actions.get("update") or []:
|
|
71
|
+
item_id = item.get("id")
|
|
72
|
+
item_row = FormData(item, type_info.list_class().read(item_id))
|
|
73
|
+
for sub_field, sub_type in sub_schema.items():
|
|
74
|
+
if (
|
|
75
|
+
sub_type.field_type == "pk"
|
|
76
|
+
or sub_type.readonly
|
|
77
|
+
or not sub_type.visible_form
|
|
78
|
+
):
|
|
79
|
+
continue
|
|
80
|
+
if sub_field not in item:
|
|
81
|
+
continue
|
|
82
|
+
err = sub_type.validate_submission(
|
|
83
|
+
item[sub_field], row=item_row, enforce_default=enforce_default
|
|
84
|
+
)
|
|
85
|
+
if err:
|
|
86
|
+
err.label = sub_type.name
|
|
87
|
+
errors[f"{field}-{item_id}-{sub_field}"] = err
|
|
88
|
+
continue
|
|
89
|
+
if isinstance(type_info, schema.ManyRelated) and type_info.create:
|
|
90
|
+
staged = (data.get("many_related_new") or {}).get(field) or {}
|
|
91
|
+
quick_values = staged.get("quick") or []
|
|
92
|
+
new_items = staged.get("new") or []
|
|
93
|
+
if type_info.create == "quick" and quick_values:
|
|
94
|
+
quick_field = type_info.resolve_quick_field()
|
|
95
|
+
quick_type = type_info.related_class._schema[quick_field]
|
|
96
|
+
for i, value in enumerate(quick_values):
|
|
97
|
+
err = quick_type.validate_submission(
|
|
98
|
+
value, row=None, enforce_default=enforce_default
|
|
99
|
+
)
|
|
100
|
+
if err:
|
|
101
|
+
err.label = type_info.name
|
|
102
|
+
errors[f"{field}-quicknew-{i}"] = err
|
|
103
|
+
if type_info.create == "form":
|
|
104
|
+
for i, item in enumerate(new_items):
|
|
105
|
+
item_row = FormData(item)
|
|
106
|
+
for sub_field, sub_type in type_info.inline_form_fields():
|
|
107
|
+
err = sub_type.validate_submission(
|
|
108
|
+
item.get(sub_field),
|
|
109
|
+
row=item_row,
|
|
110
|
+
enforce_default=enforce_default,
|
|
111
|
+
)
|
|
112
|
+
if err:
|
|
113
|
+
err.label = sub_type.name
|
|
114
|
+
errors[f"{field}-newrel-{i}-{sub_field}"] = err
|
|
115
|
+
if not data.get(field) and (quick_values or new_items):
|
|
116
|
+
# Staged creations satisfy `required` on the field itself.
|
|
117
|
+
continue
|
|
118
|
+
if field not in data:
|
|
119
|
+
if partial:
|
|
120
|
+
continue
|
|
121
|
+
value = None
|
|
122
|
+
else:
|
|
123
|
+
value = data[field]
|
|
124
|
+
err = type_info.validate_submission(
|
|
125
|
+
value, row=row, enforce_default=enforce_default
|
|
126
|
+
)
|
|
127
|
+
if err:
|
|
128
|
+
err.label = type_info.name
|
|
129
|
+
errors[field] = err
|
|
130
|
+
return errors
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from flask import Response, stream_with_context
|
|
5
|
+
from markupsafe import Markup, escape
|
|
6
|
+
from typing import Any, Generator, Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sse_event(event: str, data: Any) -> str:
|
|
10
|
+
"""Formats an SSE block for a given event name and data.
|
|
11
|
+
|
|
12
|
+
If data is not a string, it will be JSON serialized automatically.
|
|
13
|
+
"""
|
|
14
|
+
if not isinstance(data, str):
|
|
15
|
+
data = json.dumps(data)
|
|
16
|
+
return f"event: {event}\ndata: {data}\n\n"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sse_response(generator: Generator[str, None, None] | Iterable[str]) -> Response:
|
|
20
|
+
"""Returns a Flask response configured for EventSource streaming."""
|
|
21
|
+
return Response(
|
|
22
|
+
stream_with_context(generator),
|
|
23
|
+
mimetype="text/event-stream",
|
|
24
|
+
headers={
|
|
25
|
+
"Cache-Control": "no-cache",
|
|
26
|
+
"X-Accel-Buffering": "no",
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def live_attributes(
|
|
32
|
+
sse_url: str | None = None,
|
|
33
|
+
poll_url: str | None = None,
|
|
34
|
+
interval: int = 10,
|
|
35
|
+
content_id: str | None = None,
|
|
36
|
+
format: str = "default",
|
|
37
|
+
max_lines: int | None = 200,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Generates the data-live-* attributes for a custom HTML container.
|
|
40
|
+
|
|
41
|
+
This can be used on any HTML element to wire up live updates.
|
|
42
|
+
"""
|
|
43
|
+
attrs = []
|
|
44
|
+
if sse_url:
|
|
45
|
+
attrs.append(f'data-live-sse-url="{escape(sse_url)}"')
|
|
46
|
+
if poll_url:
|
|
47
|
+
attrs.append(f'data-live-poll-url="{escape(poll_url)}"')
|
|
48
|
+
|
|
49
|
+
attrs.append(f'data-live-interval="{interval * 1000}"')
|
|
50
|
+
|
|
51
|
+
if content_id:
|
|
52
|
+
attrs.append(f'data-live-content-id="{escape(content_id)}"')
|
|
53
|
+
|
|
54
|
+
if format and format != "default":
|
|
55
|
+
attrs.append(f'data-live-format="{escape(format)}"')
|
|
56
|
+
if format == "append":
|
|
57
|
+
attrs.append(f'data-live-limit="{max_lines if max_lines is not None else 0}"')
|
|
58
|
+
|
|
59
|
+
return Markup(" ".join(attrs)) # nosemgrep: explicit-unescape-with-markup # nosec B704
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def live_container(
|
|
63
|
+
content_html: str = "",
|
|
64
|
+
sse_url: str | None = None,
|
|
65
|
+
poll_url: str | None = None,
|
|
66
|
+
interval: int = 10,
|
|
67
|
+
content_id: str | None = None,
|
|
68
|
+
format: str = "default",
|
|
69
|
+
css_class: str | None = None,
|
|
70
|
+
container_id: str | None = None,
|
|
71
|
+
max_lines: int | None = 200,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Renders a fully-formed live update container, including No-JS fallback.
|
|
74
|
+
|
|
75
|
+
``content_html`` is inserted verbatim (it is expected to be HTML); the caller
|
|
76
|
+
must sanitize any untrusted content before passing it in. The id/class
|
|
77
|
+
config values are HTML-escaped.
|
|
78
|
+
"""
|
|
79
|
+
cid = container_id or f"live-headless-{uuid.uuid4().hex[:8]}"
|
|
80
|
+
target_content_id = content_id or f"live-content-{cid}"
|
|
81
|
+
|
|
82
|
+
attrs = live_attributes(
|
|
83
|
+
sse_url=sse_url,
|
|
84
|
+
poll_url=poll_url,
|
|
85
|
+
interval=interval,
|
|
86
|
+
content_id=target_content_id,
|
|
87
|
+
format=format,
|
|
88
|
+
max_lines=max_lines,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
class_str = (
|
|
92
|
+
f' class="live-component {escape(css_class)}"'
|
|
93
|
+
if css_class
|
|
94
|
+
else ' class="live-component"'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Escape the id/class config values (they may be caller-supplied) so this
|
|
98
|
+
# helper matches live_attributes(); content_html is intentionally raw — the
|
|
99
|
+
# caller owns its HTML and is responsible for sanitizing untrusted content.
|
|
100
|
+
html = (
|
|
101
|
+
f'<div id="{escape(cid)}"{class_str} {attrs}>\n'
|
|
102
|
+
f' <div id="{escape(target_content_id)}" class="live-content">\n'
|
|
103
|
+
f" {content_html}\n"
|
|
104
|
+
f" </div>\n"
|
|
105
|
+
f" <noscript>\n"
|
|
106
|
+
f' <meta http-equiv="refresh" content="{interval}">\n'
|
|
107
|
+
f" </noscript>\n"
|
|
108
|
+
f"</div>"
|
|
109
|
+
)
|
|
110
|
+
return Markup(html) # nosemgrep: explicit-unescape-with-markup # nosec B704
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Theme-agnostic icon rendering.
|
|
2
|
+
|
|
3
|
+
The icon *string* an app passes — a Bootstrap Icons class like ``bi-star`` or a
|
|
4
|
+
Material Symbol ligature like ``star`` — is wrapped in different markup by each
|
|
5
|
+
theme. ``render_icon`` centralises that wrapper choice so a single (eventually
|
|
6
|
+
unified) template tree can emit icons without hard-coding one library's markup.
|
|
7
|
+
|
|
8
|
+
The wrapper is chosen by an *icon mode*:
|
|
9
|
+
|
|
10
|
+
* ``"bootstrap"`` -> ``<i class="bi <name> <extra>"></i>``
|
|
11
|
+
* ``"material"`` -> ``<span class="material-symbols-outlined <extra>"><name></span>``
|
|
12
|
+
|
|
13
|
+
A structural theme has a default mode; a *design* may override it, so the
|
|
14
|
+
``adminlte`` design layered on the whitelabel theme opts back into
|
|
15
|
+
Bootstrap-Icons markup (the look of the former adminlte3 theme it replaced).
|
|
16
|
+
|
|
17
|
+
The library refers to its own icons by *semantic* names (``"save"``, ``"back"``,
|
|
18
|
+
…) collected in the ``ICONS`` registry below, which maps each to a concrete
|
|
19
|
+
glyph per icon set. ``render_icon`` translates the semantic name to the active
|
|
20
|
+
mode's glyph *and* wraps it in that mode's markup — so one template renders
|
|
21
|
+
correctly under either icon set. Names not in the registry (app-supplied icon
|
|
22
|
+
strings) pass through verbatim.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from flask import g
|
|
26
|
+
from markupsafe import Markup
|
|
27
|
+
|
|
28
|
+
# Default icon mode per structural theme. `whitelabel` is the only structural
|
|
29
|
+
# theme; bootstrap-icon rendering is reached via the `adminlte` design (see
|
|
30
|
+
# DESIGN_ICON_MODE below).
|
|
31
|
+
THEME_ICON_MODE = {
|
|
32
|
+
"whitelabel": "material",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Optional per-design override (design name -> icon mode). Lets a design pick an
|
|
36
|
+
# icon library independently of the theme it skins. The ``adminlte`` design
|
|
37
|
+
# (the whitelabel skin that replaced the old adminlte3 theme) opts back into
|
|
38
|
+
# Bootstrap Icons so its UI matches the original AdminLTE look.
|
|
39
|
+
DESIGN_ICON_MODE: dict[str, str] = {
|
|
40
|
+
"adminlte": "bootstrap",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
DEFAULT_ICON_MODE = "material"
|
|
44
|
+
|
|
45
|
+
# --- The icon translation registry ---------------------------------------
|
|
46
|
+
#
|
|
47
|
+
# The library refers to its own icons by *semantic* names; this table maps each
|
|
48
|
+
# to a concrete glyph in every supported icon set. ``render_icon`` looks a
|
|
49
|
+
# semantic name up here and emits the glyph for the active icon mode, so the
|
|
50
|
+
# same template renders correctly whichever icon set a theme/design selects.
|
|
51
|
+
#
|
|
52
|
+
# Every entry MUST provide both modes. When a feature needs a new icon, add a
|
|
53
|
+
# row here (pick a Bootstrap Icons class and a Material Symbol name) rather than
|
|
54
|
+
# hard-coding a glyph in a template. Names not found here are passed through
|
|
55
|
+
# verbatim (e.g. app-supplied icons), wrapped in the active mode's markup.
|
|
56
|
+
ICONS = {
|
|
57
|
+
"back": {"bootstrap": "bi-arrow-left", "material": "arrow_back"},
|
|
58
|
+
"return": {"bootstrap": "bi-arrow-return-left", "material": "keyboard_return"},
|
|
59
|
+
"notifications": {"bootstrap": "bi-bell-fill", "material": "notifications"},
|
|
60
|
+
"check": {"bootstrap": "bi-check-circle-fill", "material": "check_circle"},
|
|
61
|
+
"folder": {"bootstrap": "bi-folder", "material": "folder"},
|
|
62
|
+
"filter": {"bootstrap": "bi-funnel", "material": "filter_alt"},
|
|
63
|
+
"home": {"bootstrap": "bi-house", "material": "home"},
|
|
64
|
+
"lock": {"bootstrap": "bi-lock-fill", "material": "lock"},
|
|
65
|
+
"edit": {"bootstrap": "bi-pencil-fill", "material": "edit"},
|
|
66
|
+
"add": {"bootstrap": "bi-plus-lg", "material": "add"},
|
|
67
|
+
"save": {"bootstrap": "bi-save-fill", "material": "save"},
|
|
68
|
+
"search": {"bootstrap": "bi-search", "material": "search"},
|
|
69
|
+
"delete": {"bootstrap": "bi-trash-fill", "material": "delete"},
|
|
70
|
+
"cancel": {"bootstrap": "bi-x-circle-fill", "material": "cancel"},
|
|
71
|
+
"close": {"bootstrap": "bi-x-lg", "material": "close"},
|
|
72
|
+
"page": {"bootstrap": "bi-activity", "material": "label"},
|
|
73
|
+
"list": {"bootstrap": "bi-list-ul", "material": "list"},
|
|
74
|
+
"logout": {"bootstrap": "bi-box-arrow-right", "material": "logout"},
|
|
75
|
+
"expand": {"bootstrap": "bi-chevron-right", "material": "chevron_right"},
|
|
76
|
+
"settings": {"bootstrap": "bi-gear-fill", "material": "settings"},
|
|
77
|
+
"extension": {"bootstrap": "bi-puzzle-fill", "material": "extension"},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register_icons(icons):
|
|
82
|
+
"""Register additional semantic icons (e.g. from a companion library).
|
|
83
|
+
|
|
84
|
+
``icons`` maps each semantic name to a dict providing **both** icon modes,
|
|
85
|
+
e.g. ``{"profile": {"bootstrap": "bi-person", "material": "person"}}``.
|
|
86
|
+
Entries are merged into the global ``ICONS`` registry, overriding any
|
|
87
|
+
existing row with the same name. A ``ValueError`` is raised when a mode is
|
|
88
|
+
missing, so a half-defined icon can't silently render blank under the other
|
|
89
|
+
icon set. Returns the names registered.
|
|
90
|
+
"""
|
|
91
|
+
for name, glyphs in icons.items():
|
|
92
|
+
missing = {"bootstrap", "material"} - set(glyphs)
|
|
93
|
+
if missing:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"icon {name!r} is missing mode(s) {sorted(missing)}; both "
|
|
96
|
+
"'bootstrap' and 'material' glyphs are required"
|
|
97
|
+
)
|
|
98
|
+
ICONS[name] = {
|
|
99
|
+
"bootstrap": glyphs["bootstrap"],
|
|
100
|
+
"material": glyphs["material"],
|
|
101
|
+
}
|
|
102
|
+
return list(icons)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_glyph(name, mode):
|
|
106
|
+
"""Resolve a semantic icon name to a concrete glyph for ``mode``.
|
|
107
|
+
|
|
108
|
+
Unknown names pass through unchanged (covers app-supplied icon strings).
|
|
109
|
+
"""
|
|
110
|
+
entry = ICONS.get(name)
|
|
111
|
+
return entry[mode] if entry else name
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_icon_mode(theme=None, design=None):
|
|
115
|
+
"""Resolve the active icon mode: a design override wins, else the theme."""
|
|
116
|
+
if design and design in DESIGN_ICON_MODE:
|
|
117
|
+
return DESIGN_ICON_MODE[design]
|
|
118
|
+
return THEME_ICON_MODE.get(theme, DEFAULT_ICON_MODE)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _current_icon_mode():
|
|
122
|
+
"""Resolve the icon mode for the active request.
|
|
123
|
+
|
|
124
|
+
Prefers the live ``construct`` (which folds in any design override); falls
|
|
125
|
+
back to the bare theme on ``g`` for contexts that render components without
|
|
126
|
+
a full ``construct`` in scope (e.g. standalone component rendering).
|
|
127
|
+
"""
|
|
128
|
+
construct = getattr(g, "construct", None)
|
|
129
|
+
if construct is not None:
|
|
130
|
+
return construct.icon_mode
|
|
131
|
+
return THEME_ICON_MODE.get(getattr(g, "construct_theme", None), DEFAULT_ICON_MODE)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def render_icon(name, default=None, cls="", style="", mode=None):
|
|
135
|
+
"""Render an icon as theme-appropriate, escaped HTML.
|
|
136
|
+
|
|
137
|
+
``name`` semantic icon name (see ``ICONS``) or a raw glyph; a falsy value
|
|
138
|
+
falls back to ``default``. Semantic names are translated to the
|
|
139
|
+
active mode's glyph; unknown names pass through verbatim.
|
|
140
|
+
``default`` icon used when ``name`` is empty.
|
|
141
|
+
``cls`` extra CSS classes appended to the wrapper element.
|
|
142
|
+
``style`` inline style applied to the wrapper element.
|
|
143
|
+
``mode`` force a specific icon mode; defaults to the active request's.
|
|
144
|
+
"""
|
|
145
|
+
name = (name or default or "").strip()
|
|
146
|
+
if not name:
|
|
147
|
+
return Markup("")
|
|
148
|
+
if mode is None:
|
|
149
|
+
mode = _current_icon_mode()
|
|
150
|
+
glyph = resolve_glyph(name, mode)
|
|
151
|
+
extra = cls.strip()
|
|
152
|
+
style_attr = Markup(' style="{}"').format(style) if style.strip() else Markup("")
|
|
153
|
+
if mode == "bootstrap":
|
|
154
|
+
classes = "bi " + glyph + ((" " + extra) if extra else "")
|
|
155
|
+
return Markup('<i class="{}"{}></i>').format(classes, style_attr)
|
|
156
|
+
# material (default)
|
|
157
|
+
classes = "material-symbols-outlined" + ((" " + extra) if extra else "")
|
|
158
|
+
return Markup('<span class="{}"{}>{}</span>').format(classes, style_attr, glyph)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
crud-edit-rec = Edit {$recname}
|
|
2
|
+
crud-create-rec = Create New {$recname}
|
|
3
|
+
crud-enter-field = Enter {$fieldname}
|
|
4
|
+
crud-create-button = Create
|
|
5
|
+
crud-save-button = Save
|
|
6
|
+
crud-cancel-button = Cancel
|
|
7
|
+
crud-action-title = Actions
|
|
8
|
+
crud-num-notifications = {$count ->
|
|
9
|
+
[one] 1 Notification
|
|
10
|
+
*[other] {$count} Notifications
|
|
11
|
+
}
|
|
12
|
+
crud-see-all-notifications = See all notifications
|
|
13
|
+
crud-sign-out = Sign-out
|
|
14
|
+
crud-error-creating-item = Error creating item.
|
|
15
|
+
crud-validation-errors = Please correct the errors below:
|
|
16
|
+
crud-error-updating-item = Error updating item.
|
|
17
|
+
crud-confirm-delete = Are you sure you want to delete this item?
|
|
18
|
+
crud-error-deleting-item = Error deleting item.
|
|
19
|
+
crud-error-not-found = Item not found.
|
|
20
|
+
crud-item-created = Item created successfully.
|
|
21
|
+
crud-item-updated = Item updated successfully.
|
|
22
|
+
crud-item-deleted = Item deleted successfully.
|
|
23
|
+
crud-search-all = Search all fields
|
|
24
|
+
crud-no-items-found = No items found
|
|
25
|
+
crud-add-new-item = Add new item
|
|
26
|
+
crud-new-item = New item
|
|
27
|
+
crud-select = Select
|
|
28
|
+
crud-search = Search...
|
|
29
|
+
crud-change = Change
|
|
30
|
+
crud-enter-new-value = Enter new value
|
|
31
|
+
crud-quick-add = Add "{$value}"
|
|
32
|
+
crud-pending-new = (new)
|
|
33
|
+
crud-back-button = Back
|
|
34
|
+
crud-edit-tab = Edit
|
|
35
|
+
live-not-updating = Not updating
|
|
36
|
+
|
|
37
|
+
auth-login-required = You must log in to access this page.
|
|
38
|
+
auth-access-denied = You do not have permission to access this resource.
|
|
39
|
+
auth-forbidden-title = Access denied
|
|
40
|
+
auth-forbidden-message = You do not have permission to view this page.
|
|
41
|
+
auth-back-home = Back to start
|
|
42
|
+
|
|
43
|
+
validation-field-required = This field is required
|
|
44
|
+
validation-must-be-string = Must be a string
|
|
45
|
+
validation-must-be-alpha = Must contain only letters
|
|
46
|
+
validation-must-be-alphanumeric = Must contain only letters and digits
|
|
47
|
+
validation-must-be-email = Must be a valid email address
|
|
48
|
+
validation-must-be-ip = Must be a valid IP address
|
|
49
|
+
validation-must-be-url = Must be a valid URL
|
|
50
|
+
validation-must-be-domain = Must be a valid domain
|
|
51
|
+
validation-must-be-capitalize = Must be capitalized
|
|
52
|
+
validation-must-be-lower = Must be lowercase
|
|
53
|
+
validation-must-be-upper = Must be uppercase
|
|
54
|
+
validation-must-be-trim = Must not have leading or trailing whitespace
|
|
55
|
+
validation-must-be-number = Must be a number
|
|
56
|
+
validation-must-be-integer = Must be an integer
|
|
57
|
+
validation-must-be-datetime = Must be a valid date/time
|
|
58
|
+
validation-must-be-exactly = Must be exactly '{$literal}'
|
|
59
|
+
validation-must-match-pattern = Must match pattern /{$pattern}/
|
|
60
|
+
validation-must-match-one = Must satisfy one of the options
|
|
61
|
+
validation-must-be-one-of = Must be one of: {$options}
|
|
62
|
+
validation-string-len-greater = Length must be greater than {$limit} characters
|
|
63
|
+
validation-string-len-less = Length must be less than {$limit} characters
|
|
64
|
+
validation-string-len-equal = Length must be equal to {$limit} characters
|
|
65
|
+
validation-string-len-not-equal = Length must not be equal to {$limit} characters
|
|
66
|
+
validation-string-len-at-least = Length must be at least {$limit} characters
|
|
67
|
+
validation-string-len-at-most = Length must be at most {$limit} characters
|
|
68
|
+
validation-number-val-greater = Value must be greater than {$limit}
|
|
69
|
+
validation-number-val-less = Value must be less than {$limit}
|
|
70
|
+
validation-number-val-equal = Value must be equal to {$limit}
|
|
71
|
+
validation-number-val-not-equal = Value must not be equal to {$limit}
|
|
72
|
+
validation-number-val-at-least = Value must be at least {$limit}
|
|
73
|
+
validation-number-val-at-most = Value must be at most {$limit}
|
|
74
|
+
|
|
75
|
+
extensions-title = System Extensions
|
|
76
|
+
extensions-name-col = Extension Name
|
|
77
|
+
extensions-package-col = Package
|
|
78
|
+
extensions-version-col = Version
|
|
79
|
+
extensions-status-col = Status
|
|
80
|
+
extensions-desc-col = Details / Entry Point
|
|
81
|
+
extensions-no-extensions = No extensions registered via entry points.
|
|
82
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
crud-edit-rec = Redigera {$recname}
|
|
2
|
+
crud-create-rec = Skapa ny {$recname}
|
|
3
|
+
crud-enter-field = Ange {$fieldname}
|
|
4
|
+
crud-create-button = Skapa
|
|
5
|
+
crud-save-button = Spara
|
|
6
|
+
crud-cancel-button = Avbryt
|
|
7
|
+
crud-action-title = Åtgärder
|
|
8
|
+
crud-num-notifications = {$count ->
|
|
9
|
+
[one] 1 notifiering
|
|
10
|
+
*[other] {$count} notifieringar
|
|
11
|
+
}
|
|
12
|
+
crud-see-all-notifications = Se alla notifieringar
|
|
13
|
+
crud-sign-out = Logga ut
|
|
14
|
+
crud-error-creating-item = Fel vid skapande av post.
|
|
15
|
+
crud-validation-errors = Rätta felen nedan:
|
|
16
|
+
crud-error-updating-item = Fel vid uppdatering av post.
|
|
17
|
+
crud-confirm-delete = Är du säker på att du vill ta bort den här posten?
|
|
18
|
+
crud-error-deleting-item = Fel vid borttagning av post.
|
|
19
|
+
crud-error-not-found = Posten hittades inte.
|
|
20
|
+
crud-item-created = Posten skapades framgångsrikt.
|
|
21
|
+
crud-item-updated = Posten uppdaterades framgångsrikt.
|
|
22
|
+
crud-item-deleted = Posten togs bort framgångsrikt.
|
|
23
|
+
crud-search-all = Sök alla fält
|
|
24
|
+
crud-no-items-found = Inga poster hittades
|
|
25
|
+
crud-add-new-item = Lägg till ny post
|
|
26
|
+
crud-new-item = Ny post
|
|
27
|
+
crud-select = Välj
|
|
28
|
+
crud-search = Sök...
|
|
29
|
+
crud-change = Ändra
|
|
30
|
+
crud-enter-new-value = Ange nytt värde
|
|
31
|
+
crud-quick-add = Lägg till "{$value}"
|
|
32
|
+
crud-pending-new = (ny)
|
|
33
|
+
crud-back-button = Tillbaka
|
|
34
|
+
crud-edit-tab = Redigera
|
|
35
|
+
live-not-updating = Uppdateras inte
|
|
36
|
+
|
|
37
|
+
auth-login-required = Du måste logga in för att komma åt den här sidan.
|
|
38
|
+
auth-access-denied = Du har inte behörighet att komma åt den här resursen.
|
|
39
|
+
auth-forbidden-title = Åtkomst nekad
|
|
40
|
+
auth-forbidden-message = Du har inte behörighet att visa den här sidan.
|
|
41
|
+
auth-back-home = Tillbaka till start
|
|
42
|
+
|
|
43
|
+
validation-field-required = Detta fält är obligatoriskt
|
|
44
|
+
validation-must-be-string = Måste vara en sträng
|
|
45
|
+
validation-must-be-alpha = Måste innehålla endast bokstäver
|
|
46
|
+
validation-must-be-alphanumeric = Måste innehålla endast bokstäver och siffror
|
|
47
|
+
validation-must-be-email = Måste vara en giltig e-postadress
|
|
48
|
+
validation-must-be-ip = Måste vara en giltig IP-adress
|
|
49
|
+
validation-must-be-url = Måste vara en giltig webbadress
|
|
50
|
+
validation-must-be-domain = Måste vara en giltig domän
|
|
51
|
+
validation-must-be-capitalize = Måste börja med stor bokstav
|
|
52
|
+
validation-must-be-lower = Måste vara gemener
|
|
53
|
+
validation-must-be-upper = Måste vara versaler
|
|
54
|
+
validation-must-be-trim = Får inte innehålla inledande eller avslutande mellanslag
|
|
55
|
+
validation-must-be-number = Måste vara ett nummer
|
|
56
|
+
validation-must-be-integer = Måste vara ett heltal
|
|
57
|
+
validation-must-be-datetime = Måste vara ett giltigt datum/klockslag
|
|
58
|
+
validation-must-be-exactly = Måste vara exakt '{$literal}'
|
|
59
|
+
validation-must-match-pattern = Måste matcha mönstret /{$pattern}/
|
|
60
|
+
validation-must-match-one = Måste uppfylla ett av alternativen
|
|
61
|
+
validation-must-be-one-of = Måste vara ett av: {$options}
|
|
62
|
+
validation-string-len-greater = Längden måste vara större än {$limit} tecken
|
|
63
|
+
validation-string-len-less = Längden måste vara mindre än {$limit} tecken
|
|
64
|
+
validation-string-len-equal = Längden måste vara exakt {$limit} tecken
|
|
65
|
+
validation-string-len-not-equal = Längden får inte vara {$limit} tecken
|
|
66
|
+
validation-string-len-at-least = Längden måste vara minst {$limit} tecken
|
|
67
|
+
validation-string-len-at-most = Längden får vara högst {$limit} tecken
|
|
68
|
+
validation-number-val-greater = Värdet måste vara större än {$limit}
|
|
69
|
+
validation-number-val-less = Värdet måste vara mindre än {$limit}
|
|
70
|
+
validation-number-val-equal = Värdet måste vara lika med {$limit}
|
|
71
|
+
validation-number-val-not-equal = Värdet får inte vara lika med {$limit}
|
|
72
|
+
validation-number-val-at-least = Värdet måste vara minst {$limit}
|
|
73
|
+
validation-number-val-at-most = Värdet får vara högst {$limit}
|
|
74
|
+
|
|
75
|
+
extensions-title = Systemtillägg
|
|
76
|
+
extensions-name-col = Tilläggsnamn
|
|
77
|
+
extensions-package-col = Paket
|
|
78
|
+
extensions-version-col = Version
|
|
79
|
+
extensions-status-col = Status
|
|
80
|
+
extensions-desc-col = Detaljer / Entry Point
|
|
81
|
+
extensions-no-extensions = Inga tillägg registrerade via entry points.
|
|
82
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from flask import g, request, current_app
|
|
3
|
+
from fluent.runtime import FluentBundle
|
|
4
|
+
from fluent.syntax.parser import FluentParser
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
LOCALE_MAPPING = {
|
|
8
|
+
"en": "en_US.UTF-8",
|
|
9
|
+
"sv": "sv_SE.UTF-8",
|
|
10
|
+
"de": "de_DE.UTF-8",
|
|
11
|
+
"fr": "fr_FR.UTF-8",
|
|
12
|
+
"es": "es_ES.UTF-8",
|
|
13
|
+
"zh": "zh_CN.UTF-8",
|
|
14
|
+
"ja": "ja_JP.UTF-8",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def set_language_from_browser():
|
|
19
|
+
g.language = request.accept_languages.best_match(current_app.l10n.languages)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Translator:
|
|
23
|
+
"""
|
|
24
|
+
Flask integrated format_value call.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
languages = []
|
|
28
|
+
translations = {}
|
|
29
|
+
|
|
30
|
+
def t(self, msg_id, args=None):
|
|
31
|
+
# Fallback to English if the user's language is not available (or not
|
|
32
|
+
# yet resolved — e.g. a before_request short-circuits with a translated
|
|
33
|
+
# response before set_language_from_browser runs).
|
|
34
|
+
lang = g.get("language", None)
|
|
35
|
+
bundle = self.translations.get(lang, self.translations.get("en"))
|
|
36
|
+
if not bundle:
|
|
37
|
+
return msg_id
|
|
38
|
+
if not bundle.has_message(msg_id):
|
|
39
|
+
return msg_id
|
|
40
|
+
message = bundle.get_message(msg_id)
|
|
41
|
+
if message and message.value:
|
|
42
|
+
value, errors = bundle.format_pattern(message.value, args)
|
|
43
|
+
if errors:
|
|
44
|
+
print(f"Fluent translation errors for '{msg_id}': {errors} - {value}")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
# Return ID as default
|
|
48
|
+
return msg_id
|
|
49
|
+
|
|
50
|
+
def format_value(self, msg_id, args=None):
|
|
51
|
+
return self.t(msg_id, args)
|
|
52
|
+
|
|
53
|
+
def set_languages(self, paths):
|
|
54
|
+
if isinstance(paths, str):
|
|
55
|
+
paths = [paths]
|
|
56
|
+
|
|
57
|
+
# Collect the union of available language codes across all search
|
|
58
|
+
# paths, preserving first-seen order. (Previously only the first path
|
|
59
|
+
# with any language dirs was consulted, which dropped languages shipped
|
|
60
|
+
# solely by a later path — e.g. a companion library's l10n dir.)
|
|
61
|
+
self.translations = {}
|
|
62
|
+
self.languages = []
|
|
63
|
+
for p in paths:
|
|
64
|
+
if os.path.isdir(p):
|
|
65
|
+
try:
|
|
66
|
+
for fldr in sorted(next(os.walk(p))[1]):
|
|
67
|
+
if len(fldr) in (2, 5) and fldr not in self.languages:
|
|
68
|
+
self.languages.append(fldr)
|
|
69
|
+
except StopIteration:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
parser = FluentParser()
|
|
73
|
+
|
|
74
|
+
# For each language, load all .flt files from all paths
|
|
75
|
+
for lng in self.languages:
|
|
76
|
+
all_ftl_content = []
|
|
77
|
+
for p in paths:
|
|
78
|
+
locale_path = os.path.join(p, lng)
|
|
79
|
+
if os.path.isdir(locale_path):
|
|
80
|
+
for filename in os.listdir(locale_path):
|
|
81
|
+
if filename.endswith(".flt"):
|
|
82
|
+
try:
|
|
83
|
+
with open(
|
|
84
|
+
os.path.join(locale_path, filename),
|
|
85
|
+
"r",
|
|
86
|
+
encoding="utf-8",
|
|
87
|
+
) as f:
|
|
88
|
+
all_ftl_content.append(f.read())
|
|
89
|
+
except IOError:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if not all_ftl_content:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
concatenated_content = "\n".join(all_ftl_content)
|
|
96
|
+
|
|
97
|
+
bundle = FluentBundle([lng], use_isolating=False)
|
|
98
|
+
resource = parser.parse(concatenated_content)
|
|
99
|
+
bundle.add_resource(resource)
|
|
100
|
+
self.translations[lng] = bundle
|