syntaxmatrix 2.6.4.4__py3-none-any.whl → 3.0.0__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 (41) hide show
  1. syntaxmatrix/__init__.py +6 -4
  2. syntaxmatrix/agentic/agents.py +195 -15
  3. syntaxmatrix/agentic/agents_orchestrer.py +16 -10
  4. syntaxmatrix/client_docs.py +237 -0
  5. syntaxmatrix/commentary.py +96 -25
  6. syntaxmatrix/core.py +142 -56
  7. syntaxmatrix/dataset_preprocessing.py +2 -2
  8. syntaxmatrix/db.py +0 -17
  9. syntaxmatrix/kernel_manager.py +174 -150
  10. syntaxmatrix/page_builder_generation.py +654 -50
  11. syntaxmatrix/page_layout_contract.py +25 -3
  12. syntaxmatrix/page_patch_publish.py +368 -15
  13. syntaxmatrix/plugins/__init__.py +0 -0
  14. syntaxmatrix/premium/__init__.py +10 -2
  15. syntaxmatrix/premium/catalogue/__init__.py +121 -0
  16. syntaxmatrix/premium/gate.py +15 -3
  17. syntaxmatrix/premium/state.py +507 -0
  18. syntaxmatrix/premium/verify.py +222 -0
  19. syntaxmatrix/profiles.py +1 -1
  20. syntaxmatrix/routes.py +9782 -8004
  21. syntaxmatrix/settings/model_map.py +50 -65
  22. syntaxmatrix/settings/prompts.py +1435 -380
  23. syntaxmatrix/settings/string_navbar.py +4 -4
  24. syntaxmatrix/static/icons/bot_icon.png +0 -0
  25. syntaxmatrix/static/icons/bot_icon2.png +0 -0
  26. syntaxmatrix/templates/admin_billing.html +408 -0
  27. syntaxmatrix/templates/admin_branding.html +65 -2
  28. syntaxmatrix/templates/admin_features.html +54 -0
  29. syntaxmatrix/templates/dashboard.html +285 -8
  30. syntaxmatrix/templates/edit_page.html +199 -18
  31. syntaxmatrix/themes.py +17 -17
  32. syntaxmatrix/workspace_db.py +0 -23
  33. syntaxmatrix-3.0.0.dist-info/METADATA +219 -0
  34. {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.0.dist-info}/RECORD +38 -33
  35. {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.0.dist-info}/WHEEL +1 -1
  36. syntaxmatrix/settings/default.yaml +0 -13
  37. syntaxmatrix-2.6.4.4.dist-info/METADATA +0 -539
  38. syntaxmatrix-2.6.4.4.dist-info/licenses/LICENSE.txt +0 -21
  39. /syntaxmatrix/{plugin_manager.py → plugins/plugin_manager.py} +0 -0
  40. /syntaxmatrix/static/icons/{logo3.png → logo2.png} +0 -0
  41. {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.0.dist-info}/top_level.txt +0 -0
@@ -150,10 +150,32 @@ def _s(x: Any) -> str:
150
150
  return x.strip() if isinstance(x, str) else ""
151
151
 
152
152
  def _clean_ws(s: str) -> str:
153
- s = s.replace("\x00", "")
153
+ """Single-line clean (titles, ids, etc.)."""
154
+ s = (s or "").strip()
154
155
  s = re.sub(r"\s+", " ", s).strip()
155
156
  return s
156
157
 
158
+
159
+ def _clean_text_block(s: str) -> str:
160
+ """
161
+ Preserve new lines for body text.
162
+ - Normalise line endings
163
+ - Collapse repeated spaces/tabs *within a line*
164
+ - Keep paragraph breaks
165
+ """
166
+ s = (s or "")
167
+ s = s.replace("\r\n", "\n").replace("\r", "\n")
168
+
169
+ lines = []
170
+ for ln in s.split("\n"):
171
+ ln = re.sub(r"[ \t]{2,}", " ", ln).rstrip()
172
+ lines.append(ln)
173
+
174
+ out = "\n".join(lines).strip()
175
+ out = re.sub(r"\n{3,}", "\n\n", out) # cap huge blank gaps
176
+ return out
177
+
178
+
157
179
  def _has_dangerous(s: str) -> bool:
158
180
  return bool(DANGEROUS_RE.search(s or ""))
159
181
 
