diff --git a/blog.go b/blog.go index 77075a1..6341a1a 100644 --- a/blog.go +++ b/blog.go @@ -11,6 +11,7 @@ import ( "github.com/kirsle/blog/internal/controllers/authctl" commentctl "github.com/kirsle/blog/internal/controllers/comments" "github.com/kirsle/blog/internal/controllers/contact" + eventctl "github.com/kirsle/blog/internal/controllers/events" postctl "github.com/kirsle/blog/internal/controllers/posts" "github.com/kirsle/blog/internal/controllers/setup" "github.com/kirsle/blog/internal/log" @@ -25,6 +26,7 @@ import ( "github.com/kirsle/blog/jsondb/caches/null" "github.com/kirsle/blog/jsondb/caches/redis" "github.com/kirsle/blog/models/comments" + "github.com/kirsle/blog/models/events" "github.com/kirsle/blog/models/posts" "github.com/kirsle/blog/models/settings" "github.com/kirsle/blog/models/users" @@ -88,6 +90,7 @@ func (b *Blog) Configure() { posts.DB = b.DB users.DB = b.DB comments.DB = b.DB + events.DB = b.DB // Redis cache? if config.Redis.Enabled { @@ -120,6 +123,7 @@ func (b *Blog) SetupHTTP() { contact.Register(r) postctl.Register(r, b.MustLogin) commentctl.Register(r) + eventctl.Register(r, b.MustLogin) // GitHub Flavored Markdown CSS. r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) diff --git a/internal/controllers/events/edit.go b/internal/controllers/events/edit.go new file mode 100644 index 0000000..80244e8 --- /dev/null +++ b/internal/controllers/events/edit.go @@ -0,0 +1,60 @@ +package events + +import ( + "html/template" + "net/http" + "strconv" + + "github.com/kirsle/blog/internal/markdown" + "github.com/kirsle/blog/internal/render" + "github.com/kirsle/blog/internal/responses" + "github.com/kirsle/blog/models/events" +) + +func editHandler(w http.ResponseWriter, r *http.Request) { + v := map[string]interface{}{ + "preview": "", + } + var ev *events.Event + + // Are we editing an existing event? + if idStr := r.FormValue("id"); idStr != "" { + id, err := strconv.Atoi(idStr) + if err == nil { + ev, err = events.Load(id) + if err != nil { + responses.Flash(w, r, "That event ID was not found") + ev = events.New() + } + } + } else { + ev = events.New() + } + + if r.Method == http.MethodPost { + // Parse from form values. + ev.ParseForm(r) + + // Previewing, or submitting? + switch r.FormValue("submit") { + case "preview": + v["preview"] = template.HTML(markdown.RenderTrustedMarkdown(ev.Description)) + case "save": + if err := ev.Validate(); err != nil { + responses.Flash(w, r, "Error: %s", err.Error()) + } else { + err = ev.Save() + + if err != nil { + responses.Flash(w, r, "Error: %s", err.Error()) + } else { + responses.Flash(w, r, "Event created!") + responses.Redirect(w, "/e/"+ev.Fragment) + } + } + } + } + + v["event"] = ev + render.Template(w, r, "events/edit", v) +} diff --git a/internal/controllers/events/events.go b/internal/controllers/events/events.go new file mode 100644 index 0000000..6b346b9 --- /dev/null +++ b/internal/controllers/events/events.go @@ -0,0 +1,75 @@ +package events + +import ( + "net/http" + "sort" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/internal/log" + "github.com/kirsle/blog/internal/middleware/auth" + "github.com/kirsle/blog/internal/render" + "github.com/kirsle/blog/internal/responses" + "github.com/kirsle/blog/models/events" + "github.com/urfave/negroni" +) + +// Register the blog routes to the app. +func Register(r *mux.Router, loginError http.HandlerFunc) { + // Login-required routers. + loginRouter := mux.NewRouter() + loginRouter.HandleFunc("/e/admin/edit", editHandler) + loginRouter.HandleFunc("/e/admin/invite", inviteHandler) + loginRouter.HandleFunc("/e/admin/", indexHandler) + r.PathPrefix("/e/admin").Handler( + negroni.New( + negroni.HandlerFunc(auth.LoginRequired(loginError)), + negroni.Wrap(loginRouter), + ), + ) + + // Public routes + r.HandleFunc("/e/{fragment}", viewHandler) +} + +// Admin index to view all events. +func indexHandler(w http.ResponseWriter, r *http.Request) { + result := []*events.Event{} + docs, _ := events.DB.List("events/by-id") + for _, doc := range docs { + ev := &events.Event{} + err := events.DB.Get(doc, &ev) + if err != nil { + log.Error("error reading %s: %s", doc, err) + continue + } + + result = append(result, ev) + } + + sort.Sort(sort.Reverse(events.ByDate(result))) + + render.Template(w, r, "events/index", map[string]interface{}{ + "events": result, + }) +} + +// User handler to view a single event page. +func viewHandler(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + fragment, ok := params["fragment"] + if !ok { + responses.NotFound(w, r, "Not Found") + return + } + + event, err := events.LoadFragment(fragment) + if err != nil { + responses.FlashAndRedirect(w, r, "/", "Event Not Found") + return + } + + v := map[string]interface{}{ + "event": event, + } + render.Template(w, r, "events/view", v) +} diff --git a/internal/controllers/events/invite.go b/internal/controllers/events/invite.go new file mode 100644 index 0000000..f52b143 --- /dev/null +++ b/internal/controllers/events/invite.go @@ -0,0 +1,30 @@ +package events + +import ( + "net/http" + "strconv" + + "github.com/kirsle/blog/internal/render" + "github.com/kirsle/blog/internal/responses" + "github.com/kirsle/blog/models/events" +) + +func inviteHandler(w http.ResponseWriter, r *http.Request) { + v := map[string]interface{}{ + "preview": "", + } + + id, err := strconv.Atoi(r.FormValue("id")) + if err != nil { + responses.FlashAndRedirect(w, r, "/e/admin/", "Invalid ID") + return + } + event, err := events.Load(id) + if err != nil { + responses.FlashAndRedirect(w, r, "/e/admin/", "Can't load event: %s", err) + return + } + + v["event"] = event + render.Template(w, r, "events/invite", v) +} diff --git a/internal/render/functions.go b/internal/render/functions.go index 9e18b3c..37b0c68 100644 --- a/internal/render/functions.go +++ b/internal/render/functions.go @@ -4,11 +4,22 @@ import ( "html/template" "strings" "time" + + "github.com/kirsle/blog/internal/markdown" ) // Funcs is a global funcmap that the blog can hook its internal // methods onto. var Funcs = template.FuncMap{ "StringsJoin": strings.Join, - "Now": time.Now, + "NewlinesToSpace": func(text string) string { + return strings.Replace( + strings.Replace(text, "\n", " ", -1), + "\r", "", -1, + ) + }, + "Now": time.Now, + "TrustedMarkdown": func(text string) template.HTML { + return template.HTML(markdown.RenderTrustedMarkdown(text)) + }, } diff --git a/models/events/events.go b/models/events/events.go new file mode 100644 index 0000000..3fe4d4a --- /dev/null +++ b/models/events/events.go @@ -0,0 +1,243 @@ +package events + +import ( + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/kirsle/blog/jsondb" + "github.com/kirsle/golog" +) + +// DB is a reference to the parent app's JsonDB object. +var DB *jsondb.DB + +var log *golog.Logger + +func init() { + log = golog.GetLogger("blog") +} + +// Event holds information about events. +type Event struct { + ID int `json:"id"` + Title string `json:"title"` + Fragment string `json:"fragment"` + Description string `json:"description"` + Location string `json:"location"` + CoverPhoto string `json:"coverPhoto"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + AllDay bool `json:"allDay"` + OpenSignup bool `json:"openSignup"` + RSVP []RSVP `json:"rsvp"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// RSVP tracks invitations and confirmations to events. +type RSVP struct { + // If the user was invited by an admin, they will have a ContactID and + // not much else. Users who signed up themselves from an OpenSignup event + // will have the metadata filled in instead. + ContactID int `json:"contactId"` + Notified bool `json:"notified"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` // invited, going, maybe, not going + Email string `json:"email,omitempty"` + SMS string `json:"sms,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// New creates a blank event with sensible defaults. +func New() *Event { + return &Event{ + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC(), + } +} + +// ParseForm populates the event from form values. +func (ev *Event) ParseForm(r *http.Request) { + id, _ := strconv.Atoi(r.FormValue("id")) + + ev.ID = id + ev.Title = r.FormValue("title") + ev.Fragment = r.FormValue("fragment") + ev.Description = r.FormValue("description") + ev.Location = r.FormValue("location") + ev.AllDay = r.FormValue("all_day") == "true" + ev.OpenSignup = r.FormValue("open_signup") == "true" + + startTime, err := parseDateTime(r, "start_date", "start_time") + ev.StartTime = startTime + if err != nil { + log.Error("startTime parse error: %s", err) + } + + endTime, err := parseDateTime(r, "end_date", "end_time") + ev.EndTime = endTime + if err != nil { + log.Error("endTime parse error: %s", err) + } +} + +// parseDateTime parses separate date + time fields into a single time.Time. +func parseDateTime(r *http.Request, dateField, timeField string) (time.Time, error) { + dateValue := r.FormValue(dateField) + timeValue := r.FormValue(timeField) + + if dateValue != "" && timeValue != "" { + datetime, err := time.Parse("2006-01-02 15:04", dateValue+" "+timeValue) + return datetime, err + } else if dateValue != "" { + datetime, err := time.Parse("2006-01-02", dateValue) + return datetime, err + } else { + return time.Time{}, errors.New("no date/times given") + } +} + +// Validate makes sure the required fields are all present. +func (ev *Event) Validate() error { + if ev.Title == "" { + return errors.New("title is required") + } else if ev.Description == "" { + return errors.New("description is required") + } + return nil +} + +// Load an event by its ID. +func Load(id int) (*Event, error) { + ev := &Event{} + err := DB.Get(fmt.Sprintf("events/by-id/%d", id), &ev) + return ev, err +} + +// LoadFragment loads an event by its URL fragment. +func LoadFragment(fragment string) (*Event, error) { + idx, err := GetIndex() + if err != nil { + return nil, err + } + + if id, ok := idx.Fragments[fragment]; ok { + ev, err := Load(id) + return ev, err + } + + return nil, errors.New("fragment not found") +} + +// Save the event. +func (ev *Event) Save() error { + // Editing an existing event? + if ev.ID == 0 { + ev.ID = nextID() + } + + // Generate a URL fragment if needed. + if ev.Fragment == "" { + fragment := strings.ToLower(ev.Title) + fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-") + if strings.Contains(fragment, "--") { + log.Error("Generated event fragment '%s' contains double dashes still!", fragment) + } + ev.Fragment = strings.Trim(fragment, "-") + + // If still no fragment, make one based on the post ID. + if ev.Fragment == "" { + ev.Fragment = fmt.Sprintf("event-%d", ev.ID) + } + } + + // Make sure the URL fragment is unique! + if len(ev.Fragment) > 0 { + if exist, err := LoadFragment(ev.Fragment); err == nil && exist.ID != ev.ID { + var resolved bool + for i := 1; i <= 100; i++ { + fragment := fmt.Sprintf("%s-%d", ev.Fragment, i) + _, err := LoadFragment(fragment) + if err == nil { + continue + } + + ev.Fragment = fragment + resolved = true + break + } + + if !resolved { + return fmt.Errorf("failed to generate a unique URL fragment for '%s' after 100 attempts", ev.Fragment) + } + } + } + + // Dates & times. + if ev.Created.IsZero() { + ev.Created = time.Now().UTC() + } + if ev.Updated.IsZero() { + ev.Updated = ev.Created + } + + // Write the event. + DB.Commit(fmt.Sprintf("events/by-id/%d", ev.ID), ev) + + // Update the index cache. + err := UpdateIndex(ev) + if err != nil { + return fmt.Errorf("UpdateIndex() error: %v", err) + } + + return nil +} + +// Delete an event. +func (ev *Event) Delete() error { + if ev.ID == 0 { + return errors.New("event has no ID") + } + + // Delete the DB files. + DB.Delete(fmt.Sprintf("events/by-id/%d", ev.ID)) + + // Remove it from the index. + idx, err := GetIndex() + if err != nil { + return fmt.Errorf("GetIndex error: %v", err) + } + return idx.Delete(ev) +} + +// getNextID gets the next blog post ID. +func nextID() int { + // Highest ID seen so far. + var highest int + + events, err := DB.List("events/by-id") + if err != nil { + return 1 + } + + for _, doc := range events { + fields := strings.Split(doc, "/") + id, err := strconv.Atoi(fields[len(fields)-1]) + if err != nil { + continue + } + + if id > highest { + highest = id + } + } + + // Return the highest +1 + return highest + 1 +} diff --git a/models/events/index.go b/models/events/index.go new file mode 100644 index 0000000..1bfcc07 --- /dev/null +++ b/models/events/index.go @@ -0,0 +1,60 @@ +package events + +// Index maps URL fragments to event IDs. +type Index struct { + Fragments map[string]int `json:"fragments"` +} + +// GetIndex loads the index DB, or rebuilds it if not found. +func GetIndex() (*Index, error) { + if !DB.Exists("events/index") { + index, err := RebuildIndex() + return index, err + } + + idx := &Index{} + err := DB.Get("events/index", &idx) + return idx, err +} + +// RebuildIndex builds the event index from scratch. +func RebuildIndex() (*Index, error) { + idx := &Index{ + Fragments: map[string]int{}, + } + + events, _ := DB.List("events/by-id") + for _, doc := range events { + ev := &Event{} + err := DB.Get(doc, &ev) + if err != nil { + return nil, err + } + + idx.Update(ev) + } + + return idx, nil +} + +// UpdateIndex updates the index with an event. +func UpdateIndex(event *Event) error { + idx, err := GetIndex() + if err != nil { + return err + } + + return idx.Update(event) +} + +// Update an event in the index. +func (idx *Index) Update(event *Event) error { + idx.Fragments[event.Fragment] = event.ID + return DB.Commit("events/index", idx) +} + +// Delete an event from the index. +func (idx *Index) Delete(event *Event) error { + delete(idx.Fragments, event.Fragment) + return DB.Commit("events/index", idx) +} diff --git a/models/events/sorting.go b/models/events/sorting.go new file mode 100644 index 0000000..4e8258d --- /dev/null +++ b/models/events/sorting.go @@ -0,0 +1,10 @@ +package events + +// ByDate sorts events by their start time. +type ByDate []*Event + +func (a ByDate) Len() int { return len(a) } +func (a ByDate) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByDate) Less(i, j int) bool { + return a[i].StartTime.Before(a[i].StartTime) || a[i].ID < a[j].ID +} diff --git a/models/posts/index.go b/models/posts/index.go index 7f4cb2e..6b5ff02 100644 --- a/models/posts/index.go +++ b/models/posts/index.go @@ -70,6 +70,7 @@ func (idx *Index) Update(p *Post) error { // Delete a blog's entry from the index. func (idx *Index) Delete(p *Post) error { delete(idx.Posts, p.ID) + delete(idx.Fragments, p.Fragment) return DB.Commit("blog/index", idx) } diff --git a/root/admin/index.gohtml b/root/admin/index.gohtml index 04ddaf1..509383a 100644 --- a/root/admin/index.gohtml +++ b/root/admin/index.gohtml @@ -4,6 +4,7 @@