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:
17
browser/app/filters/__init__.py
Normal file
17
browser/app/filters/__init__.py
Normal 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)
|
||||
25
browser/app/filters/combine.py
Normal file
25
browser/app/filters/combine.py
Normal 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}
|
||||
12
browser/app/filters/currency.py
Normal file
12
browser/app/filters/currency.py
Normal 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}"
|
||||
6
browser/app/filters/getattr.py
Normal file
6
browser/app/filters/getattr.py
Normal 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)
|
||||
21
browser/app/filters/highlight.py
Normal file
21
browser/app/filters/highlight.py
Normal 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
13
browser/app/filters/qs.py
Normal 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 ""
|
||||
78
browser/app/filters/qs_base.py
Normal file
78
browser/app/filters/qs_base.py
Normal 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
|
||||
33
browser/app/filters/query_types.py
Normal file
33
browser/app/filters/query_types.py
Normal 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
|
||||
22
browser/app/filters/truncate.py
Normal file
22
browser/app/filters/truncate.py
Normal 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] + "…"
|
||||
19
browser/app/filters/url_join.py
Normal file
19
browser/app/filters/url_join.py
Normal 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])
|
||||
Reference in New Issue
Block a user