flask-construct-ui 0.11.2__tar.gz → 0.11.4__tar.gz
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-0.11.2 → flask_construct_ui-0.11.4}/PKG-INFO +1 -1
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/auth.py +2 -2
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/base.py +378 -61
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/l10n/en/main.flt +8 -1
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/l10n/sv/main.flt +8 -1
- flask_construct_ui-0.11.4/flask_construct_ui/static/js/construct-core.min.js +1 -0
- flask_construct_ui-0.11.4/flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/related_crud_tab_list.html +3 -3
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/navbar/search_results.html +1 -1
- flask_construct_ui-0.11.4/flask_construct_ui/templates/style/_menu.html +67 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/style/base_full.html +10 -58
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/validation.py +19 -4
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/PKG-INFO +1 -1
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/SOURCES.txt +1 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/pyproject.toml +1 -1
- flask_construct_ui-0.11.2/flask_construct_ui/static/js/construct-core.min.js +0 -1
- flask_construct_ui-0.11.2/flask_construct_ui/static/js/prosemirror-bundle.min.js +0 -34
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/LICENSE +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/README.md +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/__init__.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/cli.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/components.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/contrast.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/crud.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/csrf.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/data.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/data_sqlalchemy.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/forms.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/grid.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/headless.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/icons.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/language.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/navbar.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/navigation.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/orgmode.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/pagination.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/py.typed +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/resume.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/sanitize.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/schema.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/css/grid.css +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/css/unpoly.min.css +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/css/whitelabel.css +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/designs/adminlte.css +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/designs/sandstone.css +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/bootstrap-icons/bootstrap-icons.min.css +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/js/construct-grid.min.js +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/js/unpoly.min.js +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/_form_field.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/button.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/calendar.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/calendarmini.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/card.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/chart.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/form.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/grid.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/grid_layout.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/grid_notice.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/kanban.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/live.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/main.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/markup.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/rich_text.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/table.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/tabs.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/text.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/_macros.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/_many_related_options.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/_related_options.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/create.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/edit.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/listindex.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/related_crud_tab_form.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/tab_view.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/view.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/errors/forbidden.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/navbar/notifications.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/structure/base.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/structure/public.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/style/_flashes.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/style/base_partial.html +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/testing.py +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/requires.txt +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/top_level.txt +0 -0
- {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/setup.cfg +0 -0
|
@@ -291,7 +291,7 @@ class FlaskSecurityAuth(AuthProvider):
|
|
|
291
291
|
|
|
292
292
|
return [
|
|
293
293
|
{
|
|
294
|
-
"label": "Sign out",
|
|
294
|
+
"label": _t("crud-sign-out", "Sign out"),
|
|
295
295
|
"url": url_for("security.logout"),
|
|
296
296
|
"icon": "logout",
|
|
297
297
|
}
|
|
@@ -490,7 +490,7 @@ class FlaskOIDCAuth(AuthProvider):
|
|
|
490
490
|
|
|
491
491
|
return [
|
|
492
492
|
{
|
|
493
|
-
"label": "Sign out",
|
|
493
|
+
"label": _t("crud-sign-out", "Sign out"),
|
|
494
494
|
"url": url_for(self.logout_endpoint),
|
|
495
495
|
"icon": "logout",
|
|
496
496
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from functools import wraps
|
|
4
|
+
import hashlib
|
|
4
5
|
from importlib.metadata import entry_points
|
|
5
6
|
import inspect
|
|
6
7
|
import json
|
|
@@ -140,6 +141,50 @@ def apply_trailing_slash(url, construct):
|
|
|
140
141
|
return url + "/"
|
|
141
142
|
|
|
142
143
|
|
|
144
|
+
# Header carrying the menu fingerprint of an HTML response, so a client whose
|
|
145
|
+
# sidebar was rendered for a different one can refresh it. Mirrors
|
|
146
|
+
# AUTH_STAMP_HEADER; see FlaskConstructUI.menu_stamp.
|
|
147
|
+
MENU_STAMP_HEADER = "X-Construct-Menu"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _copy_menu_entry(entry):
|
|
151
|
+
"""Copy a registered entry deeply enough to be reshaped per request.
|
|
152
|
+
|
|
153
|
+
The per-request build filters subitems and merges provider entries into
|
|
154
|
+
existing groups, so the registered dicts and their ``subitems`` lists must
|
|
155
|
+
not be shared with it — a filtered submenu would otherwise permanently lose
|
|
156
|
+
the entries the first user was not allowed to see. Values inside an entry
|
|
157
|
+
(icons, callables) are shared, since nothing rewrites them.
|
|
158
|
+
"""
|
|
159
|
+
copy = dict(entry)
|
|
160
|
+
if "subitems" in copy:
|
|
161
|
+
copy["subitems"] = [dict(sub) for sub in copy["subitems"]]
|
|
162
|
+
return copy
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _sort_menu(items):
|
|
166
|
+
"""Order entries by weight, keeping registration order within a weight."""
|
|
167
|
+
items.sort(key=lambda entry: entry.get("weight", 0))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _menu_fingerprint(items):
|
|
171
|
+
"""The parts of a menu a viewer could notice changing.
|
|
172
|
+
|
|
173
|
+
Titles, links, icons and nesting — not weights or the callables that
|
|
174
|
+
produced them, which are invisible once the menu is rendered.
|
|
175
|
+
"""
|
|
176
|
+
return [
|
|
177
|
+
[
|
|
178
|
+
entry.get("type"),
|
|
179
|
+
entry.get("title"),
|
|
180
|
+
entry.get("link"),
|
|
181
|
+
entry.get("icon"),
|
|
182
|
+
_menu_fingerprint(entry.get("subitems", [])),
|
|
183
|
+
]
|
|
184
|
+
for entry in items
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
143
188
|
def livesource(name=None, interval=10):
|
|
144
189
|
def wrap(f):
|
|
145
190
|
f._livesource_name = name or f.__name__
|
|
@@ -821,22 +866,26 @@ class ExtensionsPage(ComponentPageBase):
|
|
|
821
866
|
]
|
|
822
867
|
)
|
|
823
868
|
|
|
869
|
+
content_comp = (
|
|
870
|
+
components.Table(
|
|
871
|
+
headers=[
|
|
872
|
+
self.t("extensions-name-col"),
|
|
873
|
+
self.t("extensions-package-col"),
|
|
874
|
+
self.t("extensions-version-col"),
|
|
875
|
+
self.t("extensions-status-col"),
|
|
876
|
+
self.t("extensions-desc-col"),
|
|
877
|
+
],
|
|
878
|
+
rows=rows,
|
|
879
|
+
)
|
|
880
|
+
if rows
|
|
881
|
+
else components.Text(content=self.t("extensions-no-extensions"))
|
|
882
|
+
)
|
|
883
|
+
|
|
824
884
|
return components.VerticalLayout(
|
|
825
885
|
components=[
|
|
826
886
|
components.Card(
|
|
827
|
-
title="extensions-title",
|
|
828
|
-
components=[
|
|
829
|
-
components.Table(
|
|
830
|
-
headers=[
|
|
831
|
-
self.t("extensions-name-col"),
|
|
832
|
-
self.t("extensions-package-col"),
|
|
833
|
-
self.t("extensions-version-col"),
|
|
834
|
-
self.t("extensions-status-col"),
|
|
835
|
-
self.t("extensions-desc-col"),
|
|
836
|
-
],
|
|
837
|
-
rows=rows,
|
|
838
|
-
)
|
|
839
|
-
],
|
|
887
|
+
title=self.t("extensions-title"),
|
|
888
|
+
components=[content_comp],
|
|
840
889
|
)
|
|
841
890
|
]
|
|
842
891
|
)
|
|
@@ -862,6 +911,7 @@ class FlaskConstructUI:
|
|
|
862
911
|
auth=None,
|
|
863
912
|
search=None,
|
|
864
913
|
notifications=None,
|
|
914
|
+
menu_poll=None,
|
|
865
915
|
discover_extensions=True,
|
|
866
916
|
strict_slashes=None,
|
|
867
917
|
check_trailing_slashes=False,
|
|
@@ -869,6 +919,18 @@ class FlaskConstructUI:
|
|
|
869
919
|
):
|
|
870
920
|
self._pages = []
|
|
871
921
|
self._menu = []
|
|
922
|
+
# Callables contributing menu entries at render time; see
|
|
923
|
+
# add_menu_provider. Each is (fn, key) where key is an optional cheap
|
|
924
|
+
# fingerprint callable that lets menu_stamp skip building the menu.
|
|
925
|
+
self._menu_providers = []
|
|
926
|
+
# True once any registered entry carries visibility rules, so a purely
|
|
927
|
+
# static app never pays for stamping.
|
|
928
|
+
self._menu_gated = False
|
|
929
|
+
# True once a registered entry carries a `visible` predicate. Such an
|
|
930
|
+
# entry can change on nothing the auth stamp can see, so it rules out
|
|
931
|
+
# the cheap stamp path below.
|
|
932
|
+
self._menu_predicates = False
|
|
933
|
+
self._menu_poll = menu_poll
|
|
872
934
|
self._blueprint_names = set()
|
|
873
935
|
self._pages_by_endpoint = {}
|
|
874
936
|
self._registered_pages = set()
|
|
@@ -1002,6 +1064,22 @@ class FlaskConstructUI:
|
|
|
1002
1064
|
response.headers.setdefault(AUTH_STAMP_HEADER, auth_stamp(self._auth))
|
|
1003
1065
|
return response
|
|
1004
1066
|
|
|
1067
|
+
@app.after_request
|
|
1068
|
+
def add_menu_stamp(response):
|
|
1069
|
+
# Same problem as the auth stamp, one level down: the sidebar is
|
|
1070
|
+
# outside #main-content, so a fragment response leaves whatever
|
|
1071
|
+
# menu the page was loaded with standing — including one built
|
|
1072
|
+
# before the user joined a group, or before the feed a provider
|
|
1073
|
+
# reads gained an entry. Tagging every HTML response with the menu
|
|
1074
|
+
# it *would* have rendered lets the client notice and reload just
|
|
1075
|
+
# the menu fragment. Static menus never get here.
|
|
1076
|
+
if not self.menu_is_dynamic or "text/html" not in response.content_type:
|
|
1077
|
+
return response
|
|
1078
|
+
stamp = self.menu_stamp
|
|
1079
|
+
if stamp is not None:
|
|
1080
|
+
response.headers.setdefault(MENU_STAMP_HEADER, stamp)
|
|
1081
|
+
return response
|
|
1082
|
+
|
|
1005
1083
|
# A stash the library's own CRUD forms consume when they render it,
|
|
1006
1084
|
# but an app template using resumed_value() only reads — so retire it
|
|
1007
1085
|
# here once the form it belongs to has been submitted successfully.
|
|
@@ -1157,51 +1235,70 @@ class FlaskConstructUI:
|
|
|
1157
1235
|
self._pages_by_endpoint[page.endpoint] = page
|
|
1158
1236
|
self._register_page_blueprint(page)
|
|
1159
1237
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1238
|
+
# The features blueprint is registered unconditionally because the menu
|
|
1239
|
+
# fragment route cannot be conditional: providers are normally added
|
|
1240
|
+
# after init_app (`fcu = FlaskConstructUI(app)` first, decorators
|
|
1241
|
+
# after), so anything keyed on menu_is_dynamic here would decide before
|
|
1242
|
+
# the app had its say. Search and notifications keep their own guards
|
|
1243
|
+
# below; the menu route simply serves the static menu when no provider
|
|
1244
|
+
# ever appears.
|
|
1245
|
+
prefix = self.url.rstrip("/") + "/_construct"
|
|
1246
|
+
if self.enforce_trailing_slash and not prefix.endswith("/"):
|
|
1247
|
+
prefix += "/"
|
|
1248
|
+
|
|
1249
|
+
features_bp = Blueprint("construct_features", __name__, url_prefix=prefix)
|
|
1250
|
+
_self = self
|
|
1251
|
+
|
|
1252
|
+
search_url = "/search"
|
|
1253
|
+
notifications_url = "/notifications"
|
|
1254
|
+
menu_url = "/menu"
|
|
1255
|
+
if self.enforce_trailing_slash:
|
|
1256
|
+
search_url += "/"
|
|
1257
|
+
notifications_url += "/"
|
|
1258
|
+
menu_url += "/"
|
|
1259
|
+
|
|
1260
|
+
route_kwargs = {}
|
|
1261
|
+
if self.strict_slashes is not None:
|
|
1262
|
+
route_kwargs["strict_slashes"] = self.strict_slashes
|
|
1263
|
+
|
|
1264
|
+
if self._search is not None:
|
|
1265
|
+
_search = self._search
|
|
1266
|
+
|
|
1267
|
+
@features_bp.route(search_url, **route_kwargs)
|
|
1268
|
+
def search_results():
|
|
1269
|
+
q = request.args.get("q", "")
|
|
1270
|
+
results = _search.search(q)
|
|
1271
|
+
return render_template(
|
|
1272
|
+
"navbar/search_results.html",
|
|
1273
|
+
construct=_self,
|
|
1274
|
+
results=results,
|
|
1275
|
+
query=q,
|
|
1276
|
+
)
|
|
1191
1277
|
|
|
1192
|
-
|
|
1278
|
+
if self._notifications is not None:
|
|
1193
1279
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1280
|
+
@features_bp.route(notifications_url, **route_kwargs)
|
|
1281
|
+
def notifications_fragment():
|
|
1282
|
+
# Just the #fcu-notifications fragment; Unpoly's up-poll
|
|
1283
|
+
# reloads it on a timer to keep the count + items fresh.
|
|
1284
|
+
return render_template(
|
|
1285
|
+
"navbar/notifications.html",
|
|
1286
|
+
construct=_self,
|
|
1287
|
+
)
|
|
1202
1288
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1289
|
+
@features_bp.route(menu_url, **route_kwargs)
|
|
1290
|
+
def menu_fragment():
|
|
1291
|
+
# Just the #fcu-main-menu nav, the same markup the full layout
|
|
1292
|
+
# includes. construct-core.js reloads it through up-source when a
|
|
1293
|
+
# response's menu stamp disagrees with the shell's; up-poll can
|
|
1294
|
+
# drive it on a timer instead.
|
|
1295
|
+
return render_template(
|
|
1296
|
+
"style/_menu.html",
|
|
1297
|
+
construct=_self,
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
self.app.register_blueprint(features_bp)
|
|
1301
|
+
self._blueprint_names.add("construct_features")
|
|
1205
1302
|
|
|
1206
1303
|
def load_extensions(self):
|
|
1207
1304
|
"""Discover and load companion libraries via entry points in the
|
|
@@ -1307,7 +1404,15 @@ class FlaskConstructUI:
|
|
|
1307
1404
|
)
|
|
1308
1405
|
return warnings
|
|
1309
1406
|
|
|
1310
|
-
def add_page(
|
|
1407
|
+
def add_page(
|
|
1408
|
+
self,
|
|
1409
|
+
page,
|
|
1410
|
+
submenu=None,
|
|
1411
|
+
submenu_icon=None,
|
|
1412
|
+
groups=None,
|
|
1413
|
+
visible=None,
|
|
1414
|
+
weight=None,
|
|
1415
|
+
):
|
|
1311
1416
|
"""Register a page and add it to the navigation menu.
|
|
1312
1417
|
|
|
1313
1418
|
When ``submenu`` is given, the page's menu entry is nested under a
|
|
@@ -1316,6 +1421,18 @@ class FlaskConstructUI:
|
|
|
1316
1421
|
into the same group, in the order they are added. ``submenu_icon`` sets
|
|
1317
1422
|
the icon for the group the first time it is created; if omitted, the
|
|
1318
1423
|
group falls back to the ``folder`` icon.
|
|
1424
|
+
|
|
1425
|
+
``groups`` and ``visible`` gate the *menu entry* per request (see
|
|
1426
|
+
:meth:`menu`): the entry is listed only for a user in one of ``groups``
|
|
1427
|
+
/ only when ``visible()`` returns true. When ``groups`` is omitted the
|
|
1428
|
+
page's own ``required_groups`` is used, so a group-gated page hides its
|
|
1429
|
+
own entry with no extra argument. This is presentation only — the route
|
|
1430
|
+
behind the entry is gated by ``required_groups`` / ``@require_groups``,
|
|
1431
|
+
and an entry left out of the menu is never a substitute for that check.
|
|
1432
|
+
|
|
1433
|
+
``weight`` orders the entry among its siblings (default 0, ties keep
|
|
1434
|
+
registration order), which is what lets a provider place entries around
|
|
1435
|
+
statically registered pages.
|
|
1319
1436
|
"""
|
|
1320
1437
|
self._pages.append(page)
|
|
1321
1438
|
self._blueprint_names.add(page.endpoint)
|
|
@@ -1340,6 +1457,17 @@ class FlaskConstructUI:
|
|
|
1340
1457
|
# they need the same normalization the route patterns get —
|
|
1341
1458
|
# otherwise every sidebar entry 308-redirects to its own page.
|
|
1342
1459
|
menu["link"] = apply_trailing_slash(menu["link"], self)
|
|
1460
|
+
if groups is None:
|
|
1461
|
+
groups = getattr(page, "required_groups", None)
|
|
1462
|
+
if groups:
|
|
1463
|
+
menu.setdefault("groups", list(groups))
|
|
1464
|
+
if visible is not None:
|
|
1465
|
+
menu["visible"] = visible
|
|
1466
|
+
self._menu_predicates = True
|
|
1467
|
+
if weight is not None:
|
|
1468
|
+
menu["weight"] = weight
|
|
1469
|
+
if menu.get("groups") or menu.get("visible") is not None:
|
|
1470
|
+
self._menu_gated = True
|
|
1343
1471
|
if submenu is not None:
|
|
1344
1472
|
if submenu_icon is None:
|
|
1345
1473
|
self._add_to_submenu(submenu, menu)
|
|
@@ -1348,17 +1476,64 @@ class FlaskConstructUI:
|
|
|
1348
1476
|
else:
|
|
1349
1477
|
self._menu.append(menu)
|
|
1350
1478
|
|
|
1351
|
-
def
|
|
1479
|
+
def add_menu_provider(self, provider, key=None):
|
|
1480
|
+
"""Register a callable that contributes menu entries per request.
|
|
1481
|
+
|
|
1482
|
+
``provider(construct)`` is called once per rendered menu and returns an
|
|
1483
|
+
iterable of entry dicts (``{"type": "item", "title": ..., "icon": ...,
|
|
1484
|
+
"link": ...}``). An entry may carry ``"submenu": "<title>"`` to be
|
|
1485
|
+
placed inside that group — creating it if the group does not exist yet
|
|
1486
|
+
— plus the same ``groups`` / ``visible`` / ``weight`` keys
|
|
1487
|
+
:meth:`add_page` accepts. Titles are translated like any other entry,
|
|
1488
|
+
so a provider may return either literal text or an l10n key.
|
|
1489
|
+
|
|
1490
|
+
Nothing about this is specific to permissions: a provider is simply
|
|
1491
|
+
"code that decides part of the menu at render time", whether that means
|
|
1492
|
+
the groups a user belongs to, their five most recent discussions, or
|
|
1493
|
+
entries pulled from an external feed.
|
|
1494
|
+
|
|
1495
|
+
``key`` is an optional zero-argument callable returning a cheap value
|
|
1496
|
+
that changes whenever this provider's output would. When *every*
|
|
1497
|
+
provider supplies one, :attr:`menu_stamp` is computed from the keys and
|
|
1498
|
+
the providers never run on requests that do not render the menu. Give a
|
|
1499
|
+
``key`` to any provider whose work is more than a dict comprehension.
|
|
1500
|
+
|
|
1501
|
+
Returns ``provider``, so it also works as a decorator via
|
|
1502
|
+
:meth:`menu_provider`.
|
|
1503
|
+
"""
|
|
1504
|
+
self._menu_providers.append((provider, key))
|
|
1505
|
+
return provider
|
|
1506
|
+
|
|
1507
|
+
def menu_provider(self, key=None):
|
|
1508
|
+
"""Decorator form of :meth:`add_menu_provider`.
|
|
1509
|
+
|
|
1510
|
+
Usable bare (``@construct.menu_provider``) or called with a key
|
|
1511
|
+
(``@construct.menu_provider(key=lambda: feed.version)``).
|
|
1512
|
+
"""
|
|
1513
|
+
if callable(key):
|
|
1514
|
+
return self.add_menu_provider(key)
|
|
1515
|
+
|
|
1516
|
+
def wrap(fn):
|
|
1517
|
+
return self.add_menu_provider(fn, key=key)
|
|
1518
|
+
|
|
1519
|
+
return wrap
|
|
1520
|
+
|
|
1521
|
+
def _add_to_submenu(self, title, item, icon="folder", items=None):
|
|
1352
1522
|
"""Place ``item`` under the submenu group named ``title``.
|
|
1353
1523
|
|
|
1354
1524
|
Reuses an existing group with the same title if one was already
|
|
1355
1525
|
created, otherwise appends a new submenu entry at the current position.
|
|
1526
|
+
``items`` defaults to the statically registered menu; the per-request
|
|
1527
|
+
build passes its own working list so providers merge into the same
|
|
1528
|
+
groups without mutating the registered one.
|
|
1356
1529
|
"""
|
|
1357
|
-
|
|
1530
|
+
if items is None:
|
|
1531
|
+
items = self._menu
|
|
1532
|
+
for entry in items:
|
|
1358
1533
|
if entry.get("type") == "submenu" and entry.get("title") == title:
|
|
1359
1534
|
entry["subitems"].append(item)
|
|
1360
1535
|
return
|
|
1361
|
-
|
|
1536
|
+
items.append(
|
|
1362
1537
|
{
|
|
1363
1538
|
"type": "submenu",
|
|
1364
1539
|
"title": title,
|
|
@@ -1513,7 +1688,142 @@ class FlaskConstructUI:
|
|
|
1513
1688
|
|
|
1514
1689
|
@property
|
|
1515
1690
|
def menu(self):
|
|
1516
|
-
|
|
1691
|
+
"""The menu as it stands for this request.
|
|
1692
|
+
|
|
1693
|
+
Statically registered entries (:meth:`add_page`) first, then whatever
|
|
1694
|
+
the registered providers contribute, then visibility filtering and
|
|
1695
|
+
ordering. Built once per request and cached on ``g``, so providers run
|
|
1696
|
+
once however many times a template asks for the menu.
|
|
1697
|
+
|
|
1698
|
+
Outside a request context this is the registered menu, unfiltered:
|
|
1699
|
+
every dynamic part is by definition a function of the request (who is
|
|
1700
|
+
asking, what their feed holds), so there is nothing honest to compute
|
|
1701
|
+
without one.
|
|
1702
|
+
"""
|
|
1703
|
+
if not has_request_context():
|
|
1704
|
+
return self._menu
|
|
1705
|
+
cached = g.get("_construct_menu", None)
|
|
1706
|
+
if cached is not None:
|
|
1707
|
+
return cached
|
|
1708
|
+
items = [_copy_menu_entry(entry) for entry in self._menu]
|
|
1709
|
+
for provider, _key in self._menu_providers:
|
|
1710
|
+
for entry in provider(self) or []:
|
|
1711
|
+
self._place_entry(items, dict(entry))
|
|
1712
|
+
items = self._visible_entries(items)
|
|
1713
|
+
_sort_menu(items)
|
|
1714
|
+
g._construct_menu = items
|
|
1715
|
+
return items
|
|
1716
|
+
|
|
1717
|
+
def _place_entry(self, items, entry):
|
|
1718
|
+
"""Insert a provider-supplied ``entry`` into the working menu."""
|
|
1719
|
+
entry.setdefault("type", "item")
|
|
1720
|
+
if entry.get("link"):
|
|
1721
|
+
entry["link"] = apply_trailing_slash(entry["link"], self)
|
|
1722
|
+
submenu = entry.pop("submenu", None)
|
|
1723
|
+
if submenu is None:
|
|
1724
|
+
items.append(entry)
|
|
1725
|
+
return
|
|
1726
|
+
icon = entry.pop("submenu_icon", None) or "folder"
|
|
1727
|
+
self._add_to_submenu(submenu, entry, icon, items=items)
|
|
1728
|
+
|
|
1729
|
+
def _entry_visible(self, entry):
|
|
1730
|
+
"""Whether ``entry`` should be listed for the current request."""
|
|
1731
|
+
visible = entry.get("visible")
|
|
1732
|
+
if visible is not None and not visible():
|
|
1733
|
+
return False
|
|
1734
|
+
groups = entry.get("groups")
|
|
1735
|
+
if not groups:
|
|
1736
|
+
return True
|
|
1737
|
+
if self._auth is None:
|
|
1738
|
+
# Matches the route guards, which only enforce group membership
|
|
1739
|
+
# once an AuthProvider is configured.
|
|
1740
|
+
return True
|
|
1741
|
+
return self._auth.has_groups(groups, entry.get("groups_mode", "any"))
|
|
1742
|
+
|
|
1743
|
+
def _visible_entries(self, items):
|
|
1744
|
+
"""Filter ``items`` in place-ish, dropping submenus left empty.
|
|
1745
|
+
|
|
1746
|
+
A group that only held entries this user cannot see is noise — worse,
|
|
1747
|
+
an empty disclosure the user can open to find nothing — so it goes with
|
|
1748
|
+
its last child.
|
|
1749
|
+
"""
|
|
1750
|
+
kept = []
|
|
1751
|
+
for entry in items:
|
|
1752
|
+
if not self._entry_visible(entry):
|
|
1753
|
+
continue
|
|
1754
|
+
if entry.get("type") == "submenu":
|
|
1755
|
+
subitems = [
|
|
1756
|
+
sub for sub in entry.get("subitems", []) if self._entry_visible(sub)
|
|
1757
|
+
]
|
|
1758
|
+
if not subitems:
|
|
1759
|
+
continue
|
|
1760
|
+
entry["subitems"] = subitems
|
|
1761
|
+
_sort_menu(subitems)
|
|
1762
|
+
kept.append(entry)
|
|
1763
|
+
return kept
|
|
1764
|
+
|
|
1765
|
+
@property
|
|
1766
|
+
def menu_is_dynamic(self):
|
|
1767
|
+
"""Whether this menu can change between requests.
|
|
1768
|
+
|
|
1769
|
+
False for an app that only registers plain pages, and the stamping
|
|
1770
|
+
below is then skipped entirely: no header, no ``data-fcu-menu``, no
|
|
1771
|
+
refresh endpoint, nothing for a static app to pay for.
|
|
1772
|
+
"""
|
|
1773
|
+
return bool(self._menu_providers) or self._menu_gated
|
|
1774
|
+
|
|
1775
|
+
@property
|
|
1776
|
+
def menu_stamp(self):
|
|
1777
|
+
"""Fingerprint of the menu this request is being served, or None.
|
|
1778
|
+
|
|
1779
|
+
The counterpart of :attr:`auth_stamp`, for the same reason: the sidebar
|
|
1780
|
+
lives outside ``#main-content``, so a fragment response can never bring
|
|
1781
|
+
it up to date on its own. Fragment responses carry this value in the
|
|
1782
|
+
``X-Construct-Menu`` header and construct-core.js compares it with the
|
|
1783
|
+
``data-fcu-menu`` the full-page layout stamped on ``<html>``.
|
|
1784
|
+
|
|
1785
|
+
Unlike a stale *auth* shell, a stale menu is not a correctness problem,
|
|
1786
|
+
so the client reloads just the menu fragment rather than the document —
|
|
1787
|
+
a menu entry appearing must not throw anyone out of a half-filled form.
|
|
1788
|
+
|
|
1789
|
+
Cheap path: when every provider declared a ``key``, the stamp is built
|
|
1790
|
+
from those keys plus the auth stamp (which already folds in the groups
|
|
1791
|
+
the gated entries are filtered on) and the menu itself is never built.
|
|
1792
|
+
A registered ``visible`` predicate rules that out — it answers to
|
|
1793
|
+
something no key and no auth stamp describes, so the only honest
|
|
1794
|
+
fingerprint is the menu it produces. (A predicate on a *provider's*
|
|
1795
|
+
entry is the provider's business, and its key is taken to cover it.)
|
|
1796
|
+
"""
|
|
1797
|
+
if not self.menu_is_dynamic:
|
|
1798
|
+
return None
|
|
1799
|
+
keys = [key for _provider, key in self._menu_providers]
|
|
1800
|
+
if not self._menu_predicates and all(key is not None for key in keys):
|
|
1801
|
+
source = [auth_stamp(self._auth) if self._auth is not None else ""]
|
|
1802
|
+
source.extend(repr(key()) for key in keys)
|
|
1803
|
+
else:
|
|
1804
|
+
source = _menu_fingerprint(self.menu)
|
|
1805
|
+
return hashlib.sha256(
|
|
1806
|
+
json.dumps(source, default=str).encode("utf-8")
|
|
1807
|
+
).hexdigest()[:16]
|
|
1808
|
+
|
|
1809
|
+
@property
|
|
1810
|
+
def menu_url(self):
|
|
1811
|
+
"""URL of the menu fragment endpoint, or None when the menu is static."""
|
|
1812
|
+
if not self.menu_is_dynamic:
|
|
1813
|
+
return None
|
|
1814
|
+
return apply_trailing_slash(self.url.rstrip("/") + "/_construct/menu", self)
|
|
1815
|
+
|
|
1816
|
+
@property
|
|
1817
|
+
def menu_poll(self):
|
|
1818
|
+
"""Interval in ms for polling the menu fragment, or None.
|
|
1819
|
+
|
|
1820
|
+
Only needed by an app that wants an idle page to notice menu changes
|
|
1821
|
+
and has no notifications poll already running — every Unpoly response,
|
|
1822
|
+
that poll's included, carries the stamp that triggers a refresh.
|
|
1823
|
+
"""
|
|
1824
|
+
if self._menu_poll is None or not self.menu_is_dynamic:
|
|
1825
|
+
return None
|
|
1826
|
+
return int(self._menu_poll * 1000)
|
|
1517
1827
|
|
|
1518
1828
|
@property
|
|
1519
1829
|
def titleFromLink(self):
|
|
@@ -1524,11 +1834,18 @@ class FlaskConstructUI:
|
|
|
1524
1834
|
a ``self.page`` attribute that has never existed on the extension, so
|
|
1525
1835
|
it raised on every call — harmless while nothing used it, and the cause
|
|
1526
1836
|
of an empty ``<title>`` once ``page_title`` started depending on it.)
|
|
1837
|
+
|
|
1838
|
+
Reads the *registered* menu, not the per-request one. A heading names
|
|
1839
|
+
the page, and pages are registered; deriving it from the dynamic menu
|
|
1840
|
+
instead would put every ``<h1>`` and ``<title>`` at the mercy of
|
|
1841
|
+
whatever a provider reads — a feed that times out would leave the page
|
|
1842
|
+
nameless — and would make every partial render build a menu it is not
|
|
1843
|
+
going to draw.
|
|
1527
1844
|
"""
|
|
1528
1845
|
if not has_request_context():
|
|
1529
1846
|
return ""
|
|
1530
1847
|
here = request.path.rstrip("/")
|
|
1531
|
-
for item in self.
|
|
1848
|
+
for item in self._menu:
|
|
1532
1849
|
if item.get("type") == "submenu":
|
|
1533
1850
|
for subitem in item.get("subitems", []):
|
|
1534
1851
|
link = subitem.get("link")
|
|
@@ -11,7 +11,7 @@ crud-num-notifications = {$count ->
|
|
|
11
11
|
*[other] {$count} Notifications
|
|
12
12
|
}
|
|
13
13
|
crud-see-all-notifications = See all notifications
|
|
14
|
-
crud-sign-out = Sign
|
|
14
|
+
crud-sign-out = Sign out
|
|
15
15
|
crud-error-creating-item = Error creating item.
|
|
16
16
|
crud-validation-errors = Please correct the errors below:
|
|
17
17
|
crud-error-updating-item = Error updating item.
|
|
@@ -61,6 +61,7 @@ nav-main = Main navigation
|
|
|
61
61
|
nav-sidebar = Sidebar
|
|
62
62
|
nav-user-tools = User tools
|
|
63
63
|
nav-search = Search
|
|
64
|
+
nav-search-no-results = No results for "{$query}".
|
|
64
65
|
nav-notifications = Notifications
|
|
65
66
|
nav-tabs = Sections
|
|
66
67
|
nav-pagination = Pagination
|
|
@@ -132,6 +133,7 @@ validation-must-be-date = Must be a valid date
|
|
|
132
133
|
validation-must-be-datetime = Must be a valid date/time
|
|
133
134
|
validation-must-be-exactly = Must be exactly '{$literal}'
|
|
134
135
|
validation-must-match-pattern = Must match pattern /{$pattern}/
|
|
136
|
+
validation-must-match-keyword = Must match keyword '{$keyword}'
|
|
135
137
|
validation-must-match-one = Must satisfy one of the options
|
|
136
138
|
validation-must-be-one-of = Must be one of: {$options}
|
|
137
139
|
validation-string-len-greater = Length must be greater than {$limit} characters
|
|
@@ -140,12 +142,14 @@ validation-string-len-equal = Length must be equal to {$limit} characters
|
|
|
140
142
|
validation-string-len-not-equal = Length must not be equal to {$limit} characters
|
|
141
143
|
validation-string-len-at-least = Length must be at least {$limit} characters
|
|
142
144
|
validation-string-len-at-most = Length must be at most {$limit} characters
|
|
145
|
+
validation-string-len-unknown = Length constraint is not valid ({$limit})
|
|
143
146
|
validation-number-val-greater = Value must be greater than {$limit}
|
|
144
147
|
validation-number-val-less = Value must be less than {$limit}
|
|
145
148
|
validation-number-val-equal = Value must be equal to {$limit}
|
|
146
149
|
validation-number-val-not-equal = Value must not be equal to {$limit}
|
|
147
150
|
validation-number-val-at-least = Value must be at least {$limit}
|
|
148
151
|
validation-number-val-at-most = Value must be at most {$limit}
|
|
152
|
+
validation-number-val-unknown = Value constraint is not valid ({$limit})
|
|
149
153
|
|
|
150
154
|
extensions-title = System Extensions
|
|
151
155
|
extensions-name-col = Extension Name
|
|
@@ -155,3 +159,6 @@ extensions-status-col = Status
|
|
|
155
159
|
extensions-desc-col = Details / Entry Point
|
|
156
160
|
extensions-no-extensions = No extensions registered via entry points.
|
|
157
161
|
|
|
162
|
+
Manage = Manage
|
|
163
|
+
Extensions = Extensions
|
|
164
|
+
|
|
@@ -43,7 +43,7 @@ crud-view-button = Visa
|
|
|
43
43
|
crud-delete-button = Ta bort
|
|
44
44
|
crud-remove-item = Ta bort
|
|
45
45
|
crud-close = Stäng
|
|
46
|
-
crud-apply-filter =
|
|
46
|
+
crud-apply-filter = Tillämpa
|
|
47
47
|
crud-advanced-filter = Avancerat filter
|
|
48
48
|
crud-simple-search = Enkel sökning
|
|
49
49
|
alert-success = Lyckades
|
|
@@ -62,6 +62,7 @@ nav-main = Huvudnavigering
|
|
|
62
62
|
nav-sidebar = Sidopanel
|
|
63
63
|
nav-user-tools = Användarverktyg
|
|
64
64
|
nav-search = Sök
|
|
65
|
+
nav-search-no-results = Inga resultat för "{$query}".
|
|
65
66
|
nav-notifications = Notifieringar
|
|
66
67
|
nav-tabs = Avsnitt
|
|
67
68
|
nav-pagination = Paginering
|
|
@@ -133,6 +134,7 @@ validation-must-be-date = Måste vara ett giltigt datum
|
|
|
133
134
|
validation-must-be-datetime = Måste vara ett giltigt datum/klockslag
|
|
134
135
|
validation-must-be-exactly = Måste vara exakt '{$literal}'
|
|
135
136
|
validation-must-match-pattern = Måste matcha mönstret /{$pattern}/
|
|
137
|
+
validation-must-match-keyword = Måste matcha nyckelordet '{$keyword}'
|
|
136
138
|
validation-must-match-one = Måste uppfylla ett av alternativen
|
|
137
139
|
validation-must-be-one-of = Måste vara ett av: {$options}
|
|
138
140
|
validation-string-len-greater = Längden måste vara större än {$limit} tecken
|
|
@@ -141,12 +143,14 @@ validation-string-len-equal = Längden måste vara exakt {$limit} tecken
|
|
|
141
143
|
validation-string-len-not-equal = Längden får inte vara {$limit} tecken
|
|
142
144
|
validation-string-len-at-least = Längden måste vara minst {$limit} tecken
|
|
143
145
|
validation-string-len-at-most = Längden får vara högst {$limit} tecken
|
|
146
|
+
validation-string-len-unknown = Längdvillkoret är ogiltigt ({$limit})
|
|
144
147
|
validation-number-val-greater = Värdet måste vara större än {$limit}
|
|
145
148
|
validation-number-val-less = Värdet måste vara mindre än {$limit}
|
|
146
149
|
validation-number-val-equal = Värdet måste vara lika med {$limit}
|
|
147
150
|
validation-number-val-not-equal = Värdet får inte vara lika med {$limit}
|
|
148
151
|
validation-number-val-at-least = Värdet måste vara minst {$limit}
|
|
149
152
|
validation-number-val-at-most = Värdet får vara högst {$limit}
|
|
153
|
+
validation-number-val-unknown = Värdevillkoret är ogiltigt ({$limit})
|
|
150
154
|
|
|
151
155
|
extensions-title = Systemtillägg
|
|
152
156
|
extensions-name-col = Tilläggsnamn
|
|
@@ -156,3 +160,6 @@ extensions-status-col = Status
|
|
|
156
160
|
extensions-desc-col = Detaljer / Entry Point
|
|
157
161
|
extensions-no-extensions = Inga tillägg registrerade via entry points.
|
|
158
162
|
|
|
163
|
+
Manage = Hantera
|
|
164
|
+
Extensions = Tillägg
|
|
165
|
+
|