From 1bda4516049030895d392db955967e973b776ea4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:33:18 -0400 Subject: [PATCH] Flag flyer language in Nostr events --- docs/nostr-static-client-spec.md | 11 +++++++++++ site/nostr/nostr-client.js | 16 +++++++++++++++- tests/test_nostr_schema.py | 24 +++++++++++++++++++++++- voxvera/nostr/schema.py | 14 ++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index ead22cf..3adb360 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -61,6 +61,14 @@ Required tags: - `["t", "voxvera"]` - `["t", "flyer"]` +Recommended language tags: + +- `["language", ""]` +- `["L", "ISO-639-1"]` +- `["l", "", "ISO-639-1"]` + +The payload `lang` field is authoritative. The language tags exist for relay/search metadata and as a fallback for older events whose payload omitted `lang`. + Payload: ```json @@ -118,6 +126,9 @@ Nostr editor/viewer controls and Nostr-specific flyer labels come from `NOSTR_UI Language changes should: +- publish the selected flyer language in payload `lang` +- publish matching Nostr language tags +- infer language from event tags if a fetched payload omits `lang` - update default flyer content while the flyer is still using default content - preserve user-authored or fetched event content - update editor/viewer tool labels diff --git a/site/nostr/nostr-client.js b/site/nostr/nostr-client.js index 3a0fa77..950b3cf 100644 --- a/site/nostr/nostr-client.js +++ b/site/nostr/nostr-client.js @@ -751,6 +751,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui function buildUnsignedEvent(payload) { const folderName = slugify(payload.folder_name); + const lang = supportedLang(payload.lang || FALLBACK_LANG); return { kind: EVENT_KIND, created_at: Math.floor(Date.now() / 1000), @@ -759,7 +760,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui ["t", "voxvera"], ["t", "flyer"], ["title", payload.headline || payload.title || folderName], - ["language", payload.lang || "en"] + ["language", lang], + ["L", "ISO-639-1"], + ["l", lang, "ISO-639-1"] ], content: JSON.stringify(payload) }; @@ -1011,10 +1014,21 @@ Join us in a revolution that values truth and transparency. Together, we can bui const hasTag = (name, value) => Array.isArray(event.tags) && event.tags.some((tag) => tag[0] === name && tag[1] === value); if (!hasTag("t", "voxvera") || !hasTag("t", "flyer")) throw new Error("Event is missing VoxVera flyer tags."); const payload = JSON.parse(event.content); + const taggedLang = languageFromTags(event.tags); + if (!payload.lang && taggedLang) { + payload.lang = taggedLang; + } validatePayload(payload); return payload; } + function languageFromTags(tags) { + if (!Array.isArray(tags)) return ""; + const languageTag = tags.find((tag) => Array.isArray(tag) && tag[0] === "language" && tag[1]); + const labelTag = tags.find((tag) => Array.isArray(tag) && tag[0] === "l" && tag[1]); + return supportedLang((languageTag && languageTag[1]) || (labelTag && labelTag[1]) || ""); + } + function makeQrSvg(value) { if (!value) return ""; if (typeof qrcode !== "function") { diff --git a/tests/test_nostr_schema.py b/tests/test_nostr_schema.py index 7960b84..061da6e 100644 --- a/tests/test_nostr_schema.py +++ b/tests/test_nostr_schema.py @@ -29,7 +29,14 @@ def _event(payload=None, **overrides): event = { "kind": 30078, "content": json.dumps(payload or _payload()), - "tags": [["d", "voxvera:nostr-flyer"], ["t", "voxvera"], ["t", "flyer"]], + "tags": [ + ["d", "voxvera:nostr-flyer"], + ["t", "voxvera"], + ["t", "flyer"], + ["language", "en"], + ["L", "ISO-639-1"], + ["l", "en", "ISO-639-1"], + ], } event.update(overrides) return event @@ -43,6 +50,21 @@ def test_validate_accepts_nostr_event_payload(): assert validate_event_source(_event())["content"] == "This flyer came from a Nostr event." +def test_validate_uses_language_tag_when_payload_lang_is_missing(): + payload = _payload() + payload.pop("lang") + event = _event(payload, tags=[ + ["d", "voxvera:nostr-flyer"], + ["t", "voxvera"], + ["t", "flyer"], + ["language", "es"], + ["L", "ISO-639-1"], + ["l", "es", "ISO-639-1"], + ]) + + assert validate_event_source(event)["lang"] == "es" + + def test_normalize_maps_to_flyer_config_defaults(): defaults = { "folder_name": "voxvera", diff --git a/voxvera/nostr/schema.py b/voxvera/nostr/schema.py index d55219a..405f48f 100644 --- a/voxvera/nostr/schema.py +++ b/voxvera/nostr/schema.py @@ -137,6 +137,10 @@ def _extract_payload(source: dict[str, Any]) -> dict[str, Any]: raise NostrValidationError(f"Nostr event content is not valid JSON: {exc.msg}") from exc if not isinstance(payload, dict): raise NostrValidationError("Nostr event content must decode to a JSON object.") + if not payload.get("lang"): + tagged_lang = _language_from_tags(source.get("tags", [])) + if tagged_lang: + payload["lang"] = tagged_lang return payload @@ -182,6 +186,16 @@ def _has_tag(source: dict[str, Any], name: str, value: str) -> bool: return any(isinstance(tag, list) and len(tag) >= 2 and tag[0] == name and tag[1] == value for tag in tags) +def _language_from_tags(tags: Any) -> str: + if not isinstance(tags, list): + return "" + for tag_name in ("language", "l"): + for tag in tags: + if isinstance(tag, list) and len(tag) >= 2 and tag[0] == tag_name: + return _normalize_lang(str(tag[1])) + return "" + + def _normalize_lang(lang: str) -> str: return lang if lang in SUPPORTED_LANGS else "en"