Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions projects/gnoland/gno.land/p/eve000/event/calendar.gno
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 + "}"
}
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
110 changes: 110 additions & 0 deletions projects/gnoland/gno.land/p/eve000/event/component/icsrecurring.gno
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@masonmcbride still evaluating this - but here's where the new template could go

I assume we'll pass in options as needed

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()
}
}
175 changes: 133 additions & 42 deletions projects/gnoland/gno.land/r/labs000/officehours/index.gno
Original file line number Diff line number Diff line change
Expand Up @@ -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
<gno-columns>
<gno-form>
<gno-textarea name="description" label="Description" rows="3" required placeholder="Describe your topic proposal, Or leave a comment here..."/>
<gno-input name="topic" type="radio" value="` + cid + `" checked="true" />
<gno-input name="date" type="radio" value="` + evt.StartDate[:10] + `" checked="true" />
</gno-form>

<gno-textarea name="description" label="Description" rows="3" required placeholder="Describe your topic proposal, or leave a comment..."/>
<gno-input name="topic" type="radio" value="` + topicCID + `" checked="true" />
<gno-input name="date" type="radio" value="` + s.StartDate[:10] + `" checked="true" />
</gno-form>
|||

</gno-columns>
`)
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=<topicCID>
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()
}