archive/ArchiveState.go

480 lines
13 KiB
Go

package archive
import (
"bufio"
"errors"
"fmt"
"html"
"io/ioutil"
"math"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"time"
)
const (
pageNotSet int = -1
)
type ArchiveState struct {
svr *ArchiveServer
log *LogSource
logBestSlug string
query string
queryIsRegex bool
isStats bool
ym YearMonth
page int
highestPage int
}
func NewArchiveState(svr *ArchiveServer) *ArchiveState {
return &ArchiveState{
svr: svr,
page: pageNotSet,
}
}
func (this *ArchiveState) showPageURLs() bool {
return this.log != nil && len(this.query) == 0 && !this.isStats
}
func (this *ArchiveState) URL() string {
if this.isStats {
return fmt.Sprintf(`/%s/stats`, this.logBestSlug)
} else if len(this.query) > 0 {
if this.queryIsRegex {
return fmt.Sprintf(`/%s/rx/%s`, this.logBestSlug, url.QueryEscape(this.query))
} else {
return fmt.Sprintf(`/%s/search/%s`, this.logBestSlug, url.QueryEscape(this.query))
}
} else {
if this.page == pageNotSet {
return fmt.Sprintf(`/%s/%s/%s`, this.logBestSlug, this.ym.Year, this.ym.Month)
} else {
return fmt.Sprintf(`/%s/%s/%s/page-%d`, this.logBestSlug, this.ym.Year, this.ym.Month, this.page)
}
}
}
func (this *ArchiveState) selectSource(log *LogSource, slug string) {
this.log = log
this.logBestSlug = slug
}
// renderView renders a single page of log entries.
// - Mandatory: log, ym
// - Optional: page
func (this *ArchiveState) renderView(w http.ResponseWriter) {
fname, err := this.svr.LogFile(this.log, this.ym)
if err != nil {
this.renderError(w, err.Error())
return
}
fc, err := ioutil.ReadFile(fname)
if err != nil {
this.renderError(w, err.Error())
return
}
lines := strings.Split(string(fc), "\n")
// Work around #newpage: if the last line is blank, skip
if len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
lines = lines[:len(lines)-1]
}
this.highestPage = int(math.Ceil(float64(len(lines))/float64(this.svr.cfg.LinesPerPage))) - 1
if this.page == pageNotSet || this.page > this.highestPage {
this.page = this.highestPage
}
if this.page < 0 {
this.page = 0
}
startLine := this.svr.cfg.LinesPerPage * this.page
endLine := this.svr.cfg.LinesPerPage * (this.page + 1)
if endLine > len(lines) {
endLine = len(lines)
}
output := ""
for i := startLine; i < endLine; i += 1 {
output += html.EscapeString(lines[i]) + "\n"
}
this.renderTemplate(w, []byte(output))
}
// parseStatsFor loads user post statistics for a single yearMonth in a single log source.
func (this *ArchiveState) parseStatsFor(ls *LogSource, ym YearMonth, into map[string]int) error {
fname, err := this.svr.LogFile(ls, ym)
if err != nil {
return err
}
fc, err := ioutil.ReadFile(fname)
if err != nil {
return err
}
rxUser := regexp.MustCompile(`(?ms)^[^<\r\n]*<([^>\r\n]+)>.+?$`)
matches := rxUser.FindAllSubmatch(fc, -1)
if matches == nil || len(matches) == 0 {
return errors.New("No matches")
}
for _, match := range matches {
username := string(match[1])
if ct, ok := into[username]; ok {
into[username] = ct + 1
} else {
into[username] = 1
}
}
return nil
}
func (this *ArchiveState) renderStats(w http.ResponseWriter) {
// Lines per year
// Users / posts/year
startTime := time.Now()
yearsToUsersToPostCount := make(map[int]map[string]int, 0)
totalErrors := 0
var lastError error = nil
ym := this.log.EarliestDate()
orderedYears := make([]int, 0)
for {
usersToPostCount, ok := yearsToUsersToPostCount[ym.Year]
if !ok {
usersToPostCount = make(map[string]int)
orderedYears = append(orderedYears, ym.Year)
}
err := this.parseStatsFor(this.log, ym, usersToPostCount)
if err != nil {
//log.Printf("Stats(%s): %s", this.logBestSlug, err.Error())
totalErrors += 1
lastError = err
}
//fmt.Printf("%#v\n", usersToPostCount)
yearsToUsersToPostCount[ym.Year] = usersToPostCount
if ym.Equals(this.log.LatestDate()) {
break
}
ym = ym.Next()
}
ret := make([]byte, 0)
if lastError != nil {
ret = append(ret, []byte(fmt.Sprintf("<em>Got %d errors, including: '%s'</em>\n\n", totalErrors, lastError.Error()))...)
}
//
allUsersExistence := make(map[string]struct{})
for _, usersMap := range yearsToUsersToPostCount {
for username, _ := range usersMap {
allUsersExistence[username] = struct{}{}
}
}
allUsernames := make([]string, 0, len(allUsersExistence))
for username, _ := range allUsersExistence {
allUsernames = append(allUsernames, username)
}
sort.Strings(allUsernames)
//
ret = append(ret, []byte(`<table><thead><tr><th>&nbsp;</th>`)...)
for _, year := range orderedYears {
ret = append(ret, []byte(fmt.Sprintf(`<th>%d</th>`, year))...)
}
ret = append(ret, []byte("</tr><tbody>\n")...)
for _, username := range allUsernames {
ret = append(ret, []byte(fmt.Sprintf(`<tr><td>%s</td>`, html.EscapeString(username)))...)
for _, year := range orderedYears {
usersMap := yearsToUsersToPostCount[year]
posts, _ /*ok*/ := usersMap[username]
ret = append(ret, []byte(fmt.Sprintf(`<td>%d</td>`, posts))...)
}
ret = append(ret, []byte("</tr>\n")...)
}
ret = append(ret, []byte(`</tbody><tfoot><th style="text-align:right;">TOTAL:</th>`)...)
for _, year := range orderedYears {
postsTotalForYear := 0
for _, userPostCount := range yearsToUsersToPostCount[year] {
postsTotalForYear += userPostCount
}
ret = append(ret, []byte(fmt.Sprintf(`<th>%d</th>`, postsTotalForYear))...)
}
duration := time.Now().Sub(startTime)
ret = append(ret, []byte(fmt.Sprintf("</tfoot></table>\n\n<em>%d total users\nStatistics generated in %s</em>", len(allUsernames), duration.String()))...)
this.renderTemplate(w, ret)
}
// renderSearch renders the search results.
// - Mandatory: log, query, queryIsRegex
func (this *ArchiveState) renderSearch(w http.ResponseWriter) {
var matcher func(line string) bool
if this.queryIsRegex {
rx, err := regexp.Compile(`(?i)` + this.query)
if err != nil {
this.renderError(w, "Invalid regular expression "+this.query+" ("+err.Error()+")")
return
}
matcher = rx.MatchString
} else {
queryLower := strings.ToLower(this.query)
matcher = func(line string) bool {
return strings.Contains(strings.ToLower(line), queryLower)
}
}
this.renderTemplateHead(w)
totalResults := 0
w.Write([]byte(`<ul class="search-results">`))
limit := this.log.LatestDate().Next() // one off the end
for ympair := this.log.EarliestDate(); !ympair.Equals(limit); ympair = ympair.Next() {
fname, err := this.svr.LogFile(this.log, ympair)
if err != nil {
continue // no log exists for this ym
}
fh, err := os.Open(fname)
if err != nil {
continue // can't open this log file
}
func() {
defer fh.Close()
scanner := bufio.NewScanner(fh)
for i := 0; scanner.Scan(); i += 1 {
if !matcher(scanner.Text()) {
continue
}
totalResults += 1
page := i / this.svr.cfg.LinesPerPage
lineNo := i % this.svr.cfg.LinesPerPage
url := fmt.Sprintf(`/%s/%d/%d/page-%d#line-%d`, this.logBestSlug, ympair.Year, ympair.Month, page, lineNo)
w.Write([]byte(`<li><a href="` + html.EscapeString(url) + `">&raquo;</a> ` + html.EscapeString(scanner.Text()) + `</li>`))
}
}()
}
w.Write([]byte(`</ul>`))
if totalResults == 0 {
w.Write([]byte(`No search results for &quot;<em>` + html.EscapeString(this.query) + `</em>&quot;`))
} else {
w.Write([]byte(`<br><em>Found ` + fmt.Sprintf("%d", totalResults) + ` total result(s).</em><br><br>`))
}
this.renderTemplateFoot(w)
}
// renderError renders a plain text string, escaping it for HTML use.
func (this *ArchiveState) renderError(w http.ResponseWriter, msg string) {
this.renderTemplate(w, []byte(html.EscapeString(msg)))
}
func (this *ArchiveState) renderTemplate(w http.ResponseWriter, body []byte) {
this.renderTemplateHead(w)
w.Write(body)
this.renderTemplateFoot(w)
}
func (this *ArchiveState) renderTemplateHead(w http.ResponseWriter) {
w.Header().Set(`Content-Type`, `text/html; charset=UTF-8`)
w.Header().Set(`X-UA-Compatible`, `IE=Edge`)
w.WriteHeader(200)
title := `Archives`
if this.log != nil {
title = this.log.Description + ` Archives`
}
latestUrl := `/`
if this.log != nil {
latestUrl = fmt.Sprintf(`/%s/%d/%d`, url.PathEscape(this.logBestSlug), this.log.LatestDate().Year, this.log.LatestDate().Month)
}
statsUrl := fmt.Sprintf(`/%s/stats`, url.PathEscape(this.logBestSlug))
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>` + html.EscapeString(title) + `</title>
<link rel="stylesheet" type="text/css" href="/style.css?nonce=` + fmt.Sprintf("%d", this.svr.startup.Unix()) + `">
</head>
<body>
<div class="layout-top nav">
<div id="menu-container" class="noselect">
<div id="tr1"></div>
<div id="tr2"></div>
<div class="ddmenu">
<a href="` + html.EscapeString(latestUrl) + `">Latest</a>
<a href="` + html.EscapeString(statsUrl) + `">Statistics</a>
<a onclick="fontSize(1);">Font increase</a>
<a onclick="fontSize(-1);">Font decrease</a>
<a href="/download" onclick="return confirm('Are you sure you want to download a backup?');">Download backup</a>
</div>
</div>
<div id="logo" class="layout-pushdown"><svg viewBox="0 0 24 24">
<path fill="#000000" d="M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z" />
</svg></div>
<span class="area-nav">
<select onchange="window.location.pathname = this.value;">
`))
for i, h := range this.svr.cfg.Logs {
slug, _ := this.svr.bestSlugFor(&this.svr.cfg.Logs[i])
current := (this.log == &this.svr.cfg.Logs[i])
targetUri := `/` + url.PathEscape(slug) + `/`
if len(this.query) > 0 {
if this.queryIsRegex {
targetUri += `rx/` + url.PathEscape(this.query)
} else {
targetUri += `search/` + url.PathEscape(this.query)
}
} else {
targetUri += fmt.Sprintf(`%d/%d`, h.LatestDate().Year, h.LatestDate().Month)
}
w.Write([]byte(fmt.Sprintf("\t\t\t\t\t<option value=\"%s\"%s>%s</option>\n", html.EscapeString(targetUri), attr(current, " selected"), html.EscapeString(h.Description))))
}
w.Write([]byte(`
</select>
`))
if this.showPageURLs() {
w.Write([]byte(`
<select onchange="window.location.pathname = this.value;">
`))
// Generate month dropdown options
lastY := -1
limit := this.log.LatestDate().Next() // one off the end
for ympair := this.log.EarliestDate(); !ympair.Equals(limit); ympair = ympair.Next() {
if ympair.Year != lastY {
if lastY != -1 {
w.Write([]byte("\t\t\t\t\t</optgroup>\n"))
}
w.Write([]byte(fmt.Sprintf("\t\t\t\t\t<optgroup label=\"%d\">\n", ympair.Year)))
lastY = ympair.Year
}
targetUri := fmt.Sprintf(`/%s/%d/%d`, this.logBestSlug, ympair.Year, ympair.Month)
w.Write([]byte(fmt.Sprintf("\t\t\t\t\t\t<option value=\"%s\"%s>%s</option>\n", html.EscapeString(targetUri), attr(ympair.Equals(this.ym), " selected"), html.EscapeString(ympair.Month.String()))))
}
if lastY != -1 {
w.Write([]byte("\t\t\t\t\t</optgroup>\n"))
}
w.Write([]byte(`
</select>
<div class="mini-separator layout-pushdown"></div>
`))
//
pageBase := fmt.Sprintf(`/%s/%d/%d`, this.logBestSlug, this.ym.Year, this.ym.Month)
previousPage := this.page - 1
if previousPage < 0 {
previousPage = 0
}
nextPage := this.page + 1
if nextPage > this.highestPage {
nextPage = this.highestPage
}
prevMonth := this.ym.Prev() // FIXME check bounds
prevMonthLink := fmt.Sprintf(`/%s/%d/%d`, this.logBestSlug, prevMonth.Year, prevMonth.Month)
nextMonth := this.ym.Next() // FIXME check bounds
nextMonthLink := fmt.Sprintf(`/%s/%d/%d`, this.logBestSlug, nextMonth.Year, nextMonth.Month)
w.Write([]byte(`
<a style="display:none;" id="monthprev" accesskey="j" href="` + html.EscapeString(prevMonthLink) + `">
<a style="display:none;" id="monthnext" accesskey="k" href="` + html.EscapeString(nextMonthLink) + `">
<a class="btn" href="` + pageBase + `/page-0">&laquo;</a>
<a class="btn" accesskey="h" title="Previous page (Alt+H)" href="` + pageBase + `/page-` + fmt.Sprintf("%d", previousPage) + `">&lsaquo;</a>
<span class="current-page">` + fmt.Sprintf("%d", this.page) + `</span>
<a class="btn" accesskey="l" title="Next page (Alt+L)" href="` + pageBase + `/page-` + fmt.Sprintf("%d", nextPage) + `">&rsaquo;</a>
<a class="btn" href="` + pageBase + `">&raquo;</a>
`))
}
w.Write([]byte(`
</span>
<span class="area-search">
<form method="GET" id="search-form">
<input type="hidden" name="h" value="` + html.EscapeString(this.logBestSlug) + `">
<input type="text" id="searchbox" name="q" value="` + html.EscapeString(this.query) + `" placeholder="Search..." accesskey="m" title="Search (Alt+M)">
<input type="submit" value="&raquo;">
<input type="checkbox" class="layout-pushdown" name="rx" value="1" title="Regular Expression" ` + attr(this.queryIsRegex, "checked") + `>
</form>
</span>
</div>
<div class="layout-body" id="chatarea">`,
))
// Header ends
}
func (this *ArchiveState) renderTemplateFoot(w http.ResponseWriter) {
w.Write([]byte(`</div>
<script type="text/javascript" src="/archive.js?nonce=` + fmt.Sprintf("%d", this.svr.startup.Unix()) + `"></script>
</body>
</html>
`))
}