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:
Padreug 2026-05-02 09:12:19 +02:00
commit cedd548963
2 changed files with 109 additions and 6 deletions

View file

@ -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 []: