diff --git a/projects/gnoland/gno.land/p/eve000/event/calendar.gno b/projects/gnoland/gno.land/p/eve000/event/calendar.gno index c84055f3..cedd201e 100644 --- a/projects/gnoland/gno.land/p/eve000/event/calendar.gno +++ b/projects/gnoland/gno.land/p/eve000/event/calendar.gno @@ -489,6 +489,17 @@ func ParseTimeSafe(timeStr string) time.Time { var _ eve.LogoGraph = (*Schedule)(nil) var _ eve.Projectable = (*Schedule)(nil) +func ScheduleFromEvent(evt *Event, cid string) Schedule { + return Schedule{ + Status: string(evt.Status), + StartDate: evt.StartDate.Format(time.RFC3339), + EndDate: evt.EndDate.Format(time.RFC3339), + Title: evt.Name, + Description: evt.Description, + EventCalendarCid: cid, + } +} + type Schedule struct { Status string StartDate string @@ -503,7 +514,7 @@ func (o Schedule) Thumbnail() string { return "![" + o.Title + "](" + o.DataUrl( func (o Schedule) DataUrl() string { return "data:image/svg+xml;charset=utf-8," + url.PathEscape(o.SVG()) } -func (o Schedule) SVG() string { return scheduleSVG(o.Title) } +func (o Schedule) SVG() string { return ScheduleSVG(o.Title) } func (o Schedule) String() string { return "Schedule{Status:" + o.Status + ", StartDate:" + o.StartDate + "}" } @@ -625,7 +636,7 @@ func parseOhrScheduleOpts(obj Schedule) (start, end time.Time, status, descr, ti return } -func scheduleSVG(title string) string { +func ScheduleSVG(title string) string { if title == "" { title = "Event EventCalendar" } @@ -675,5 +686,8 @@ func (c EventCalendar) JsonLD() eve.JsonLDMap { return c.Default.JsonLD() } func (EventCalendar) Heading() string { return "| |\n| -------- |\n" } func (EventCalendar) Row(obj EventCalendar) string { - return "| [" + obj.Thumbnail() + obj.Title + "](./officehours?cal=" + obj.Cid() + ") | " + cidLink := "?cal=" + obj.Cid() + return "| [" + obj.Thumbnail() + "](" + cidLink + ") |\n" + + "| 📅 ["+ obj.Title + " Calendar](" + cidLink + ")" + " |\n" + + "| 📄 [View Event Flyer]("+ cidLink +"&render=flyer)" + " |\n" } diff --git a/projects/gnoland/gno.land/p/eve000/event/component/flyer.gno b/projects/gnoland/gno.land/p/eve000/event/component/flyer.gno index 0a402cc8..e48359d9 100644 --- a/projects/gnoland/gno.land/p/eve000/event/component/flyer.gno +++ b/projects/gnoland/gno.land/p/eve000/event/component/flyer.gno @@ -58,7 +58,7 @@ func (a *Flyer) ToMarkdown(body ...Content) string { markdown += "[📥 Download Calendar File](" + calFile.(string) + "/" + fullURL + "?format=ics" + ")\n\n" } if _, ok := a.RenderOpts()["CalendarDataUrl"]; ok { - markdown += "[📅 Add To Calendar](" + CalenderDataUrl("?format=ics", a) + ")\n\n" + markdown += "[📅 Add To Calendar](" + IcsCalenderDataUrl("?format=ics", a) + ")\n\n" } if len(body) > 0 { diff --git a/projects/gnoland/gno.land/p/eve000/event/component/calendar.gno b/projects/gnoland/gno.land/p/eve000/event/component/icsevent.gno similarity index 97% rename from projects/gnoland/gno.land/p/eve000/event/component/calendar.gno rename to projects/gnoland/gno.land/p/eve000/event/component/icsevent.gno index df2a0a14..72c5aba7 100644 --- a/projects/gnoland/gno.land/p/eve000/event/component/calendar.gno +++ b/projects/gnoland/gno.land/p/eve000/event/component/icsevent.gno @@ -9,7 +9,7 @@ import ( "gno.land/p/demo/ufmt" ) -func CalenderDataUrl(path string, a *Flyer) string { +func IcsCalenderDataUrl(path string, a *Flyer) string { if path == "" { path = "?format=ics" } diff --git a/projects/gnoland/gno.land/p/eve000/event/component/icsrecurring.gno b/projects/gnoland/gno.land/p/eve000/event/component/icsrecurring.gno new file mode 100644 index 00000000..2c8152d5 --- /dev/null +++ b/projects/gnoland/gno.land/p/eve000/event/component/icsrecurring.gno @@ -0,0 +1,110 @@ +package component + +import ( + "net/url" + "std" + "strings" + "time" + + "gno.land/p/demo/ufmt" +) + +func IcsRecurringCalenderDataUrl(path string, opts map[string]string, a *Flyer) string { + if path == "" { + path = "?format=ics" + } + data := IcsRecurringCalendarFile(path, a, opts) + return "data:text/calendar;charset=utf-8," + url.QueryEscape(data) +} + +func IcsRecurringCalendarFile(path string, a *Flyer, opts map[string]string) string { + var f = ufmt.Sprintf + var b strings.Builder + w := func(s string) { b.WriteString(s + "\n") } + + q := ParseQuery(path) + sessionIDs := q["session"] + format := strings.ToLower(q.Get("format")) + + useAll := len(sessionIDs) == 0 + allowed := make(map[string]bool) + for _, id := range sessionIDs { + allowed[id] = true + } + include := func(id string) bool { return useAll || allowed[id] } + + fullPath := std.CurrentRealm().PkgPath() + prodID := strings.ReplaceAll(fullPath, "/", "//") + "//EN" + + switch format { + case "json": + b.WriteString(a.ToJson()) + return b.String() + case "ics": + w("BEGIN:VCALENDAR") + w("VERSION:2.0") + w("CALSCALE:GREGORIAN") + w("PRODID:-" + prodID) + w("METHOD:PUBLISH\n") + + w("BEGIN:VEVENT") + w(f("UID:event-%s@%s", slugify(a.Name), fullPath)) + w("SEQUENCE:0") + w(f("DTSTAMP:%s", time.Now().UTC().Format("20060102T150405Z"))) + w(f("DTSTART;VALUE=DATE:%s", a.StartDate.Format("20060102"))) + w(f("DTEND;VALUE=DATE:%s", a.StartDate.AddDate(0, 0, 1).Format("20060102"))) + w(f("SUMMARY:%s", a.Name)) + w(f("DESCRIPTION:%s", a.Description)) + if a.Location != nil && a.Location.Name != "" { + w(f("LOCATION:%s", a.Location.Name)) + } + // Recurrence support + if rrule, ok := opts["RRULE"]; ok && rrule != "" { + w("RRULE:" + rrule) + } + if exdate, ok := opts["EXDATE"]; ok && exdate != "" { + w("EXDATE:" + exdate) + } + w("END:VEVENT\n") + + for i, s := range a.Sessions { + id := Pad3(i) + if !include(id) { + continue + } + w("BEGIN:VEVENT") + w(f("UID:%s-%d@%s", slugify(s.Title)[:5], s.StartTime.Unix(), fullPath)) + w(f("SEQUENCE:%d", s.Sequence)) + w(f("DTSTAMP:%s", time.Now().UTC().Format("20060102T150405Z"))) + w(f("DTSTART:%s", s.StartTime.UTC().Format("20060102T150000Z"))) + w(f("DTEND:%s", s.EndTime.UTC().Format("20060102T150000Z"))) + w(f("SUMMARY:%s", s.Title)) + w(f("DESCRIPTION:%s", s.Description)) + w(f("LOCATION:%s", s.Location.Name)) + if s.Cancelled { + w("STATUS:CANCELLED") + } + // Recurrence for sessions + if rrule, ok := opts["RRULE"]; ok && rrule != "" { + w("RRULE:" + rrule) + } + if exdate, ok := opts["EXDATE"]; ok && exdate != "" { + w("EXDATE:" + exdate) + } + w("END:VEVENT\n") + } + + w("END:VCALENDAR") + return b.String() + default: + w(f("# %s\n\n%s", a.Name, a.Description)) + for i, s := range a.Sessions { + id := Pad3(i) + if !include(id) { + continue + } + w(s.ToMarkdown()) + } + return b.String() + } +} \ No newline at end of file diff --git a/projects/gnoland/gno.land/r/labs000/officehours/index.gno b/projects/gnoland/gno.land/r/labs000/officehours/index.gno index 253bb764..c5284e98 100644 --- a/projects/gnoland/gno.land/r/labs000/officehours/index.gno +++ b/projects/gnoland/gno.land/r/labs000/officehours/index.gno @@ -9,83 +9,174 @@ import ( ) var ( + // Configuration eventTitle = "Office Hours" eventDescription = "AibLabs Office Hours: Join us for open discussion." - eventDayOfWeek = time.Wednesday // Configurable: time.Sunday, time.Monday, etc. + eventDayOfWeek = time.Wednesday // e.g. time.Sunday, time.Monday, ... eventStartHour = 10 eventDuration = time.Hour - cancelledDates = map[string]bool{} // Example: YYYY-MM-DD format + cancelledDates = map[string]bool{} // YYYY-MM-DD -> true + + // Per-env render options + renderOpts = map[string]interface{}{ + "dev": map[string]interface{}{ + "CalendarFile": "http://127.0.0.1:8080", + "SessionsTitle": "Next Session", + }, + "labsnet1": map[string]interface{}{ + "SvgFooter": struct{}{}, + "CalendarHost": "webcal://gnocal.aiblabs.net", + "SessionsTitle": "Next Session", + }, + } ) +// ---------- Time helpers ---------- + func nextEventDay() time.Time { now := time.Now().UTC() - daysUntilEvent := (int(eventDayOfWeek) - int(now.Weekday()) + 7) % 7 - if daysUntilEvent == 0 { - daysUntilEvent = 7 + daysUntil := (int(eventDayOfWeek) - int(now.Weekday()) + 7) % 7 + if daysUntil == 0 { + daysUntil = 7 } - nextEvent := now.AddDate(0, 0, daysUntilEvent) - return time.Date(nextEvent.Year(), nextEvent.Month(), nextEvent.Day(), eventStartHour, 0, 0, 0, time.UTC) + next := now.AddDate(0, 0, daysUntil) + return time.Date(next.Year(), next.Month(), next.Day(), eventStartHour, 0, 0, 0, time.UTC) } func isCancelled(date time.Time) bool { - dateStr := date.Format("2006-01-02") - if _, exists := cancelledDates[dateStr]; exists { - return true - } - return false + return cancelledDates[date.Format("2006-01-02")] } -func proposalForm(cid string, evt event.Schedule) string { - if time.Now().After(event.ParseTimeSafe(evt.EndDate).Add(48 * time.Hour)) { +// ---------- UI helpers ---------- + +func proposalForm(topicCID string, s event.Schedule) string { + // Hide after 48h past end + if time.Now().After(event.ParseTimeSafe(s.EndDate).Add(48 * time.Hour)) { return "" } var sb strings.Builder sb.WriteString(`### Propose Topic - - - - - + + + + ||| - `) return sb.String() } -func Render(path string) string { +// ---------- Composition core ---------- + +// EventFromPath composes the Event (model) from the route/query. +func EventFromPath(path string) *event.Event { q := eve.ParseQuery(path) - var sb strings.Builder - if topic, ok := q["topic"]; ok && len(topic) > 0 { - sb.WriteString("## Commit \n Topic: " + topic[0] + "\n\n") + start := nextEventDay() + end := start.Add(eventDuration) + status := "EventPlanned" + if isCancelled(start) { + status = "EventCancelled" + } - if date, ok := q["date"]; ok && len(date) > 0 { - sb.WriteString("Date: " + date[0] + "\n\n") + // Optional overrides via query (non-breaking) + if ds := q.Get("start"); ds != "" { + if t := event.ParseTimeSafe(ds); !t.IsZero() { + start = t + end = start.Add(eventDuration) } - - if description, ok := q["description"]; ok && len(description) > 0 { - sb.WriteString("Description: " + description[0] + "\n\n") - return sb.String() + } + if de := q.Get("end"); de != "" { + if t := event.ParseTimeSafe(de); !t.IsZero() { + end = t } } + if st := q.Get("status"); st != "" { + status = st + } - startDate := nextEventDay() - endDate := startDate.Add(eventDuration) - status := "EventPlanned" - if isCancelled(startDate) { - status = "EventCancelled" + session := &eve.Session{ + Title: eventTitle, + StartTime: start, + EndTime: end, + Description: eventDescription, + Sequence: 0, } - options := map[string]string{ - "Status": status, - "StartDate": startDate.Format(time.RFC3339), - "EndDate": endDate.Format(time.RFC3339), - "Title": eventTitle, - "Description": eventDescription, + evt := &event.Event{ + Name: eventTitle, + Status: eve.Status(status), + StartDate: start, + EndDate: end, + Location: &eve.Location{Name: "Online"}, + Sessions: []*eve.Session{session}, } + evt.SetRenderOpts(renderOpts) + return evt +} - return event.Calendar(options, event.WithCommentForm(proposalForm)).Render(path) +// RenderFlyer composes the flyer page view. +func RenderFlyer(path string) string { + q := eve.ParseQuery(path) + evt := EventFromPath(path) + var body strings.Builder + + body.WriteString(event.Schedule{ Title: evt.Name }.Thumbnail()) + + // Optional topic proposal form: ?cal= + if topicCID := q.Get("cal"); topicCID != "" { + s := event.ScheduleFromEvent(evt, topicCID) + body.WriteString(proposalForm(topicCID, s)) + } + + return evt.RenderPage("", eve.Content{ + Published: true, + Markdown: body.String(), + }) } + +// RenderCalendar composes an ICS calendar file (recurring) for the event. +func RenderCalendar(path string) string { + evt := EventFromPath(path) + opts := map[string]string{ + "ProdId": "-//aibLabs//Office Hours//EN", + "CalName": "aibLabs Office Hours", + "CalDesc": "Join us for open discussion.", + "TimeZone": "UTC", + "Method": "PUBLISH", + "XWR-CALNAME": "aibLabs Office Hours", + "XWR-CALDESC": "Join us for open discussion.", + "XWR-TIMEZONE": "UTC", + } + return eve.IcsRecurringCalendarFile(path, evt.Flyer(), opts) +} + +// Render is the single entrypoint; it composes the correct view by route param. +func Render(path string) string { + q := eve.ParseQuery(path) + var sb strings.Builder + + if ics := q.Get("render"); ics == "calendar" { + return RenderCalendar(path) + } + + if topic := q.Get("topic"); topic != "" { + sb.WriteString("## Commit\n") + sb.WriteString("Topic: " + topic + "\n\n") + + if date := q.Get("date"); date != "" { + sb.WriteString("Date: " + date + "\n\n") + } + if desc := q.Get("description"); desc != "" { + sb.WriteString("Description: " + desc + "\n\n") + } + return sb.String() + } else { + evt := EventFromPath(path) + cid := evt.Cid() + sb.WriteString(proposalForm(cid, event.ScheduleFromEvent(evt, cid))) + } + return RenderFlyer(path) + sb.String() +} \ No newline at end of file