diff --git a/nostr_publisher.py b/nostr_publisher.py index c980449..17137fc 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -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, "", ""] image each entry in item.images - t "menu", "", 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:" (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 []: diff --git a/views_api.py b/views_api.py index 3f09a5d..74ca8e3 100644 --- a/views_api.py +++ b/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")