hswaw/site: render main page and blog feed

This reimplements the blog rendering functionality and the main/index
page.

www-main used to combine multiple atom feeds into one (Redmine and the
wordpress blog at blog.hackerspace.pl). We retain the functionality, but
only render the wordpress blog now (some other content might follow).

We also cowardly comment out the broken calendar iframe.

Change-Id: I9abcd8d85149968d06e1cb9c97d72eba7f0bc99f
diff --git a/hswaw/site/BUILD.bazel b/hswaw/site/BUILD.bazel
index 0b05bf3..88de8e6 100644
--- a/hswaw/site/BUILD.bazel
+++ b/hswaw/site/BUILD.bazel
@@ -3,6 +3,7 @@
 go_library(
     name = "go_default_library",
     srcs = [
+        "feeds.go",
         "main.go",
         "views.go",
     ],
diff --git a/hswaw/site/feeds.go b/hswaw/site/feeds.go
new file mode 100644
index 0000000..ad9bc31
--- /dev/null
+++ b/hswaw/site/feeds.go
@@ -0,0 +1,164 @@
+package main
+
+import (
+	"context"
+	"encoding/xml"
+	"fmt"
+	"html/template"
+	"net/http"
+	"sort"
+	"time"
+
+	"github.com/golang/glog"
+)
+
+// This implements 'Atom' feed parsing. Honestly, this was written without
+// looking at any spec. If it ever breaks, you know why.
+
+var (
+	// feedURLs is a map from an atom feed name to its URL. All the following
+	// feeds will be combined and rendered on the main page of the website.
+	feedsURLs = map[string]string{
+		"blog": "https://blog.hackerspace.pl/feed/atom/",
+	}
+)
+
+// atomFeed is a retrieved atom feed.
+type atomFeed struct {
+	XMLName xml.Name     `xml:"feed"`
+	Entries []*atomEntry `xml:"entry"`
+}
+
+// atomEntry is an entry (eg. blog post) from an atom feed. It contains fields
+// directly from the XML, plus some additional parsed types and metadata.
+type atomEntry struct {
+	XMLName      xml.Name      `xml:"entry"`
+	Author       string        `xml:"author>name"`
+	Title        template.HTML `xml:"title"`
+	Summary      template.HTML `xml:"summary"`
+	UpdatedRaw   string        `xml:"updated"`
+	PublishedRaw string        `xml:"published"`
+	Link         struct {
+		Href string `xml:"href,attr"`
+	} `xml:"link"`
+
+	// Updated is the updated time parsed from UpdatedRaw.
+	Updated time.Time
+	// UpdatedHuman is a human-friendly representation of Updated for web rendering.
+	UpdatedHuman string
+	// Published is the published time parsed from PublishedRaw.
+	Published time.Time
+	// Source is the name of the feed that this entry was retrieved from. Only
+	// set after combining multiple feeds together (ie. when returned from
+	// getFeeds).
+	Source string
+}
+
+// getAtomFeed retrieves a single Atom feed from the given URL.
+func getAtomFeed(ctx context.Context, url string) (*atomFeed, error) {
+	r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+	if err != nil {
+		return nil, fmt.Errorf("NewRequest(%q): %w", url, err)
+	}
+	res, err := http.DefaultClient.Do(r)
+	if err != nil {
+		return nil, fmt.Errorf("Do(%q): %w", url, err)
+	}
+	defer res.Body.Close()
+
+	var feed atomFeed
+	d := xml.NewDecoder(res.Body)
+	if err := d.Decode(&feed); err != nil {
+		return nil, fmt.Errorf("Decode: %w", err)
+	}
+
+	for i, e := range feed.Entries {
+		updated, err := time.Parse(time.RFC3339, e.UpdatedRaw)
+		if err != nil {
+			return nil, fmt.Errorf("entry %d: cannot parse updated date %q: %v", i, e.UpdatedRaw, err)
+		}
+		published, err := time.Parse(time.RFC3339, e.PublishedRaw)
+		if err != nil {
+			return nil, fmt.Errorf("entry %d: cannot parse published date %q: %v", i, e.PublishedRaw, err)
+		}
+		e.Updated = updated
+		e.Published = published
+		e.UpdatedHuman = e.Updated.Format("02-01-2006")
+		if e.Author == "" {
+			e.Author = "Anonymous"
+		}
+	}
+
+	return &feed, nil
+}
+
+// feedWorker runs a worker which retrieves all atom feeds every minute and
+// updates the services' feeds map with the retrieved data. On error, the feeds
+// are not updated (whatever is already cached in the map will continue to be
+// available) and the error is logged.
+func (s *service) feedWorker(ctx context.Context) {
+	okay := false
+	get := func() {
+		feeds := make(map[string]*atomFeed)
+
+		prev := okay
+		okay = true
+		for name, url := range feedsURLs {
+			feed, err := getAtomFeed(ctx, url)
+			if err != nil {
+				glog.Errorf("Getting feed %v failed: %v", feed, err)
+				okay = false
+				continue
+			}
+			feeds[name] = feed
+		}
+
+		// Log whenever the first fetch succeeds, or whenever the fetch
+		// succeeds again (avoiding polluting logs with success messages).
+		if !prev && okay {
+			glog.Infof("Feeds okay.")
+		}
+
+		// Update cached feeds.
+		s.feedsMu.Lock()
+		s.feeds = feeds
+		s.feedsMu.Unlock()
+	}
+	// Perform initial fetch.
+	get()
+
+	// ... and update every minute.
+	t := time.NewTicker(time.Minute)
+	defer t.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-t.C:
+			get()
+		}
+	}
+}
+
+// getFeeds retrieves the currently cached feeds and combines them into a
+// single reverse-chronological timeline, annotating each entries' Source field
+// with the name of the feed from where it was retrieved.
+func (s *service) getFeeds() []*atomEntry {
+	s.feedsMu.RLock()
+	feeds := s.feeds
+	s.feedsMu.RUnlock()
+
+	var res []*atomEntry
+	for n, feed := range feeds {
+		for _, entry := range feed.Entries {
+			e := *entry
+			e.Source = n
+			res = append(res, &e)
+		}
+	}
+	sort.Slice(res, func(i, j int) bool {
+		return res[j].Published.Before(res[i].Published)
+	})
+	return res
+}
diff --git a/hswaw/site/main.go b/hswaw/site/main.go
index f5e42d0..f8c6ae8 100644
--- a/hswaw/site/main.go
+++ b/hswaw/site/main.go
@@ -6,6 +6,7 @@
 	"mime"
 	"net/http"
 	"strings"