@@ -284,7 +306,7 @@ def normalise_layout(
284
306
 
285
307
  # Strings
286
308
  s2["title"] = _clean_ws(_s(s2.get("title")))
287
- s2["text"] = _clean_ws(_s(s2.get("text")))
309
+ s2["text"] = _clean_text_block(_s(s2.get("text")))
288
310
 
289
311
  # Items
290
312
  items = s2.get("items")
@@ -298,7 +320,7 @@ def normalise_layout(
298
320
  it2["id"] = _safe_id(_s(it2.get("id"))) or f"item_{sid}_{j+1}"
299
321
  it2["type"] = _safe_id(_s(it2.get("type"))) or "card"
300
322
  it2["title"] = _clean_ws(_s(it2.get("title")))
301
- it2["text"] = _clean_ws(_s(it2.get("text")))
323
+ it2["text"] = _clean_text_block(_s(it2.get("text")))
302
324
  if "imageUrl" in it2:
303
325
  it2["imageUrl"] = _clean_ws(_s(it2.get("imageUrl")))
304
326
  items2.append(it2)
@@ -4,6 +4,76 @@ from typing import Any, Dict, Tuple, List, Optional
4
4
  from bs4 import BeautifulSoup
5
5
 
6
6
 
7
+ _RICH_DROP_TAGS = {"script", "style", "iframe", "object", "embed", "link", "meta", "base"}
8
+
9
+ def _safe_text_align(v: str) -> str:
10
+ v = (v or "").strip().lower()
11
+ if v == "centre":
12
+ v = "center"
13
+ return v if v in ("left", "center", "right", "justify") else ""
14
+
15
+ def _sanitize_rich_fragment(html: str) -> str:
16
+ """
17
+ Best-effort sanitiser for rich text coming from the page editor.
18
+ Removes scripts/events and javascript/data URLs.
19
+ """
20
+ html = (html or "").strip()
21
+ if not html:
22
+ return ""
23
+
24
+ frag = BeautifulSoup(html, "html.parser")
25
+
26
+ # Drop dangerous container tags entirely
27
+ for t in frag.find_all(list(_RICH_DROP_TAGS)):
28
+ t.decompose()
29
+
30
+ # Scrub attributes
31
+ for tag in frag.find_all(True):
32
+ # remove inline on* handlers
33
+ for k in list(tag.attrs.keys()):
34
+ if k.lower().startswith("on"):
35
+ del tag.attrs[k]
36
+
37
+ # block javascript:/data: URLs
38
+ for url_attr in ("href", "src"):
39
+ if url_attr in tag.attrs:
40
+ v = str(tag.attrs.get(url_attr) or "").strip().lower()
41
+ if v.startswith("javascript:") or v.startswith("data:"):
42
+ del tag.attrs[url_attr]
43
+
44
+ # scrub risky style content
45
+ if "style" in tag.attrs:
46
+ st = str(tag.attrs.get("style") or "")
47
+ low = st.lower()
48
+ if "expression(" in low or "javascript:" in low:
49
+ del tag.attrs["style"]
50
+ else:
51
+ # remove url(...) to avoid surprises
52
+ st = re.sub(r"url\s*\([^)]*\)", "", st, flags=re.IGNORECASE)
53
+ tag.attrs["style"] = st
54
+
55
+ return "".join(str(c) for c in frag.contents).strip()
56
+
57
+ def _set_inner_html(tag, html: str):
58
+ tag.clear()
59
+ frag = BeautifulSoup(html or "", "html.parser")
60
+ for child in list(frag.contents):
61
+ tag.append(child)
62
+
63
+ def _set_text_with_breaks(soup, tag, text: str):
64
+ """
65
+ Put plain text into an element but keep user newlines visible.
66
+ """
67
+ tag.clear()
68
+ text = text or ""
69
+ lines = text.splitlines()
70
+ for i, ln in enumerate(lines):
71
+ if i > 0:
72
+ tag.append(soup.new_tag("br"))
73
+ if ln:
74
+ tag.append(ln)
75
+
76
+
7
77
  _SECTION_BY_ID_RE = r'(<section\b[^>]*\bid="{sid}"[^>]*>)(.*?)(</section>)'
8
78
  _SECTION_OPEN_RE = re.compile(r"<section\b[^>]*\bid=['\"]([^'\"]+)['\"][^>]*>", re.IGNORECASE)
9
79
 
@@ -309,10 +379,24 @@ def _default_card_node(soup, it: Dict[str, Any]):
309
379
  row.append(h3)
310
380
  card.append(row)
311
381
 
312
- p = soup.new_tag("p")
313
- p["style"] = "margin-top:8px;"
314
- p.string = it_text
315
- card.append(p)
382
+ it_text_html = (it.get("textHtml") or "").strip()
383
+
384
+ # Body: prefer rich HTML if provided, otherwise plain text
385
+ if it_text_html:
386
+ body = soup.new_tag("div")
387
+ body["class"] = ["smx-rich"]
388
+ body["data-smx"] = "card-body"
389
+ body["style"] = "margin-top:8px;"
390
+ safe = _sanitize_rich_fragment(it_text_html)
391
+ if safe:
392
+ _set_inner_html(body, safe)
393
+ card.append(body)
394
+ else:
395
+ p = soup.new_tag("p")
396
+ p["data-smx"] = "card-text"
397
+ p["style"] = "margin-top:8px;"
398
+ _set_text_with_breaks(soup, p, it_text)
399
+ card.append(p)
316
400
 
317
401
  return card
318
402
 
@@ -363,11 +447,39 @@ def _patch_default_cards(sec, soup, items: List[Dict[str, Any]], cols: int) -> b
363
447
  a.clear(); a.append(label); changed = True
364
448
  if a.get("href") != href:
365
449
  a["href"] = href; changed = True
450
+
451
+ # Open exported HTML results in a new tab (client docs assets)
452
+ href = str(it_href or "").strip()
453
+ auto_newtab = href.startswith("/docs/") and href.lower().endswith(".html")
454
+
455
+ open_new = bool(
456
+ auto_newtab
457
+ or it.get("openInNewTab")
458
+ or it.get("newTab")
459
+ or (str(it.get("target") or "").strip().lower() == "_blank")
460
+ )
461
+
462
+ if open_new:
463
+ if a.get("target") != "_blank":
464
+ a["target"] = "_blank"; changed = True
465
+ # keep rel consistent
466
+ if a.get("rel") != "noopener":
467
+ a["rel"] = "noopener"; changed = True
468
+ else:
469
+ if a.has_attr("target"):
470
+ del a["target"]; changed = True
471
+ if a.has_attr("rel"):
472
+ del a["rel"]; changed = True
473
+
366
474
  continue
367
475
 
368
- # normal card patch (existing behaviour)
476
+ # normal card patch (existing behaviour)
369
477
  it_title = (it.get("title") or "").strip()
370
478
  it_text = (it.get("text") or "").strip()
479
+ it_text_html = (it.get("textHtml") or "").strip()
480
+ title_align = _safe_text_align(it.get("titleAlign") or it.get("title_align") or "")
481
+ body_align = _safe_text_align(it.get("bodyAlign") or it.get("body_align") or "")
482
+
371
483
  it_img = (it.get("imageUrl") or "").strip()
372
484
  it_href = _safe_href(it.get("href") or "")
373
485
  cta_lbl = (it.get("ctaLabel") or "Read more").strip() or "Read more"
@@ -391,15 +503,65 @@ def _patch_default_cards(sec, soup, items: List[Dict[str, Any]], cols: int) -> b
391
503
 
392
504
  # title
393
505
  h = node.find(["h3", "h4"]) or node.find(["h2", "h3", "h4"])
394
- if h and it_title and h.get_text(strip=True) != it_title:
395
- h.clear(); h.append(it_title); changed = True
506
+ if h and it_title:
507
+ cur_title = h.get_text("\n", strip=False).strip()
508
+ if cur_title != it_title:
509
+ _set_text_with_breaks(soup, h, it_title)
510
+ changed = True
511
+ if title_align:
512
+ h["style"] = _merge_inline_style(h.get("style") or "", {"text-align": title_align})
513
+
514
+ # body (prefer rich)
515
+ body = (
516
+ node.find(attrs={"data-smx": "card-body"})
517
+ or node.find("div", class_=lambda c: c and "smx-rich" in str(c))
518
+ or node.find("p")
519
+ )
520
+
521
+ if it_text_html:
522
+ safe = _sanitize_rich_fragment(it_text_html)
523
+
524
+ # ensure we have a div container for rich HTML
525
+ if body is None or body.name != "div":
526
+ new_body = soup.new_tag("div")
527
+ new_body["class"] = ["smx-rich"]
528
+ new_body["data-smx"] = "card-body"
529
+ new_body["style"] = "margin-top:8px;"
530
+ if body is not None:
531
+ body.replace_with(new_body)
532
+ else:
533
+ node.append(new_body)
534
+ body = new_body
535
+ changed = True
536
+
537
+ cur_html = "".join(str(c) for c in body.contents).strip()
538
+ if safe and cur_html != safe:
539
+ _set_inner_html(body, safe)
540
+ changed = True
541
+
542
+ else:
543
+ # plain text path (keep newlines visible)
544
+ if body is None or body.name != "p":
545
+ new_p = soup.new_tag("p")
546
+ new_p["data-smx"] = "card-text"
547
+ new_p["style"] = "margin-top:8px;"
548
+ if body is not None:
549
+ body.replace_with(new_p)
550
+ else:
551
+ node.append(new_p)
552
+ body = new_p
553
+ changed = True
554
+
555
+ cur_text = body.get_text("\n", strip=False).strip()
556
+ if it_text and cur_text != it_text:
557
+ _set_text_with_breaks(soup, body, it_text)
558
+ changed = True
396
559
 
397
- # text
398
- p = node.find("p") or node.select_one(".mut")
399
- if p and it_text and p.get_text(" ", strip=True) != it_text:
400
- p.clear(); p.append(it_text); changed = True
560
+ # body alignment
561
+ if body_align and body is not None:
562
+ body["style"] = _merge_inline_style(body.get("style") or "", {"text-align": body_align})
401
563
 
402
- # card CTA link (Read more) + alignment
564
+ # card CTA link (Read more) + alignment
403
565
  align = (it.get("ctaAlign") or it.get("cta_align") or "").strip().lower() or "left"
404
566
  if align == "centre":
405
567
  align = "center"
@@ -729,6 +891,104 @@ def _patch_testimonials(sec, soup, items: List[Dict[str, Any]], cols: int) -> bo
729
891
  grid["style"] = f"grid-template-columns:repeat({max(1, min(cols, 3))}, minmax(0,1fr));"
730
892
  return changed
731
893
 
894
+ def _patch_gallery_items(sec, soup, items: List[Dict[str, Any]]) -> bool:
895
+ """
896
+ Patch a gallery section so image items render as true gallery tiles (figure.gimg)
897
+ compatible with compile_layout_to_html/gallery_js.
898
+ """
899
+ grid = sec.select_one(".grid")
900
+ if grid is None:
901
+ wrap = sec.select_one(".wrap") or sec
902
+ grid = soup.new_tag("div")
903
+ grid["class"] = ["grid"]
904
+ wrap.append(grid)
905
+
906
+ changed = False
907
+
908
+ # Only manage direct children of grid
909
+ children = [c for c in grid.find_all(True, recursive=False)]
910
+
911
+ def make_node(it: Dict[str, Any]):
912
+ it = it if isinstance(it, dict) else {}
913
+ it_type = (it.get("type") or "card").strip().lower()
914
+
915
+ title_plain = (it.get("title") or "").strip()
916
+ text_plain = (it.get("text") or "").strip()
917
+
918
+ img_full = (it.get("imageUrl") or "").strip()
919
+ img_thumb = (it.get("thumbUrl") or it.get("thumb_url") or "").strip()
920
+ src = img_thumb or img_full
921
+
922
+ # TRUE gallery image tile
923
+ if it_type == "image" and src:
924
+ fig = soup.new_tag("figure")
925
+ fig["class"] = ["gimg", "reveal"]
926
+ fig["data-item-type"] = "image"
927
+ fig["data-full"] = img_full or src
928
+ fig["data-title"] = title_plain
929
+ fig["data-caption"] = text_plain
930
+
931
+ im = soup.new_tag("img")
932
+ im["loading"] = "lazy"
933
+ im["decoding"] = "async"
934
+ im["src"] = src
935
+ im["data-full"] = img_full or src
936
+ im["alt"] = title_plain or "image"
937
+ fig.append(im)
938
+
939
+ if title_plain:
940
+ cap = soup.new_tag("figcaption")
941
+ cap.string = title_plain
942
+ fig.append(cap)
943
+
944
+ return fig
945
+
946
+ # Otherwise render as a normal card (content card inside rail)
947
+ node = _default_card_node(soup, it)
948
+ node["data-item-type"] = it_type or "card"
949
+ return node
950
+
951
+ n = len(items)
952
+
953
+ # Sync child count
954
+ if len(children) < n:
955
+ for i in range(len(children), n):
956
+ grid.append(make_node(items[i]))
957
+ changed = True
958
+ children = [c for c in grid.find_all(True, recursive=False)]
959
+
960
+ if len(children) > n:
961
+ for extra in children[n:]:
962
+ extra.decompose()
963
+ changed = True
964
+ children = [c for c in grid.find_all(True, recursive=False)]
965
+
966
+ # Patch / replace nodes if type mismatches
967
+ for i in range(n):
968
+ it = items[i] if isinstance(items[i], dict) else {}
969
+ want_type = (it.get("type") or "card").strip().lower()
970
+
971
+ node = children[i]
972
+ is_fig = (node.name == "figure" and "gimg" in (node.get("class") or []))
973
+ want_fig = (want_type == "image" and bool((it.get("thumbUrl") or it.get("thumb_url") or it.get("imageUrl") or "").strip()))
974
+
975
+ if want_fig and not is_fig:
976
+ node.replace_with(make_node(it))
977
+ changed = True
978
+ continue
979
+
980
+ if (not want_fig) and is_fig:
981
+ node.replace_with(make_node(it))
982
+ changed = True
983
+ continue
984
+
985
+ # For simplicity and correctness, refresh the node content by replacement
986
+ # (keeps behaviour consistent with compile_layout_to_html)
987
+ node.replace_with(make_node(it))
988
+ changed = True
989
+
990
+ return changed
991
+
732
992
 
733
993
  def _patch_items_in_section_by_id(
734
994
  html: str,
@@ -758,11 +1018,14 @@ def _patch_items_in_section_by_id(
758
1018
  changed = _patch_testimonials(sec, soup, items, cols=cols)
759
1019
  return str(soup), changed
760
1020
 
1021
+ if st == "gallery":
1022
+ changed = _patch_gallery_items(sec, soup, items)
1023
+ return str(soup), changed
1024
+
761
1025
  # default cards grid: features/gallery/cta/richtext/anything else that uses cards
762
1026
  changed = _patch_default_cards(sec, soup, items, cols=cols)
763
1027
  return str(soup), changed
764
1028
 
765
-
766
1029
  def _patch_hero(html: str, hero_section: Dict[str, Any]) -> Tuple[str, bool]:
767
1030
  """
768
1031
  Patch hero bg image + <h1> + lead paragraph inside the hero section.
@@ -1350,6 +1613,84 @@ def _patch_section_styles(existing_html: str, layout: dict) -> tuple[str, bool]:
1350
1613
  out = str(soup)
1351
1614
  return out, changed
1352
1615
 
1616
+ def _ensure_gallery_assets(layout: dict) -> tuple[dict, bool]:
1617
+ """
1618
+ Normalise gallery sections so that image additions behave like true gallery tiles.
1619
+
1620
+ Rule:
1621
+ - In a section with type == "gallery", any item that has an image (imageUrl/thumbUrl/imgQuery)
1622
+ but is typed as "card" (or missing type) is converted to type="image" by default.
1623
+ - We preserve title/text, but keep them short (gallery tiles should be image-first).
1624
+ """
1625
+ changed = False
1626
+ if not isinstance(layout, dict):
1627
+ return layout, changed
1628
+
1629
+ secs = layout.get("sections")
1630
+ if not isinstance(secs, list):
1631
+ return layout, changed
1632
+
1633
+ def _has_img(it: dict) -> bool:
1634
+ return bool(
1635
+ (it.get("imageUrl") or "").strip()
1636
+ or (it.get("thumbUrl") or it.get("thumb_url") or "").strip()
1637
+ or (it.get("imgQuery") or "").strip()
1638
+ or bool(it.get("needsImage"))
1639
+ )
1640
+
1641
+ def _meaningful_text(s: str) -> bool:
1642
+ s = (s or "").strip()
1643
+ return len(s) >= 20 # heuristic: longer text suggests a content card
1644
+
1645
+ for sec in secs:
1646
+ if not isinstance(sec, dict):
1647
+ continue
1648
+ if str(sec.get("type") or "").strip().lower() != "gallery":
1649
+ continue
1650
+
1651
+ items = sec.get("items")
1652
+ if not isinstance(items, list):
1653
+ continue
1654
+
1655
+ for it in items:
1656
+ if not isinstance(it, dict):
1657
+ continue
1658
+
1659
+ it_type = str(it.get("type") or "").strip().lower()
1660
+ if it_type == "image":
1661
+ continue
1662
+
1663
+ if not _has_img(it):
1664
+ continue
1665
+
1666
+ # If it looks like a content card (long text), leave it as card.
1667
+ # Otherwise treat it as a gallery image tile.
1668
+ title = (it.get("title") or "").strip()
1669
+ text = (it.get("text") or "").strip()
1670
+
1671
+ if _meaningful_text(text):
1672
+ # keep as card: someone likely wants a case-study style card inside the gallery rail
1673
+ if not it_type:
1674
+ it["type"] = "card"
1675
+ changed = True
1676
+ continue
1677
+
1678
+ it["type"] = "image"
1679
+ changed = True
1680
+
1681
+ # Optional: keep gallery captions concise (avoid feature-card paragraphs)
1682
+ if len(text) > 110:
1683
+ it["text"] = text[:107].rstrip() + "…"
1684
+
1685
+ if len(title) > 60:
1686
+ it["title"] = title[:57].rstrip() + "…"
1687
+
1688
+ # Ensure fields exist (helps downstream)
1689
+ it.setdefault("thumbUrl", it.get("thumb_url", "") or "")
1690
+ it.setdefault("imageUrl", it.get("imageUrl", "") or "")
1691
+
1692
+ return layout, changed
1693
+
1353
1694
 
1354
1695
  def patch_page_publish(existing_html: str, layout: Dict[str, Any], page_slug: Optional[str] = None) -> Tuple[str, Dict[str, int]]:
1355
1696
  """
@@ -1360,6 +1701,11 @@ def patch_page_publish(existing_html: str, layout: Dict[str, Any], page_slug: Op
1360
1701
  stats = {"hero": 0, "sections": 0, "skipped": 0}
1361
1702
 
1362
1703
  if not existing_html or not isinstance(layout, dict):
1704
+ # Inject Gallery rail + lightbox behaviour ONLY if this page has a gallery section
1705
+ existing_html, gallery_injected = _ensure_gallery_assets(existing_html)
1706
+ if gallery_injected:
1707
+ stats["gallery_assets_injected"] = 1
1708
+
1363
1709
  return existing_html, stats
1364
1710
 
1365
1711
  sections = layout.get("sections") if isinstance(layout.get("sections"), list) else []
@@ -1470,8 +1816,15 @@ def _remove_deleted_sections(existing_html: str, layout: Dict[str, Any], *, page
1470
1816
  if not sid:
1471
1817
  continue
1472
1818
 
1473
- # Only touch builder sections.
1474
- is_builder_section = sid.startswith("sec_") or sec.has_attr("data-section-type")
1819
+ cls = [c for c in (sec.get("class") or []) if isinstance(c, str)]
1820
+
1821
+ # Treat generator sections as builder-owned even if the ID is custom.
1822
+ is_builder_section = (
1823
+ sid.startswith("sec_")
1824
+ or sec.has_attr("data-section-type")
1825
+ or ("sec" in cls)
1826
+ or ("hero" in cls)
1827
+ )
1475
1828
  if not is_builder_section:
1476
1829
  continue
1477
1830
 
File without changes
@@ -1,10 +1,18 @@
1
1
  """Premium support.
2
2
 
3
3
  This package contains runtime plumbing for premium features (entitlements +
4
- plugin loading). The actual premium implementations should live in separate,
4
+ plugin loading).
5
+
6
+ Key idea:
7
+ - FeatureGate reads a single entitlement dict from env/db/licence.json
8
+ - ensure_premium_state() resolves trial + plan entitlements and writes them
9
+ into app_settings so the rest of the app can safely gate routes + navbar.
10
+
11
+ Premium implementations (e.g. non-SQLite DB backends) should live in separate,
5
12
  private distributions.
6
13
  """
7
14
 
8
15
  from .gate import FeatureGate
16
+ from .state import ensure_premium_state, PremiumState
9
17
 
10
- __all__ = ["FeatureGate"]
18
+ __all__ = ["FeatureGate", "ensure_premium_state", "PremiumState"]
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any, Dict, Optional, Tuple
6
+
7
+
8
+ def _safe_json_loads(raw: str, default: Any) -> Any:
9
+ try:
10
+ return json.loads(raw)
11
+ except Exception:
12
+ return default
13
+
14
+
15
+ def _read_json(path: str) -> Dict[str, Any]:
16
+ try:
17
+ with open(path, "r", encoding="utf-8") as f:
18
+ data = json.load(f)
19
+ return data if isinstance(data, dict) else {}
20
+ except Exception:
21
+ return {}
22
+
23
+
24
+ def _catalogue_dir() -> str:
25
+ return os.path.join(os.path.dirname(__file__))
26
+
27
+
28
+ def load_index() -> Dict[str, Any]:
29
+ """Load catalogue index.json (safe, returns empty dict on failure)."""
30
+ return _read_json(os.path.join(_catalogue_dir(), "index.json"))
31
+
32
+
33
+ def load_catalogue(version: Optional[str] = None) -> Tuple[Dict[str, Any], str]:
34
+ """Load a catalogue for the requested version.
35
+
36
+ Returns: (catalogue_dict, resolved_version)
37
+ """
38
+ idx = load_index()
39
+ resolved = (version or "").strip() or str(idx.get("default") or "").strip()
40
+ versions = idx.get("versions") if isinstance(idx.get("versions"), dict) else {}
41
+ fname = versions.get(resolved) if isinstance(versions, dict) else None
42
+
43
+ if not fname:
44
+ # Fall back to default file name if index is missing or version unknown.
45
+ fname = "catalogue.v1.json"
46
+
47
+ cat = _read_json(os.path.join(_catalogue_dir(), str(fname)))
48
+ # If the loaded file advertises a version, prefer that.
49
+ advertised = str(cat.get("version") or "").strip()
50
+ if advertised:
51
+ resolved = advertised
52
+ return cat, resolved
53
+
54
+
55
+ def ui_labels(version: Optional[str] = None) -> Dict[str, Any]:
56
+ cat, _ = load_catalogue(version)
57
+ ui = cat.get("ui") if isinstance(cat.get("ui"), dict) else {}
58
+ labels = ui.get("labels") if isinstance(ui.get("labels"), dict) else {}
59
+ return labels if isinstance(labels, dict) else {}
60
+
61
+
62
+ def resolve_entitlements(
63
+ *,
64
+ plan_id: str,
65
+ version: Optional[str] = None,
66
+ addons: Optional[list] = None,
67
+ custom_overrides: Optional[Dict[str, Any]] = None,
68
+ ) -> Dict[str, Any]:
69
+ """Resolve final entitlements from catalogue.
70
+
71
+ This keeps entitlements data-driven:
72
+ - base entitlements come from the plan in the catalogue
73
+ - optional add-ons apply deltas/overrides
74
+ - optional custom_overrides apply last
75
+ """
76
+ pid = (plan_id or "free").strip().lower() or "free"
77
+ cat, resolved_version = load_catalogue(version)
78
+
79
+ plans = cat.get("plans") if isinstance(cat.get("plans"), dict) else {}
80
+ p = plans.get(pid) if isinstance(plans, dict) else None
81
+ base = {}
82
+ if isinstance(p, dict) and isinstance(p.get("entitlements"), dict):
83
+ base = dict(p.get("entitlements") or {})
84
+
85
+ # Always stamp plan + version for UI/debugging.
86
+ base["plan"] = pid
87
+ base["entitlement_version"] = resolved_version
88
+
89
+ # Apply add-ons.
90
+ addons_list = addons if isinstance(addons, list) else []
91
+ if addons_list:
92
+ addons_def = cat.get("addons") if isinstance(cat.get("addons"), dict) else {}
93
+ for addon_id in addons_list:
94
+ aid = str(addon_id or "").strip()
95
+ if not aid:
96
+ continue
97
+ a = addons_def.get(aid) if isinstance(addons_def, dict) else None
98
+ if not isinstance(a, dict):
99
+ continue
100
+ delta = a.get("entitlements_delta") if isinstance(a.get("entitlements_delta"), dict) else {}
101
+ for k, v in (delta or {}).items():
102
+ # Support "+N" increment for numeric limits.
103
+ if isinstance(v, str) and v.strip().startswith("+"):
104
+ try:
105
+ inc = int(v.strip()[1:])
106
+ cur = int(base.get(k) or 0)
107
+ base[k] = cur + inc
108
+ continue
109
+ except Exception:
110
+ pass
111
+ base[k] = v
112
+ base["addons"] = addons_list
113
+
114
+ # Apply overrides last.
115
+ if isinstance(custom_overrides, dict) and custom_overrides:
116
+ for k, v in custom_overrides.items():
117
+ if k in ("sig",):
118
+ continue
119
+ base[k] = v
120
+
121
+ return base