feat: extract shared infrastructure from shared_lib

Phase 1-3 of decoupling plan:
- Shared DB, models, infrastructure, browser, config, utils
- Event infrastructure (domain_events outbox, bus, processor)
- Structured logging
- Generic container concept (container_type/container_id)
- Alembic migrations for all schema changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 12:45:56 +00:00
commit ef806f8fbb
533 changed files with 276497 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
def register(app):
from .highlight import highlight
app.jinja_env.filters["highlight"] = highlight
from .qs import register as qs
from .url_join import register as url_join
from .combine import register as combine
from .currency import register as currency
from .truncate import register as truncate
from .getattr import register as getattr
qs(app)
url_join(app)
combine(app)
currency(app)
getattr(app)
# truncate(app)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Mapping
def _deep_merge(dst: dict, src: Mapping) -> dict:
out = dict(dst)
for k, v in src.items():
if isinstance(v, Mapping) and isinstance(out.get(k), Mapping):
out[k] = _deep_merge(out[k], v) # type: ignore[arg-type]
else:
out[k] = v
return out
def register(app):
@app.template_filter("combine")
def combine_filter(a: Any, b: Any, deep: bool = False, drop_none: bool = False) -> Any:
"""
Jinja filter: merge two dict-like objects.
- Non-dict inputs: returns `a` unchanged.
- If drop_none=True, keys in `b` with value None are ignored.
- If deep=True, nested dicts are merged recursively.
"""
if not isinstance(a, Mapping) or not isinstance(b, Mapping):
return a
b2 = {k: v for k, v in b.items() if not (drop_none and v is None)}
return _deep_merge(a, b2) if deep else {**a, **b2}

View File

@@ -0,0 +1,12 @@
from decimal import Decimal
def register(app):
@app.template_filter("currency")
def currency_filter(value, code="GBP"):
if value is None:
return ""
# ensure decimal-ish
if isinstance(value, float):
value = Decimal(str(value))
symbol = "£" if code == "GBP" else code
return f"{symbol}{value:.2f}"

View File

@@ -0,0 +1,6 @@
def register(app):
@app.template_filter("getattr")
def jinja_getattr(obj, name, default=None):
# Safe getattr: returns default if the attribute is missing
return getattr(obj, name, default)

View File

@@ -0,0 +1,21 @@
# ---------- misc helpers / filters ----------
from markupsafe import Markup, escape
def highlight(text: str, needle: str, cls: str = "bg-yellow-200 rounded") -> Markup:
"""
Wraps case-insensitive matches of `needle` inside <mark class="...">.
Escapes everything safely.
"""
import re
if not text or not needle:
return Markup(escape(text or ""))
pattern = re.compile(re.escape(needle), re.IGNORECASE)
def repl(m: re.Match) -> str:
return f'<mark class="{escape(cls)}">{escape(m.group(0))}</mark>'
esc = escape(text)
result = pattern.sub(lambda m: Markup(repl(m)), esc)
return Markup(result)

13
browser/app/filters/qs.py Normal file
View File

@@ -0,0 +1,13 @@
from typing import Dict
from quart import g
def register(app):
@app.template_filter("qs")
def qs_filter(dict: Dict):
if getattr(g, "makeqs_factory", False):
q= g.makeqs_factory()(
**dict,
)
return q
else:
return ""

View File

@@ -0,0 +1,78 @@
"""
Shared query-string primitives used by blog, market, and order qs modules.
"""
from __future__ import annotations
from urllib.parse import urlencode
# Sentinel meaning "leave value as-is" (used as default arg in makeqs)
KEEP = object()
def _iterify(x):
"""Normalize *x* to a list: None → [], scalar → [scalar], iterable → as-is."""
if x is None:
return []
if isinstance(x, (list, tuple, set)):
return x
return [x]
def _norm(s: str) -> str:
"""Strip + lowercase — used for case-insensitive filter dedup."""
return s.strip().lower()
def make_filter_set(
base: list[str],
add,
remove,
clear_filters: bool,
*,
single_select: bool = False,
) -> list[str]:
"""
Build a deduplicated, sorted filter list.
Parameters
----------
base : list[str]
Current filter values.
add : str | list | None
Value(s) to add.
remove : str | list | None
Value(s) to remove.
clear_filters : bool
If True, start from empty instead of *base*.
single_select : bool
If True, *add* **replaces** the list (blog tags/authors).
If False, *add* is **appended** (market brands/stickers/labels).
"""
add_list = [s for s in _iterify(add) if s is not None]
if single_select:
# Blog-style: adding replaces the entire set
if add_list:
table = {_norm(s): s for s in add_list}
else:
table = {_norm(s): s for s in base if not clear_filters}
else:
# Market-style: adding appends to the existing set
table = {_norm(s): s for s in base if not clear_filters}
for s in add_list:
k = _norm(s)
if k not in table:
table[k] = s
for s in _iterify(remove):
if s is None:
continue
table.pop(_norm(s), None)
return [table[k] for k in sorted(table)]
def build_qs(params: list[tuple[str, str]], *, leading_q: bool = True) -> str:
"""URL-encode *params* and optionally prepend ``?``."""
qs = urlencode(params, doseq=True)
return ("?" + qs) if (qs and leading_q) else qs

View File

@@ -0,0 +1,33 @@
"""
NamedTuple types returned by each blueprint's ``decode()`` function.
"""
from __future__ import annotations
from typing import NamedTuple
class BlogQuery(NamedTuple):
page: int
search: str | None
sort: str | None
selected_tags: tuple[str, ...]
selected_authors: tuple[str, ...]
liked: str | None
view: str | None
drafts: str | None
selected_groups: tuple[str, ...]
class MarketQuery(NamedTuple):
page: int
search: str | None
sort: str | None
selected_brands: tuple[str, ...]
selected_stickers: tuple[str, ...]
selected_labels: tuple[str, ...]
liked: str | None
class OrderQuery(NamedTuple):
page: int
search: str | None

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
def register(app):
@app.template_filter("truncate")
def truncate(text, max_length=100):
"""
Truncate text to max_length characters and add an ellipsis character (…)
if it was longer.
"""
if text is None:
return ""
text = str(text)
if len(text) <= max_length:
return text
# Leave space for the ellipsis itself
if max_length <= 1:
return ""
return text[:max_length - 1] + ""

View File

@@ -0,0 +1,19 @@
from typing import Iterable, Union
from shared.utils import join_url, host_url, _join_url_parts, route_prefix
# --- Register as a Jinja filter (Quart / Flask) ---
def register(app):
@app.template_filter("urljoin")
def urljoin_filter(value: Union[str, Iterable[str]]):
return join_url(value)
@app.template_filter("urlhost")
def urlhost_filter(value: Union[str, Iterable[str]]):
return host_url(value)
@app.template_filter("urlhost_no_slash")
def urlhost_no_slash_filter(value: Union[str, Iterable[str]]):
return host_url(value, True)
@app.template_filter("host")
def host_filter(value: str):
return _join_url_parts([route_prefix(), value])