+	"sync"
 
 	"code.hackerspace.pl/hscloud/go/mirko"
 	"github.com/golang/glog"
@@ -18,6 +19,11 @@
 )
 
 type service struct {
+	// feeds is a map from atom feed name to atom feed. This is updated by a
+	// background worker.
+	feeds map[string]*atomFeed
+	// feedsMu locks the feeds field.
+	feedsMu sync.RWMutex
 }
 
 func main() {
@@ -30,6 +36,7 @@
 	}
 
 	s := &service{}
+	go s.feedWorker(mi.Context())
 
 	mux := http.NewServeMux()
 	s.registerHTTP(mux)
@@ -65,6 +72,7 @@
 
 func (s *service) registerHTTP(mux *http.ServeMux) {
 	mux.HandleFunc("/static/", s.handleHTTPStatic)
+	mux.HandleFunc("/", s.handleMain)
 	mux.HandleFunc("/about", s.handleAbout)
 	mux.HandleFunc("/about_en", s.handleAboutEn)
 }
diff --git a/hswaw/site/templates/main.html b/hswaw/site/templates/main.html
index 536ffa7..d7c372d 100644
--- a/hswaw/site/templates/main.html
+++ b/hswaw/site/templates/main.html
@@ -1,12 +1,14 @@
-{% extends 'basic.html' %}
-{% block page_scripts %}
+{{ define "page_scripts" }}
   <script type="text/javascript" src="https://widgets.twimg.com/j/2/widget.js"></script>
-{% endblock %}
-{% block page_style %}
+{{ end }}
+
+{{ define "page_style" }}
   <link rel="stylesheet" href="static/main.css"/>
-{% endblock %}
-{% block title %}Hackerspace Warszawa - strona główna{% endblock %}
-{% block content %}
+{{ end }}
+
+{{ define "title" }}Hackerspace Warszawa - strona główna{{ end }}
+
+{{ define "content" }}
   <div id="left">
     <div id="about">
       <h1>Czym jest Hackerspace?</h1>
@@ -21,25 +23,26 @@
     </div>
   <h1>Nowości</h1>
   <ul class="news">
-  {% for e in entries %}
-  <li class="{{e.tag}}">
-      <a class="news-title" href="{{e.link}}">
-        <h3><span class="news-rectangle">blog</span>{{e.title|safe}}</h3>
+  {{ range .Entries }}
+  <li class="{{ .Source }}">
+      <a class="news-title" href="{{ .Link.Href }}">
+        <h3><span class="news-rectangle">blog</span>{{ .Title }}</h3>
       </a>
       <p class="news">
-        {{e.summary|safe}}
+        {{ .Summary }}
       </p>
       <p class="news-footer">
-        Ostatnio aktualizowane {{e.updated_display}} przez {{e.author_detail.name or 'Anonymous'}}
+        Ostatnio aktualizowane {{ .UpdatedHuman }} przez {{ .Author }}
       </p>
     </li>
-  {% endfor %}
+  {{ end }}
   </ul>
   <h1>Kalendarz</h1>
-  <iframe style="max-width: 750px;width:100%;border: none;" height="315" src="https://owncloud.hackerspace.pl/index.php/apps/calendar/embed/g8toktZrA9fyAHNi"></iframe>
+  <i>borked ,-,</i>
+  <!-- TODO(q3k): fix this: <iframe style="max-width: 750px;width:100%;border: none;" height="315" src="https://owncloud.hackerspace.pl/index.php/apps/calendar/embed/g8toktZrA9fyAHNi"></iframe> -->
   </div>
   <div id="right">
-    {% include "subscribe.html" %}
+    <!-- TODO(q3k): add this {% include "subscribe.html" %} -->
     <h1 class="twitter">Twitter</h1>
     <script type="text/javascript">
     new TWTR.Widget({
@@ -71,4 +74,4 @@
     </script>
   </div>
   <span class="clear"><a href="#top">↑ Powrót na górę ↑</a></span>
-{% endblock %}
+{{ end }}
diff --git a/hswaw/site/views.go b/hswaw/site/views.go
index ae62322..8944e5d 100644
--- a/hswaw/site/views.go
+++ b/hswaw/site/views.go
@@ -42,6 +42,7 @@
 var (
 	tmplAbout   = template.Must(parseTemplates("basic", "about"))
 	tmplAboutEn = template.Must(parseTemplates("basic", "about_en"))
+	tmplMain    = template.Must(parseTemplates("basic", "main"))
 )
 
 // render attempts to render a given Go template with data into the HTTP
@@ -61,3 +62,10 @@
 func (s *service) handleAboutEn(w http.ResponseWriter, r *http.Request) {
 	render(w, tmplAboutEn, nil)
 }
+
+// handleMain handles rendering the main page at /.
+func (s *service) handleMain(w http.ResponseWriter, r *http.Request) {
+	render(w, tmplMain, map[string]interface{}{
+		"Entries": s.getFeeds(),
+	})
+}