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.
Files changed (96) hide show
  1. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/PKG-INFO +1 -1
  2. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/auth.py +2 -2
  3. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/base.py +378 -61
  4. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/l10n/en/main.flt +8 -1
  5. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/l10n/sv/main.flt +8 -1
  6. flask_construct_ui-0.11.4/flask_construct_ui/static/js/construct-core.min.js +1 -0
  7. flask_construct_ui-0.11.4/flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
  8. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/related_crud_tab_list.html +3 -3
  9. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/navbar/search_results.html +1 -1
  10. flask_construct_ui-0.11.4/flask_construct_ui/templates/style/_menu.html +67 -0
  11. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/style/base_full.html +10 -58
  12. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/validation.py +19 -4
  13. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/PKG-INFO +1 -1
  14. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/SOURCES.txt +1 -0
  15. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/pyproject.toml +1 -1
  16. flask_construct_ui-0.11.2/flask_construct_ui/static/js/construct-core.min.js +0 -1
  17. flask_construct_ui-0.11.2/flask_construct_ui/static/js/prosemirror-bundle.min.js +0 -34
  18. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/LICENSE +0 -0
  19. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/README.md +0 -0
  20. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/__init__.py +0 -0
  21. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/cli.py +0 -0
  22. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/components.py +0 -0
  23. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/contrast.py +0 -0
  24. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/crud.py +0 -0
  25. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/csrf.py +0 -0
  26. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/data.py +0 -0
  27. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/data_sqlalchemy.py +0 -0
  28. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/forms.py +0 -0
  29. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/grid.py +0 -0
  30. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/headless.py +0 -0
  31. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/icons.py +0 -0
  32. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/language.py +0 -0
  33. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/navbar.py +0 -0
  34. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/navigation.py +0 -0
  35. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/orgmode.py +0 -0
  36. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/pagination.py +0 -0
  37. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/py.typed +0 -0
  38. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/resume.py +0 -0
  39. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/sanitize.py +0 -0
  40. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/schema.py +0 -0
  41. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/css/grid.css +0 -0
  42. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
  43. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/css/unpoly.min.css +0 -0
  44. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/css/whitelabel.css +0 -0
  45. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/designs/adminlte.css +0 -0
  46. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/designs/sandstone.css +0 -0
  47. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
  48. {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
  49. {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
  50. {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
  51. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
  52. {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
  53. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
  54. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/js/construct-grid.min.js +0 -0
  55. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
  56. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/static/js/unpoly.min.js +0 -0
  57. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/_form_field.html +0 -0
  58. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/button.html +0 -0
  59. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/calendar.html +0 -0
  60. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/calendarmini.html +0 -0
  61. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/card.html +0 -0
  62. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/chart.html +0 -0
  63. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/form.html +0 -0
  64. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/grid.html +0 -0
  65. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/grid_layout.html +0 -0
  66. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/grid_notice.html +0 -0
  67. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
  68. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/kanban.html +0 -0
  69. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/live.html +0 -0
  70. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/main.html +0 -0
  71. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/markup.html +0 -0
  72. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/rich_text.html +0 -0
  73. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/table.html +0 -0
  74. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/tabs.html +0 -0
  75. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/text.html +0 -0
  76. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
  77. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/_macros.html +0 -0
  78. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/_many_related_options.html +0 -0
  79. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/_related_options.html +0 -0
  80. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/create.html +0 -0
  81. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/edit.html +0 -0
  82. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/listindex.html +0 -0
  83. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/related_crud_tab_form.html +0 -0
  84. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/tab_view.html +0 -0
  85. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/crud/view.html +0 -0
  86. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/errors/forbidden.html +0 -0
  87. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/navbar/notifications.html +0 -0
  88. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/structure/base.html +0 -0
  89. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/structure/public.html +0 -0
  90. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/style/_flashes.html +0 -0
  91. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/templates/style/base_partial.html +0 -0
  92. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui/testing.py +0 -0
  93. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
  94. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/requires.txt +0 -0
  95. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/flask_construct_ui.egg-info/top_level.txt +0 -0
  96. {flask_construct_ui-0.11.2 → flask_construct_ui-0.11.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-construct-ui
3
- Version: 0.11.2
3
+ Version: 0.11.4
4
4
  Summary: A UI toolkit for Flask with CRUD and component features.
5
5
  Author-email: Olof Tjerngren <olof@tjerngren.net>
6
6
  License-Expression: LGPL-3.0-or-later
@@ -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
- if self._search is not None or self._notifications is not None:
1161
- prefix = self.url.rstrip("/") + "/_construct"
1162
- if self.enforce_trailing_slash and not prefix.endswith("/"):
1163
- prefix += "/"
1164
-
1165
- features_bp = Blueprint("construct_features", __name__, url_prefix=prefix)
1166
- _self = self
1167
-
1168
- search_url = "/search"
1169
- notifications_url = "/notifications"
1170
- if self.enforce_trailing_slash:
1171
- search_url += "/"
1172
- notifications_url += "/"
1173
-
1174
- route_kwargs = {}
1175
- if self.strict_slashes is not None:
1176
- route_kwargs["strict_slashes"] = self.strict_slashes
1177
-
1178
- if self._search is not None:
1179
- _search = self._search
1180
-
1181
- @features_bp.route(search_url, **route_kwargs)
1182
- def search_results():
1183
- q = request.args.get("q", "")
1184
- results = _search.search(q)
1185
- return render_template(
1186
- "navbar/search_results.html",
1187
- construct=_self,
1188
- results=results,
1189
- query=q,
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
- if self._notifications is not None:
1278
+ if self._notifications is not None:
1193
1279
 
1194
- @features_bp.route(notifications_url, **route_kwargs)
1195
- def notifications_fragment():
1196
- # Just the #fcu-notifications fragment; Unpoly's up-poll
1197
- # reloads it on a timer to keep the count + items fresh.
1198
- return render_template(
1199
- "navbar/notifications.html",
1200
- construct=_self,
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
- self.app.register_blueprint(features_bp)
1204
- self._blueprint_names.add("construct_features")
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(self, page, submenu=None, submenu_icon=None):
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 _add_to_submenu(self, title, item, icon="folder"):
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
- for entry in self._menu:
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
- self._menu.append(
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
- return self._menu
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.menu:
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-out
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 = Använd
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
+