From 4aa90d80ad9c14affe1b5896804918fd6e5e1c35 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 20 May 2026 01:22:38 +0200 Subject: [PATCH] feat: support optional start/end time on events event_start_date / event_end_date now accept either YYYY-MM-DD (date-only) or YYYY-MM-DDTHH:MM (ISO datetime). The NIP-52 publisher switches kind on the "T" delimiter: kind 31922 (date-based, YYYY-MM-DD start/end) when absent, kind 31923 (time-based, unix-timestamp start/end + day-granularity D tags) when present. Delete events match the original publish kind. Closing-date parsing accepts both formats. The LNbits admin form gains optional HH:MM inputs alongside each date picker; they fold into the wire-format string on submit and split back on edit. Co-Authored-By: Claude Opus 4.7 (1M context) --- models.py | 8 +++-- nostr_publisher.py | 71 +++++++++++++++++++++++++++++++++++---------- static/js/index.js | 44 ++++++++++++++++++++++++++-- static/js/index.vue | 34 +++++++++++++++++----- views_api.py | 12 ++++++-- 5 files changed, 137 insertions(+), 32 deletions(-) diff --git a/models.py b/models.py index 56b5f42..a617a13 100644 --- a/models.py +++ b/models.py @@ -30,9 +30,11 @@ class CreateEvent(BaseModel): wallet: str | None = None # filled from caller's wallet if absent name: str # title (required) info: str = "" # description (optional) - closing_date: str | None = None # defaults to event_end_date - event_start_date: str # required - event_end_date: str | None = None # defaults to event_start_date + closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date + # ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30"). + # Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time). + event_start_date: str + event_end_date: str | None = None # same format as event_start_date currency: str = "sat" amount_tickets: int = 0 # 0 = unlimited / not ticketed price_per_ticket: float = 0 # 0 = free diff --git a/nostr_publisher.py b/nostr_publisher.py index d9f46c9..a6d487b 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -1,14 +1,17 @@ """ NIP-52 calendar event publishing for the events extension. -Builds kind 31922 (date-based) calendar events from the Event model, -signs them with the event creator's Account keypair, and publishes -via the NostrClient to nostrclient relays. +Builds NIP-52 calendar events from the Event model, signs them with the +creator's Account keypair, and publishes via the NostrClient. + +Kind 31922 is used for date-only events; kind 31923 (time-based) is used +when event_start_date / event_end_date include a time component. Reference: https://github.com/nostr-protocol/nips/blob/master/52.md """ import time +from datetime import datetime, timezone import coincurve from loguru import logger @@ -17,26 +20,60 @@ from .models import Event from .nostr.event import NostrEvent +def _has_time(value: str | None) -> bool: + """ISO 8601 datetime strings contain a 'T' between date and time.""" + return value is not None and "T" in value + + +def _to_unix(value: str) -> int: + """Parse ISO 8601 datetime (assume UTC if naive) to unix seconds.""" + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp()) + + def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: """ - Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event. + Convert an Event model to a NIP-52 calendar event. - Tags: - d - event.id (addressable identifier) + Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise + date-based (kind 31922). Tags: + d - event.id title - event.name - start - event.event_start_date (ISO date string) - end - event.event_end_date (optional) - image - event.banner (optional) - Content: event.info (description) + start - unix timestamp (31923) or YYYY-MM-DD (31922) + end - same encoding (optional) + image, location, t (categories) - optional + Content: event.info """ + time_based = _has_time(event.event_start_date) + kind = 31923 if time_based else 31922 + start_value = ( + str(_to_unix(event.event_start_date)) if time_based else event.event_start_date + ) + tags = [ ["d", event.id], ["title", event.name], - ["start", event.event_start_date], + ["start", start_value], ] + end_unix: int | None = None if event.event_end_date: - tags.append(["end", event.event_end_date]) + end_value = ( + str(_to_unix(event.event_end_date)) if time_based else event.event_end_date + ) + tags.append(["end", end_value]) + if time_based: + end_unix = _to_unix(event.event_end_date) + + if time_based: + start_unix = _to_unix(event.event_start_date) + start_day = start_unix // 86400 + end_day = (end_unix // 86400) if end_unix is not None else start_day + for day in range(start_day, end_day + 1): + tags.append(["D", str(day)]) + if event.banner: tags.append(["image", event.banner]) if event.location: @@ -47,7 +84,7 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: nostr_event = NostrEvent( pubkey=pubkey, created_at=int(time.time()), - kind=31922, + kind=kind, tags=tags, content=event.info or "", ) @@ -59,15 +96,17 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent: """ Build a kind 5 delete event for a published NIP-52 calendar event. - Uses an 'a' tag to reference the parameterized replaceable event - (kind 31922) per NIP-09. + Uses an 'a' tag to reference the parameterized replaceable event per + NIP-09. The referenced kind must match what we published — 31923 for + time-based events, 31922 for date-only. """ + referenced_kind = 31923 if _has_time(event.event_start_date) else 31922 nostr_event = NostrEvent( pubkey=pubkey, created_at=int(time.time()), kind=5, tags=[ - ["a", f"31922:{pubkey}:{event.id}"], + ["a", f"{referenced_kind}:{pubkey}:{event.id}"], ], content="Event canceled", ) diff --git a/static/js/index.js b/static/js/index.js index 22a73c0..5ff1c42 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -233,11 +233,39 @@ window.PageEvents = { .catch(LNbits.utils.notifyApiError) }) }, + foldDateTime(day, time) { + // Combine separate date/time inputs into the wire format + // expected by the events extension: "YYYY-MM-DD" or + // "YYYY-MM-DDTHH:MM" (time is optional). + if (!day) return null + return time ? `${day}T${time}` : day + }, + splitDateTime(value) { + // Inverse of foldDateTime: split a stored string back into the + // day/time pieces the form inputs bind to. + if (!value) return {day: '', time: ''} + const [day, time = ''] = value.split('T') + // Time inputs only accept HH:MM, drop any seconds we stored. + return {day, time: time.slice(0, 5)} + }, sendEventData() { const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) - const data = this.formDialog.data + const data = {...this.formDialog.data} + data.event_start_date = this.foldDateTime( + data.event_start_day, + data.event_start_time + ) + data.event_end_date = this.foldDateTime( + data.event_end_day, + data.event_end_time + ) + delete data.event_start_day + delete data.event_start_time + delete data.event_end_day + delete data.event_end_time + if (data.extra && !data.extra.promo_codes) { data.extra.promo_codes = data.extra.promo_codes .filter(code => code.trim() !== '') @@ -253,10 +281,22 @@ window.PageEvents = { openEventDialog(data = false) { if (data && data.id) { - this.formDialog.data = {...data} + const start = this.splitDateTime(data.event_start_date) + const end = this.splitDateTime(data.event_end_date) + this.formDialog.data = { + ...data, + event_start_day: start.day, + event_start_time: start.time, + event_end_day: end.day, + event_end_time: end.time + } } else { this.formDialog.data = { currency: 'sats', + event_start_day: '', + event_start_time: '', + event_end_day: '', + event_end_time: '', extra: { conditional: false, min_tickets: 1, diff --git a/static/js/index.vue b/static/js/index.vue index 5aa1579..df3a990 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -471,28 +471,46 @@ > -
+
Event begins
-
+
+
+ +
-
+
Event ends
-
+
+
+ +
@@ -650,8 +668,8 @@ formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || - formDialog.data.event_start_date == null || - formDialog.data.event_end_date == null || + formDialog.data.event_start_day == null || + formDialog.data.event_end_day == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null " diff --git a/views_api.py b/views_api.py index 8602545..0a5bbd8 100644 --- a/views_api.py +++ b/views_api.py @@ -140,9 +140,15 @@ async def api_get_event(event_id: str) -> Event: # closing_date is filled in by create_event (defaults to end_date or # start_date) but the field is typed Optional, so guard for the typechecker. closing_date = event.closing_date or event.event_end_date or event.event_start_date - is_window_open = datetime.now(timezone.utc) < datetime.strptime( - closing_date, "%Y-%m-%d" - ).replace(tzinfo=timezone.utc) + # Accept either YYYY-MM-DD or full ISO 8601 datetime (e.g. event_end_date + # may carry a time component since v1.3.0-aio.3). + try: + closing_dt = datetime.fromisoformat(closing_date) + except ValueError: + closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d") + if closing_dt.tzinfo is None: + closing_dt = closing_dt.replace(tzinfo=timezone.utc) + is_window_open = datetime.now(timezone.utc) < closing_dt is_min_tickets_met = ( event.sold >= event.extra.min_tickets if event.extra.conditional else True )