feat(nostr): ancestor 't' tags on menu listings
When a menu item's NIP-99 kind-30402 listing is published, the
extension now emits one 't' tag per ancestor node name (root-first,
slugified to lowercase ASCII). This lets Nostr clients filter the
global listing stream by category — e.g.
{"#t": ["hot-beverages"]}
{"#t": ["coffee-based"]}
without having to know the publisher's pubkey or pull markdown
content. The 'menu' anchor stays first so subscribers can still
get the universal stream. Allergen / ingredient prefixes
(allergen:<x>, ingr:<x>) and dietary tags are unchanged.
nostr_publisher.py:
- Add _slugify(name) -> str (lowercase, [^a-z0-9]+ -> '-', strip).
- build_menu_item_event takes ancestor_names: tuple[str, ...] kw
and emits dedup'd slugs. Stays DB-free; the caller does the
walk.
views_api.py:
- _ancestor_names_for_node walks the materialized path of an
item's node to (root.name, ..., leaf.name).
- _publish_menu_item passes them to the builder.
- api_update_menu_node detects a name change and calls
_republish_subtree_items(node_id), which re-publishes every
menu_item in the subtree so the new ancestor slug lands on
each listing. <=50 items per restaurant in practice; eager
re-publish keeps the relay state consistent without a
background sync.
This commit is contained in:
parent
4827f5e10f
commit
cedd548963
2 changed files with 109 additions and 6 deletions
|
|
@ -23,6 +23,7 @@ who don't care about identity separation.
|
|||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -33,6 +34,28 @@ from .models import MenuItem, Restaurant
|
|||
from .nostr.event import NostrEvent
|
||||
|
||||
|
||||
_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""
|
||||
Convert a node name to a lowercase ASCII slug suitable for the
|
||||
Nostr `t` (hashtag) tag. Relays and clients filter on `#t` values
|
||||
by exact match, so 'Hot Beverages' must become 'hot-beverages'
|
||||
for the filter `{"#t": ["hot-beverages"]}` to find it.
|
||||
|
||||
Best-effort:
|
||||
* Lowercase
|
||||
* Replace any run of non-alphanumeric characters with '-'
|
||||
* Strip leading/trailing dashes
|
||||
* Empty / whitespace-only input returns ''
|
||||
"""
|
||||
s = (name or "").strip().lower()
|
||||
s = _SLUG_NON_ALNUM.sub("-", s)
|
||||
s = s.strip("-")
|
||||
return s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Builders #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
|
@ -75,7 +98,11 @@ def build_restaurant_metadata_event(restaurant: Restaurant, pubkey: str) -> Nost
|
|||
|
||||
|
||||
def build_menu_item_event(
|
||||
item: MenuItem, restaurant: Restaurant, pubkey: str
|
||||
item: MenuItem,
|
||||
restaurant: Restaurant,
|
||||
pubkey: str,
|
||||
*,
|
||||
ancestor_names: tuple[str, ...] = (),
|
||||
) -> NostrEvent:
|
||||
"""
|
||||
Build a NIP-99 classified listing (kind 30402) for a menu item.
|
||||
|
|
@ -87,13 +114,21 @@ def build_menu_item_event(
|
|||
summary item.description (truncated, optional)
|
||||
price [price, "<amount>", "<currency>"]
|
||||
image each entry in item.images
|
||||
t "menu", "<category-name>", each dietary tag, each allergen
|
||||
(prefixed `allergen:`), each ingredient (prefixed `ingr:`)
|
||||
t "menu", each ancestor node name (slugified, root-first),
|
||||
each dietary tag, each allergen (prefixed `allergen:`),
|
||||
each ingredient (prefixed `ingr:`)
|
||||
l "restaurant:<restaurant.id>" (link back to the operator)
|
||||
location restaurant.location (if set)
|
||||
g restaurant.geohash (if set)
|
||||
status "active" | "sold" (NIP-99 standard) — sold-out state
|
||||
|
||||
`ancestor_names` is the chain of node names from the root down to
|
||||
(and including) the item's own node, e.g.
|
||||
("Drinks", "Hot Beverages", "Coffee-based")
|
||||
Each is slugified to lowercase ASCII so `#t=hot-beverages` filters
|
||||
work cleanly. Caller (views_api._publish_menu_item) walks the
|
||||
materialized path; this builder stays DB-free.
|
||||
|
||||
Content is markdown — currently `item.description`; can be expanded
|
||||
later to include rich allergen/ingredient blocks.
|
||||
"""
|
||||
|
|
@ -109,6 +144,13 @@ def build_menu_item_event(
|
|||
tags.append(["summary", item.description[:140]])
|
||||
for img in item.images or []:
|
||||
tags.append(["image", img])
|
||||
# Ancestor categories — slugified, deduped, root-first.
|
||||
seen_slugs: set[str] = set()
|
||||
for ancestor in ancestor_names:
|
||||
slug = _slugify(ancestor)
|
||||
if slug and slug not in seen_slugs:
|
||||
seen_slugs.add(slug)
|
||||
tags.append(["t", slug])
|
||||
for diet in item.dietary or []:
|
||||
tags.append(["t", diet])
|
||||
for allergen in item.allergens or []:
|
||||
|
|
|
|||
67
views_api.py
67
views_api.py
|
|
@ -157,6 +157,29 @@ async def _publish_restaurant(restaurant: Restaurant) -> None:
|
|||
await update_restaurant(restaurant)
|
||||
|
||||
|
||||
async def _ancestor_names_for_node(node_id: Optional[str]) -> tuple[str, ...]:
|
||||
"""
|
||||
Walk the materialized `path` of a node, returning the chain of
|
||||
node names root-first (including the leaf node itself).
|
||||
Returns () if node_id is None or path can't be resolved.
|
||||
"""
|
||||
if not node_id:
|
||||
return ()
|
||||
leaf = await get_menu_node(node_id)
|
||||
if not leaf:
|
||||
return ()
|
||||
ancestor_ids = leaf.path.split("/")
|
||||
if not ancestor_ids:
|
||||
return ()
|
||||
# One round-trip per node — at most MAX_MENU_DEPTH+1 calls (≤4).
|
||||
names: list[str] = []
|
||||
for nid in ancestor_ids:
|
||||
n = await get_menu_node(nid)
|
||||
if n:
|
||||
names.append(n.name)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
async def _publish_menu_item(item: MenuItem) -> None:
|
||||
settings = await get_settings()
|
||||
if not settings.nostr_publish_enabled:
|
||||
|
|
@ -171,7 +194,10 @@ async def _publish_menu_item(item: MenuItem) -> None:
|
|||
|
||||
from . import nostr_client
|
||||
|
||||
event = build_menu_item_event(item, restaurant, pubkey)
|
||||
ancestors = await _ancestor_names_for_node(item.node_id)
|
||||
event = build_menu_item_event(
|
||||
item, restaurant, pubkey, ancestor_names=ancestors
|
||||
)
|
||||
published = await publish_event(nostr_client, event, prvkey)
|
||||
if published:
|
||||
item.nostr_event_id = published.id
|
||||
|
|
@ -342,7 +368,12 @@ async def api_update_menu_node(
|
|||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> MenuNodeRow:
|
||||
"""Update editable fields (name, description, sort_order, image_url).
|
||||
Tree position changes go through PUT /menu_nodes/{id}/move."""
|
||||
Tree position changes go through PUT /menu_nodes/{id}/move.
|
||||
|
||||
A name change triggers re-publishing every item in the subtree
|
||||
so their NIP-99 listings carry the new ancestor `t` tag. ≤50
|
||||
items per restaurant in practice — eager re-publish is cheap.
|
||||
"""
|
||||
node = await get_menu_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
|
|
@ -351,11 +382,41 @@ async def api_update_menu_node(
|
|||
restaurant = await get_restaurant(node.restaurant_id)
|
||||
if restaurant:
|
||||
_require_owner(restaurant, wallet)
|
||||
name_changed = node.name != data.name
|
||||
node.name = data.name
|
||||
node.description = data.description
|
||||
node.sort_order = data.sort_order
|
||||
node.image_url = data.image_url
|
||||
return await update_menu_node(node)
|
||||
updated = await update_menu_node(node)
|
||||
|
||||
if name_changed:
|
||||
await _republish_subtree_items(node_id)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
async def _republish_subtree_items(node_id: str) -> None:
|
||||
"""Re-publish every menu item under the given node's subtree,
|
||||
so its kind-30402 events carry the updated ancestor `t` tag set."""
|
||||
from .crud import db
|
||||
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT mi.* FROM restaurant.menu_items mi
|
||||
JOIN restaurant.menu_nodes mn ON mn.id = mi.node_id
|
||||
WHERE mn.path = (SELECT path FROM restaurant.menu_nodes WHERE id = :nid)
|
||||
OR mn.path LIKE (SELECT path FROM restaurant.menu_nodes WHERE id = :nid) || '/%'
|
||||
""",
|
||||
{"nid": node_id},
|
||||
model=MenuItem,
|
||||
)
|
||||
for it in rows:
|
||||
try:
|
||||
await _publish_menu_item(it)
|
||||
except Exception as ex:
|
||||
logger.warning(
|
||||
f"[RESTAURANT] re-publish failed for item {it.id[:12]}..: {ex}"
|
||||
)
|
||||
|
||||
|
||||
@restaurant_api_router.put("/api/v1/menu_nodes/{node_id}/move")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue