aboutsummaryrefslogtreecommitdiff
path: root/pkg/app
diff options
context:
space:
mode:
authorMax Magorsch <arzano@gentoo.org>2020-06-19 15:51:41 +0200
committerMax Magorsch <arzano@gentoo.org>2020-06-19 15:51:41 +0200
commit21181c518cf41828917d36005b726f9452fde657 (patch)
tree38fab1b3c86a41383e48be6b2686d92efd86db62 /pkg/app
downloadarchives-21181c518cf41828917d36005b726f9452fde657.tar.gz
archives-21181c518cf41828917d36005b726f9452fde657.tar.bz2
archives-21181c518cf41828917d36005b726f9452fde657.zip
Initial version
Signed-off-by: Max Magorsch <arzano@gentoo.org>
Diffstat (limited to 'pkg/app')
-rw-r--r--pkg/app/home/home.go71
-rw-r--r--pkg/app/home/utils.go64
-rw-r--r--pkg/app/list/browse.go40
-rw-r--r--pkg/app/list/messages.go59
-rw-r--r--pkg/app/list/show.go44
-rw-r--r--pkg/app/list/threads.go60
-rw-r--r--pkg/app/list/utils.go137
-rw-r--r--pkg/app/message/show.go49
-rw-r--r--pkg/app/message/utils.go67
-rw-r--r--pkg/app/popular/threads.go14
-rw-r--r--pkg/app/popular/utils.go57
-rw-r--r--pkg/app/search/search.go98
-rw-r--r--pkg/app/search/utils.go90
-rw-r--r--pkg/app/serve.go60
14 files changed, 910 insertions, 0 deletions
diff --git a/pkg/app/home/home.go b/pkg/app/home/home.go
new file mode 100644
index 0000000..e2d3955
--- /dev/null
+++ b/pkg/app/home/home.go
@@ -0,0 +1,71 @@
+// Used to show the landing page of the application
+
+package home
+
+import (
+ "archives/pkg/app/popular"
+ "archives/pkg/config"
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "github.com/go-pg/pg/v10/orm"
+ "net/http"
+ "time"
+)
+
+// Show renders a template to show the landing page of the application
+func Show(w http.ResponseWriter, r *http.Request) {
+
+ var mailingLists []models.MailingList
+
+ for _, mailingList := range config.IndexMailingLists() {
+ var messages []*models.Message
+ database.DBCon.Model(&messages).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE '[` + mailingList[0] + `]%'`).
+ WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE 'Re: [` + mailingList[0] + `]%'`)
+ return q, nil
+ }).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`headers::jsonb->>'To' LIKE '%` + mailingList[0] + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + mailingList[0] + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'To' LIKE '%` + mailingList[0] + `@gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + mailingList[0] + `@gentoo.org%'`)
+ return q, nil
+ }).
+ Order("date DESC").
+ Limit(5).
+ Select()
+
+ mailingLists = append(mailingLists, models.MailingList{
+ Name: mailingList[0],
+ Description: mailingList[1],
+ Messages: messages,
+ })
+ }
+
+ //
+ // Get popular threads
+ //
+ popularThreads, err := popular.GetPopularThreads(10, "2020-06-01")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ if len(popularThreads) > 5 {
+ popularThreads = popularThreads[:5]
+ }
+
+ templateData := struct {
+ MailingLists []models.MailingList
+ PopularThreads models.Threads
+ MessageCount string
+ CurrentMonth string
+ }{
+ MailingLists: mailingLists,
+ PopularThreads: popularThreads,
+ MessageCount: formatMessageCount(getAllMessagesCount()),
+ CurrentMonth: time.Now().Format("2006-01"),
+ }
+
+ renderIndexTemplate(w, templateData)
+}
diff --git a/pkg/app/home/utils.go b/pkg/app/home/utils.go
new file mode 100644
index 0000000..daab2de
--- /dev/null
+++ b/pkg/app/home/utils.go
@@ -0,0 +1,64 @@
+// miscellaneous utility functions used for the landing page of the application
+
+package home
+
+import (
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "github.com/go-pg/pg/v10"
+ "html/template"
+ "net/http"
+ "strconv"
+)
+
+// renderIndexTemplate renders all templates used for the landing page
+func renderIndexTemplate(w http.ResponseWriter, templateData interface{}) {
+ templates := template.Must(
+ template.Must(
+ template.New("Show").
+ Funcs(template.FuncMap{
+ "makeMessage" : func(headers map[string][]string) models.Message {
+ return models.Message{
+ Headers: headers,
+ }
+ },
+ }).
+ ParseGlob("web/templates/layout/*.tmpl")).
+ ParseGlob("web/templates/home/*.tmpl"))
+
+ templates.ExecuteTemplate(w, "home.tmpl", templateData)
+}
+
+// utility methods
+
+func getAllMessagesCount() int {
+ var messsageCount int
+ database.DBCon.Model((*models.Message)(nil)).QueryOne(pg.Scan(&messsageCount), `
+ SELECT
+ count(DISTINCT messages.headers->>'Message-Id')
+ FROM
+ messages;
+ `)
+ return messsageCount
+}
+
+// formatMessageCount returns the formatted number of
+// messages containing a thousands comma
+func formatMessageCount(messageCount int) string {
+ packages := strconv.Itoa(messageCount)
+ if len(string(messageCount)) == 9 {
+ return packages[:3] + "," + packages[3:6] + "," + packages[6:]
+ } else if len(packages) == 8 {
+ return packages[:2] + "," + packages[2:5] + "," + packages[5:]
+ } else if len(packages) == 7 {
+ return packages[:1] + "," + packages[1:4] + "," + packages[4:]
+ } else if len(packages) == 6 {
+ return packages[:3] + "," + packages[3:]
+ } else if len(packages) == 5 {
+ return packages[:2] + "," + packages[2:]
+ } else if len(packages) == 4 {
+ return packages[:1] + "," + packages[1:]
+ } else {
+ return packages
+ }
+}
diff --git a/pkg/app/list/browse.go b/pkg/app/list/browse.go
new file mode 100644
index 0000000..7d046e6
--- /dev/null
+++ b/pkg/app/list/browse.go
@@ -0,0 +1,40 @@
+package list
+
+import (
+ "archives/pkg/config"
+ "archives/pkg/models"
+ "net/http"
+)
+
+func Browse(w http.ResponseWriter, r *http.Request) {
+
+ // Count number of messages in the current mailing lists
+ var currentMailingLists []models.MailingList
+ for _, listName := range config.CurrentMailingLists() {
+ messageCount, _ := countMessages(listName)
+ currentMailingLists = append(currentMailingLists, models.MailingList{
+ Name: listName,
+ MessageCount: messageCount,
+ })
+ }
+
+ // Count number of messages in the frozen archives
+ var frozenArchives []models.MailingList
+ for _, listName := range config.FrozenArchives() {
+ messageCount, _ := countMessages(listName)
+ frozenArchives = append(frozenArchives, models.MailingList{
+ Name: listName,
+ MessageCount: messageCount,
+ })
+ }
+
+ browseData := struct {
+ CurrentMailingLists []models.MailingList
+ FrozenArchives []models.MailingList
+ }{
+ CurrentMailingLists: currentMailingLists,
+ FrozenArchives: frozenArchives,
+ }
+
+ renderBrowseTemplate(w, browseData)
+}
diff --git a/pkg/app/list/messages.go b/pkg/app/list/messages.go
new file mode 100644
index 0000000..383e891
--- /dev/null
+++ b/pkg/app/list/messages.go
@@ -0,0 +1,59 @@
+package list
+
+import (
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "github.com/go-pg/pg/v10/orm"
+ "math"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+func Messages(w http.ResponseWriter, r *http.Request) {
+
+ urlParts := strings.Split(r.URL.Path, "/messages/")
+ if len(urlParts) != 2 {
+ http.NotFound(w, r)
+ return
+ }
+
+ listName := strings.ReplaceAll(urlParts[0], "/", "")
+
+ trailingUrlParts := strings.Split(urlParts[1], "/")
+ combinedDate := trailingUrlParts[0]
+ currentPage := 1
+ if len(trailingUrlParts) > 1 {
+ parsedCurrentPage, err := strconv.Atoi(trailingUrlParts[1])
+ if err == nil {
+ currentPage = parsedCurrentPage
+ }
+ }
+ offset := (currentPage - 1) * 50
+
+ var messages []*models.Message
+ query := database.DBCon.Model(&messages).
+ Column("id", "headers", "date").
+ Where("to_char(date, 'YYYY-MM') = ?", combinedDate).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE '[` + listName + `]%'`).
+ WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE 'Re: [` + listName + `]%'`)
+ return q, nil
+ }).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`headers::jsonb->>'To' LIKE '%` + listName + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + listName + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'To' LIKE '%` + listName + `@gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + listName + `@gentoo.org%'`)
+ return q, nil
+ }).
+ Order("date DESC")
+
+ messagesCount, _ := query.Count()
+ query.Limit(50).Offset(offset).Select()
+
+ maxPages := int(math.Ceil(float64(messagesCount) / float64(50)))
+
+ renderMessagesTemplate(w, listName, combinedDate, currentPage, maxPages, messages)
+
+}
diff --git a/pkg/app/list/show.go b/pkg/app/list/show.go
new file mode 100644
index 0000000..8db8778
--- /dev/null
+++ b/pkg/app/list/show.go
@@ -0,0 +1,44 @@
+package list
+
+import (
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "github.com/go-pg/pg/v10/orm"
+ "net/http"
+ "strings"
+)
+
+func Show(w http.ResponseWriter, r *http.Request) {
+
+ listName := strings.ReplaceAll(r.URL.Path, "/", "")
+
+ var res []struct {
+ CombinedDate string
+ MessageCount int
+ }
+ err := database.DBCon.Model((*models.Message)(nil)).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE '[` + listName + `]%'`).
+ WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE 'Re: [` + listName + `]%'`)
+ return q, nil
+ }).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`headers::jsonb->>'To' LIKE '%` + listName + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + listName + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'To' LIKE '%` + listName + `@gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + listName + `@gentoo.org%'`)
+ return q, nil
+ }).
+ ColumnExpr("to_char(date, 'YYYY-MM') AS combined_date").
+ ColumnExpr("count(*) AS message_count").
+ Group("combined_date").
+ Order("combined_date DESC").
+ Select(&res)
+
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ renderShowTemplate(w, listName, res)
+}
diff --git a/pkg/app/list/threads.go b/pkg/app/list/threads.go
new file mode 100644
index 0000000..33ade3c
--- /dev/null
+++ b/pkg/app/list/threads.go
@@ -0,0 +1,60 @@
+package list
+
+import (
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "github.com/go-pg/pg/v10/orm"
+ "math"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+func Threads(w http.ResponseWriter, r *http.Request) {
+
+ urlParts := strings.Split(r.URL.Path, "/threads/")
+ if len(urlParts) != 2 {
+ http.NotFound(w, r)
+ return
+ }
+
+ listName := strings.ReplaceAll(urlParts[0], "/", "")
+ trailingUrlParts := strings.Split(urlParts[1], "/")
+ combinedDate := trailingUrlParts[0]
+ currentPage := 1
+ if len(trailingUrlParts) > 1 {
+ parsedCurrentPage, err := strconv.Atoi(trailingUrlParts[1])
+ if err == nil {
+ currentPage = parsedCurrentPage
+ }
+ }
+ offset := (currentPage - 1) * 50
+
+ var messages []*models.Message
+ query := database.DBCon.Model(&messages).
+ Column("id", "headers", "date").
+ Where("to_char(date, 'YYYY-MM') = ?", combinedDate).
+ Where(`NOT headers::jsonb ? 'References'`).
+ Where(`NOT headers::jsonb ? 'In-Reply-To'`).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE '[` + listName + `]%'`).
+ WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE 'Re: [` + listName + `]%'`)
+ return q, nil
+ }).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`headers::jsonb->>'To' LIKE '%` + listName + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + listName + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'To' LIKE '%` + listName + `@gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + listName + `@gentoo.org%'`)
+ return q, nil
+ }).
+ Order("date DESC")
+
+ messagesCount, _ := query.Count()
+ query.Limit(50).Offset(offset).Select()
+
+ maxPages := int(math.Ceil(float64(messagesCount) / float64(50)))
+
+ renderThreadsTemplate(w, listName, combinedDate, currentPage, maxPages, messages)
+
+}
diff --git a/pkg/app/list/utils.go b/pkg/app/list/utils.go
new file mode 100644
index 0000000..07dbc65
--- /dev/null
+++ b/pkg/app/list/utils.go
@@ -0,0 +1,137 @@
+// miscellaneous utility functions used for the landing page of the application
+
+package list
+
+import (
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "github.com/go-pg/pg/v10/orm"
+ "html/template"
+ "net/http"
+)
+
+type ListData struct {
+ ListName string
+ Date string
+ CurrentPage int
+ MaxPages int
+ Messages []*models.Message
+}
+
+// renderIndexTemplate renders all templates used for the landing page
+func renderShowTemplate(w http.ResponseWriter, listName string, messageData interface{}) {
+ templates := template.Must(
+ template.Must(
+ template.New("Show").
+ ParseGlob("web/templates/layout/*.tmpl")).
+ ParseGlob("web/templates/list/*.tmpl"))
+
+ templteData := struct {
+ ListName string
+ MessageData interface{}
+ }{
+ ListName: listName,
+ MessageData: messageData,
+ }
+
+ templates.ExecuteTemplate(w, "show.tmpl", templteData)
+}
+
+// renderIndexTemplate renders all templates used for the landing page
+func renderMessagesTemplate(w http.ResponseWriter, listName string, date string, currentPage int, maxPages int, messages []*models.Message) {
+ templates := template.Must(
+ template.Must(
+ template.Must(
+ template.New("Show").
+ Funcs(getFuncMap()).
+ ParseGlob("web/templates/layout/*.tmpl")).
+ ParseGlob("web/templates/list/components/*.tmpl")).
+ ParseGlob("web/templates/list/*.tmpl"))
+
+ templates.ExecuteTemplate(w, "messages.tmpl", buildListData(listName, date, currentPage, maxPages, messages))
+}
+
+// renderIndexTemplate renders all templates used for the landing page
+func renderThreadsTemplate(w http.ResponseWriter, listName string, date string, currentPage int, maxPages int, messages []*models.Message) {
+ templates := template.Must(
+ template.Must(
+ template.Must(
+ template.New("Show").
+ Funcs(getFuncMap()).
+ ParseGlob("web/templates/layout/*.tmpl")).
+ ParseGlob("web/templates/list/components/*.tmpl")).
+ ParseGlob("web/templates/list/*.tmpl"))
+
+ templates.ExecuteTemplate(w, "threads.tmpl", buildListData(listName, date, currentPage, maxPages, messages))
+}
+
+// renderIndexTemplate renders all templates used for the landing page
+func renderBrowseTemplate(w http.ResponseWriter, lists interface{}) {
+ templates := template.Must(
+ template.Must(
+ template.New("Show").
+ ParseGlob("web/templates/layout/*.tmpl")).
+ ParseGlob("web/templates/list/*.tmpl"))
+
+ templates.ExecuteTemplate(w, "browse.tmpl", lists)
+}
+
+// utility methods
+
+func getFuncMap() template.FuncMap {
+ return template.FuncMap{
+ "min": func(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+ },
+ "max": func(a, b int) int {
+ if a < b {
+ return b
+ }
+ return a
+ },
+ "add": func(a, b int) int {
+ return a + b
+ },
+ "sub": func(a, b int) int {
+ return a - b
+ },
+ "makeRange": makeRange,
+ }
+}
+
+func buildListData(listName string, date string, currentPage int, maxPages int, messages []*models.Message) ListData {
+ return ListData{
+ ListName: listName,
+ Date: date,
+ CurrentPage: currentPage,
+ MaxPages: maxPages,
+ Messages: messages,
+ }
+}
+
+func makeRange(min, max int) []int {
+ a := make([]int, max-min+1)
+ for i := range a {
+ a[i] = min + i
+ }
+ return a
+}
+
+func countMessages(listName string) (int, error) {
+ return database.DBCon.Model((*models.Message)(nil)).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE '[` + listName + `]%'`).
+ WhereOr(`(headers::jsonb->>'Subject')::jsonb->>0 LIKE 'Re: [` + listName + `]%'`)
+ return q, nil
+ }).
+ WhereGroup(func(q *orm.Query) (*orm.Query, error) {
+ q = q.WhereOr(`headers::jsonb->>'To' LIKE '%` + listName + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + listName + `@lists.gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'To' LIKE '%` + listName + `@gentoo.org%'`).
+ WhereOr(`headers::jsonb->>'Cc' LIKE '%` + listName + `@gentoo.org%'`)
+ return q, nil
+ }).Count()
+}
diff --git a/pkg/app/message/show.go b/pkg/app/message/show.go
new file mode 100644
index 0000000..a027596
--- /dev/null
+++ b/pkg/app/message/show.go
@@ -0,0 +1,49 @@
+// Used to show the landing page of the application
+
+package message
+
+import (
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "net/http"
+ "strings"
+)
+
+// Show renders a template to show the landing page of the application
+func Show(w http.ResponseWriter, r *http.Request) {
+
+ urlParts := strings.Split(r.URL.Path, "/")
+ listName := urlParts[1]
+ messageHash := urlParts[len(urlParts)-1]
+
+ message := &models.Message{Id: messageHash}
+ err := database.DBCon.Select(message)
+
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ var inReplyTos []*models.Message
+ var inReplyTo *models.Message
+ if message.HasHeaderField("In-Reply-To") {
+ err = database.DBCon.Model(&inReplyTos).
+ Where(`(headers::jsonb->>'Message-Id')::jsonb ? '` + message.GetHeaderField("In-Reply-To") + `'`).
+ Select()
+ if err != nil || len(inReplyTos) < 1 {
+ inReplyTo = nil
+ } else {
+ inReplyTo = inReplyTos[0]
+ }
+ } else {
+ inReplyTo = nil
+ }
+
+ var replies []*models.Message
+ database.DBCon.Model(&replies).
+ Where(`(headers::jsonb->>'References')::jsonb ? '` + message.GetHeaderField("Message-Id") + `'`).
+ WhereOr(`(headers::jsonb->>'In-Reply-To')::jsonb ? '` + message.GetHeaderField("Message-Id") + `'`).
+ Order("date ASC").Select()
+
+ renderMessageTemplate(w, listName, message, inReplyTo, replies)
+}
diff --git a/pkg/app/message/utils.go b/pkg/app/message/utils.go
new file mode 100644
index 0000000..0cb40f5
--- /dev/null
+++ b/pkg/app/message/utils.go
@@ -0,0 +1,67 @@
+// miscellaneous utility functions used for the landing page of the application
+
+package message
+
+import (
+ "archives/pkg/models"
+ "html/template"
+ "net/http"
+ "strings"
+)
+
+// renderIndexTemplate renders all templates used for the landing page
+func renderMessageTemplate(w http.ResponseWriter, listName string, message *models.Message, inReplyTo *models.Message, replies []*models.Message) {
+ templates := template.Must(
+ template.Must(
+ template.New("Show").
+ Funcs(getFuncMap()).
+ ParseGlob("web/templates/layout/*.tmpl")).
+ ParseGlob("web/templates/message/*.tmpl"))
+
+ templateData := struct {
+ ListName string
+ Message *models.Message
+ InReplyTo *models.Message
+ Replies []*models.Message
+ }{
+ ListName: listName,
+ Message: message,
+ InReplyTo: inReplyTo,
+ Replies: replies,
+ }
+
+ templates.ExecuteTemplate(w, "show.tmpl", templateData)
+}
+
+func getFuncMap() template.FuncMap {
+ return template.FuncMap{
+ "formatAddr": func(addr string) string {
+ if strings.Contains(addr, "@lists.gentoo.org") || strings.Contains(addr, "@gentoo.org") {
+ addr = strings.ReplaceAll(addr, "@lists.gentoo.org", "@l.g.o")
+ addr = strings.ReplaceAll(addr, "@gentoo.org", "@g.o")
+ } else {
+ start := false
+ for i := len(addr) - 1; i > 0; i-- {
+ if addr[i] == '@' {
+ break
+ }
+ if start {
+ out := []rune(addr)
+ out[i] = '×'
+ addr = string(out)
+ }
+ if addr[i] == '.' {
+ start = true
+ }
+ }
+ }
+ return addr
+ },
+ }
+}
+
+func replaceAtIndex(in string, r rune, i int) string {
+ out := []rune(in)
+ out[i] = r
+ return string(out)
+}
diff --git a/pkg/app/popular/threads.go b/pkg/app/popular/threads.go
new file mode 100644
index 0000000..5c12c8a
--- /dev/null
+++ b/pkg/app/popular/threads.go
@@ -0,0 +1,14 @@
+package popular
+
+import (
+ "net/http"
+)
+
+func Threads(w http.ResponseWriter, r *http.Request) {
+ threads, err := GetPopularThreads(25, "2020-06-01")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ renderPopularThreads(w, threads)
+}
diff --git a/pkg/app/popular/utils.go b/pkg/app/popular/utils.go
new file mode 100644
index 0000000..6cfeff7
--- /dev/null
+++ b/pkg/app/popular/utils.go
@@ -0,0 +1,57 @@
+package popular
+
+import (
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "html/template"
+ "net/http"
+ "strconv"
+)
+
+// renderIndexTemplate renders all templates used for the landing page
+func renderPopularThreads(w http.ResponseWriter, templateData interface{}) {
+ templates := template.Must(
+ template.Must(
+ template.New("Popular").
+ Funcs(template.FuncMap{
+ "makeMessage" : func(headers map[string][]string) models.Message {
+ return models.Message{
+ Headers: headers,
+ }
+ },
+ }).
+ ParseGlob("web/templates/layout/*.tmpl")).
+ ParseGlob("web/templates/popular/*.tmpl"))
+
+ templates.ExecuteTemplate(w, "threads.tmpl", templateData)
+}
+
+// utility methods
+
+func GetPopularThreads(n int, date string) (models.Threads, error) {
+ var popularThreads models.Threads
+ err := database.DBCon.Model(&popularThreads).
+ TableExpr(`(SELECT id, headers, regexp_replace(regexp_replace(regexp_replace(regexp_replace(headers::jsonb->>'Subject','^\["',''),'"\]$',''),'^Re:\s',''), '^\[.*\]', '') AS c FROM messages WHERE date >= '2020-06-12'::date) t`).
+ ColumnExpr(`c as Subject, jsonb_agg(id)->>0 as Id, jsonb_agg(headers)->>0 as Headers, Count(*) as Count`).
+ GroupExpr(`c`).
+ OrderExpr(`count DESC`).
+ Limit(n).
+ Select()
+
+ return popularThreads, err
+}
+
+func GetMessagesFromPopularThreads(threads models.Threads) []*models.Message {
+ var popularThreads []*models.Message
+ for _, thread := range threads {
+ var messages []*models.Message
+ err := database.DBCon.Model(&messages).
+ Where(`headers::jsonb->>'Subject' LIKE '%` + thread.Id + `%'`).
+ Select()
+ if err == nil && len(messages) > 0 {
+ messages[0].Comment = strconv.Itoa(thread.Count)
+ popularThreads = append(popularThreads, messages[0])
+ }
+ }
+ return popularThreads
+}
diff --git a/pkg/app/search/search.go b/pkg/app/search/search.go
new file mode 100644
index 0000000..f6498e9
--- /dev/null
+++ b/pkg/app/search/search.go
@@ -0,0 +1,98 @@
+package search
+
+import (
+ "archives/pkg/config"
+ "archives/pkg/database"
+ "archives/pkg/models"
+ "math"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+func Search(w http.ResponseWriter, r *http.Request) {
+
+ //
+ // Parse search params
+ //
+ searchTerm := getParameterValue("q", r)
+ showThreads := getParameterValue("threads", r) != ""
+ page, err := strconv.Atoi(getParameterValue("page", r))
+ var currentPage int
+ var offset int
+
+ if err != nil {
+ currentPage = 1
+ offset = 0
+ } else {
+ currentPage = page
+ offset = 50 * (page - 1)
+ }
+
+ //
+ // Step 1: Search for List with the same name and redirect
+ //
+ for _, list := range config.AllPublicMailingLists() {
+ if strings.TrimSpace(searchTerm) == list {
+ http.Redirect(w, r, "/"+list+"/", http.StatusMovedPermanently)
+ return
+ }
+ }
+
+ //
+ // Step 2: Search by Author
+ //
+ var searchResults []*models.Message
+ query := database.DBCon.Model(&searchResults).
+ WhereOr(`headers::jsonb->>'From' LIKE ?`, "%"+searchTerm+"%").
+ Order("date DESC")
+ if showThreads {
+ query = query.Where(`NOT headers::jsonb ? 'References'`).Where(`NOT headers::jsonb ? 'In-Reply-To'`)
+ }
+
+ messagesCount, _ := query.Count()
+ err = query.Limit(50).Offset(offset).Select()
+
+ if err == nil && messagesCount > 0 && strings.TrimSpace(searchTerm) != "gentoo" {
+ maxPages := int(math.Ceil(float64(messagesCount) / float64(50)))
+ renderSearchTemplate(w, showThreads, searchTerm, messagesCount, currentPage, maxPages, searchResults)
+ return
+ }
+
+ //
+ // Step 3: Search by Subject
+ //
+ query = database.DBCon.Model(&searchResults).
+ Where(`tsv_subject @@ to_tsquery(''?'')`, searchTerm)
+ if showThreads {
+ query = query.Where(`NOT headers::jsonb ? 'References'`).Where(`NOT headers::jsonb ? 'In-Reply-To'`)
+ }
+
+ messagesCount, _ = query.Count()
+ err = query.Limit(50).Offset(offset).Select()
+
+ if err == nil && messagesCount > 0 {
+ maxPages := int(math.Ceil(float64(messagesCount) / float64(50)))
+ renderSearchTemplate(w, showThreads, searchTerm, messagesCount, currentPage, maxPages, searchResults)
+ return
+ }
+
+ //
+ // Step 4: Search by Message Body
+ //
+ query = database.DBCon.Model(&searchResults).
+ Where(`tsv_body @@ to_tsquery(''?'')`, searchTerm)
+ if showThreads {
+ query = query.Where(`NOT headers::jsonb ? 'References'`).Where(`NOT headers::jsonb ? 'In-Reply-To'`)
+ }
+
+ messagesCount, _ = query.Count()
+ err = query.Limit(50).Offset(offset).Select()
+
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ maxPages := int(math.Ceil(float64(messagesCount) / float64(50)))
+ renderSearchTemplate(w, showThreads, searchTerm, messagesCount, currentPage, maxPages, searchResults)
+}
diff --git a/pkg/app/search/utils.go b/pkg/app/search/utils.go
new file mode 100644
index 0000000..0fa4285
--- /dev/null
+++ b/pkg/app/search/utils.go
@@ -0,0 +1,90 @@
+package search
+
+import (
+ "archives/pkg/models"
+ "html/template"
+ "net/http"
+)
+
+type SearchData struct {
+ SearchQuery string
+ ShowThreads bool
+ SearchResultsCount int
+ CurrentPage int
+ MaxPages int
+ Messages []*models.Message
+}
+
+// renderIndexTemplate renders all templates used for the landing page
+func renderSearchTemplate(w http.ResponseWriter, showThreads bool, searchQuery string, messagesCount int, currentPage int, maxPages int, messages []*models.Message) {
+ templates := template.Must(
+ template.Must(
+ template.Must(
+ template.New("Show").
+ Funcs(getFuncMap()).
+ ParseGlob("web/templates/layout/*.tmpl")).
+ ParseGlob("web/templates/search/components/pagination.tmpl")).
+ ParseGlob("web/templates/search/*.tmpl"))
+
+ templates.ExecuteTemplate(w, "searchresults.tmpl", buildSearchData(showThreads, searchQuery, messagesCount, currentPage, maxPages, messages))
+}
+
+// utility methods
+
+func getFuncMap() template.FuncMap {
+ return template.FuncMap{
+ "min": func(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+ },
+ "max": func(a, b int) int {
+ if a < b {
+ return b
+ }
+ return a
+ },
+ "add": func(a, b int) int {
+ return a + b
+ },
+ "sub": func(a, b int) int {
+ return a - b
+ },
+ "mul": func(a, b int) int {
+ return a * b
+ },
+ "makeRange": makeRange,
+ }
+}
+
+func buildSearchData(showThreads bool, searchQuery string, messagesCount int, currentPage int, maxPages int, messages []*models.Message) SearchData {
+ return SearchData{
+ SearchQuery: searchQuery,
+ ShowThreads: showThreads,
+ SearchResultsCount: messagesCount,
+ CurrentPage: currentPage,
+ MaxPages: maxPages,
+ Messages: messages,
+ }
+}
+
+func makeRange(min, max int) []int {
+ a := make([]int, max-min+1)
+ for i := range a {
+ a[i] = min + i
+ }
+ return a
+}
+
+// getParameterValue returns the value of a given parameter
+func getParameterValue(parameterName string, r *http.Request) string {
+ results, ok := r.URL.Query()[parameterName]
+ if !ok {
+ return ""
+ }
+ if len(results) == 0 {
+ return ""
+ }
+ return results[0]
+}
diff --git a/pkg/app/serve.go b/pkg/app/serve.go
new file mode 100644
index 0000000..62eac33
--- /dev/null
+++ b/pkg/app/serve.go
@@ -0,0 +1,60 @@
+// Entrypoint for the web application
+
+package app
+
+import (
+ "archives/pkg/app/home"
+ "archives/pkg/app/list"
+ "archives/pkg/app/message"
+ "archives/pkg/app/popular"
+ "archives/pkg/app/search"
+ "archives/pkg/config"
+ "fmt"
+ "log"
+ "net/http"
+)
+
+// Serve is used to serve the web application
+func Serve() {
+
+ fmt.Println("Serving on Port " + config.Port())
+
+ for _, mailingList := range config.AllPublicMailingLists() {
+ setRoute("/"+mailingList+"/message/", message.Show)
+ setRoute("/"+mailingList+"/messages/", list.Messages)
+ setRoute("/"+mailingList+"/threads/", list.Threads)
+ setRoute("/"+mailingList+"/", list.Show)
+ }
+
+ setRoute("/lists", list.Browse)
+
+ setRoute("/popular", popular.Threads)
+
+ setRoute("/search", search.Search)
+
+ setRoute("/", home.Show)
+
+ fs := http.StripPrefix("/assets/", http.FileServer(http.Dir("assets")))
+ http.Handle("/assets/", fs)
+
+ log.Fatal(http.ListenAndServe(":"+config.Port(), nil))
+
+}
+
+// define a route using the default middleware and the given handler
+func setRoute(path string, handler http.HandlerFunc) {
+ http.HandleFunc(path, mw(handler))
+}
+
+// mw is used as default middleware to set the default headers
+func mw(handler http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ setDefaultHeaders(w)
+ handler(w, r)
+ }
+}
+
+// setDefaultHeaders sets the default headers that apply for all pages
+func setDefaultHeaders(w http.ResponseWriter) {
+ w.Header().Set("Cache-Control", config.CacheControl())
+}