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.
Files changed (80) hide show
  1. flask_construct_ui/__init__.py +82 -0
  2. flask_construct_ui/auth.py +290 -0
  3. flask_construct_ui/base.py +1259 -0
  4. flask_construct_ui/components.py +812 -0
  5. flask_construct_ui/crud.py +1641 -0
  6. flask_construct_ui/csrf.py +39 -0
  7. flask_construct_ui/data.py +257 -0
  8. flask_construct_ui/data_sqlalchemy.py +304 -0
  9. flask_construct_ui/forms.py +130 -0
  10. flask_construct_ui/headless.py +110 -0
  11. flask_construct_ui/icons.py +158 -0
  12. flask_construct_ui/l10n/en/main.flt +82 -0
  13. flask_construct_ui/l10n/sv/main.flt +82 -0
  14. flask_construct_ui/language.py +100 -0
  15. flask_construct_ui/navbar.py +54 -0
  16. flask_construct_ui/navigation.py +58 -0
  17. flask_construct_ui/orgmode.py +338 -0
  18. flask_construct_ui/pagination.py +39 -0
  19. flask_construct_ui/py.typed +0 -0
  20. flask_construct_ui/sanitize.py +25 -0
  21. flask_construct_ui/schema.py +507 -0
  22. flask_construct_ui/static/css/prosemirror-bundle.min.css +1 -0
  23. flask_construct_ui/static/css/unpoly.min.css +6 -0
  24. flask_construct_ui/static/js/construct-core.min.js +1 -0
  25. flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
  26. flask_construct_ui/static/js/unpoly.min.js +1 -0
  27. flask_construct_ui/static/themes/whitelabel/css/whitelabel.css +720 -0
  28. flask_construct_ui/static/themes/whitelabel/designs/adminlte.css +94 -0
  29. flask_construct_ui/static/themes/whitelabel/designs/sandstone.css +64 -0
  30. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/LICENSE.txt +21 -0
  31. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/bootstrap-icons.min.css +5 -0
  32. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  33. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  34. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/LICENSE.txt +202 -0
  35. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  36. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols.css +26 -0
  37. flask_construct_ui/templates/structure/base.html +5 -0
  38. flask_construct_ui/templates/structure/flashes.html +7 -0
  39. flask_construct_ui/templates/structure/public.html +18 -0
  40. flask_construct_ui/templates/themes/whitelabel/components/_form_field.html +69 -0
  41. flask_construct_ui/templates/themes/whitelabel/components/button.html +16 -0
  42. flask_construct_ui/templates/themes/whitelabel/components/calendar.html +30 -0
  43. flask_construct_ui/templates/themes/whitelabel/components/calendarmini.html +30 -0
  44. flask_construct_ui/templates/themes/whitelabel/components/card.html +25 -0
  45. flask_construct_ui/templates/themes/whitelabel/components/chart.html +5 -0
  46. flask_construct_ui/templates/themes/whitelabel/components/form.html +61 -0
  47. flask_construct_ui/templates/themes/whitelabel/components/grid_layout.html +5 -0
  48. flask_construct_ui/templates/themes/whitelabel/components/horizontal_layout.html +5 -0
  49. flask_construct_ui/templates/themes/whitelabel/components/live.html +40 -0
  50. flask_construct_ui/templates/themes/whitelabel/components/main.html +4 -0
  51. flask_construct_ui/templates/themes/whitelabel/components/markup.html +1 -0
  52. flask_construct_ui/templates/themes/whitelabel/components/rich_text.html +19 -0
  53. flask_construct_ui/templates/themes/whitelabel/components/table.html +30 -0
  54. flask_construct_ui/templates/themes/whitelabel/components/tabs.html +30 -0
  55. flask_construct_ui/templates/themes/whitelabel/components/text.html +26 -0
  56. flask_construct_ui/templates/themes/whitelabel/components/vertical_layout.html +5 -0
  57. flask_construct_ui/templates/themes/whitelabel/crud/_macros.html +962 -0
  58. flask_construct_ui/templates/themes/whitelabel/crud/_many_related_options.html +26 -0
  59. flask_construct_ui/templates/themes/whitelabel/crud/_related_child_form.html +98 -0
  60. flask_construct_ui/templates/themes/whitelabel/crud/_related_child_list.html +136 -0
  61. flask_construct_ui/templates/themes/whitelabel/crud/_related_options.html +15 -0
  62. flask_construct_ui/templates/themes/whitelabel/crud/create.html +45 -0
  63. flask_construct_ui/templates/themes/whitelabel/crud/edit.html +125 -0
  64. flask_construct_ui/templates/themes/whitelabel/crud/listindex.html +238 -0
  65. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_child_view.html +33 -0
  66. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_form.html +125 -0
  67. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_list.html +140 -0
  68. flask_construct_ui/templates/themes/whitelabel/crud/tab_view.html +38 -0
  69. flask_construct_ui/templates/themes/whitelabel/errors/forbidden.html +16 -0
  70. flask_construct_ui/templates/themes/whitelabel/navbar/notifications.html +26 -0
  71. flask_construct_ui/templates/themes/whitelabel/navbar/search_results.html +31 -0
  72. flask_construct_ui/templates/themes/whitelabel/style/_flashes.html +16 -0
  73. flask_construct_ui/templates/themes/whitelabel/style/base_full.html +181 -0
  74. flask_construct_ui/templates/themes/whitelabel/style/base_partial.html +16 -0
  75. flask_construct_ui/validation.py +422 -0
  76. flask_construct_ui-0.9.5.dist-info/METADATA +60 -0
  77. flask_construct_ui-0.9.5.dist-info/RECORD +80 -0
  78. flask_construct_ui-0.9.5.dist-info/WHEEL +5 -0
  79. flask_construct_ui-0.9.5.dist-info/licenses/LICENSE +165 -0
  80. 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