]> Sergey Matveev's repositories - sgblog.git/commitdiff
Topics support v0.8.0
authorSergey Matveev <stargrave@stargrave.org>
Sat, 26 Sep 2020 09:14:56 +0000 (12:14 +0300)
committerSergey Matveev <stargrave@stargrave.org>
Sat, 26 Sep 2020 09:20:47 +0000 (12:20 +0300)
README.texi
cmd/sgblog/atom/atom.go [new file with mode: 0644]
cmd/sgblog/gopher.go
cmd/sgblog/http.go
cmd/sgblog/main.go
cmd/sgblog/topics.go [new file with mode: 0644]
common.go

index c2b5887c45acee02d80385d178c2ba9b72caed94..a0309d81dd4dd38677b4b8b27845d510b8d1e2d1 100644 (file)
@@ -25,6 +25,7 @@ Its main competitive features:
 @item Uses @url{https://en.wikipedia.org/wiki/Inetd, inetd} interface
     for working as @url{https://en.wikipedia.org/wiki/Gopher_(protocol), Gopher}
     server
+@item Topics (tags/categories) support
 @item Supports on the fly generation of
     @url{https://en.wikipedia.org/wiki/Atom_(feed), Atom} feeds
     for posts, comments and per-post comments
@@ -33,9 +34,9 @@ Its main competitive features:
     copy of your blog/phlog!
 @end itemize
 
-All of that, except for comments and phlog, could be achieved with some
-Git viewer like @url{https://git.zx2c4.com/cgit/about/, cgit}. But
-SGBlog also is able to:
+All of that, except for comments, topics and phlog, could be achieved
+with some Git viewer like @url{https://git.zx2c4.com/cgit/about/, cgit}.
+But SGBlog also is able to:
 
 @itemize
 @item Convert URLs to clickable links
@@ -56,6 +57,7 @@ see the file COPYING for copying conditions.
 
 @menu
 * Comments::
+* Topics::
 * Installation::
 * Configuration::
 @end menu
@@ -67,7 +69,7 @@ Comments are posted through the email interface, just by sending the
 message to special address. For example:
 
 @example
-mutt "mailto:comment@@blog.example.com?subject=576540a5b98517b46d0efc791bb90b9121bf147e" <<EOF
+mutt "mailto:comment@@blog.example.com?subject=576540a5b98517b46d0efc791bb90b9121bf147e" <<EOF
 This is the comments contents.
 Could be multilined of course.
 EOF
@@ -86,6 +88,18 @@ Technically comments are stored in concatenated
 are accepted and only with UTF-8, US-ASCII, ISO-8859-1 character sets.
 Sane people won't send HTML email anyway, but this is just a precaution.
 
+@node Topics
+@unnumbered Topics
+
+Each post can have any number of attached topics (also known as tags or
+categories). They are whitespace separated single words kept in separate
+@url{https://git-scm.com/docs/git-notes, note} namespace. You can
+add/change comments with commands like:
+
+@example
+$ git notes --ref=topics add -m "linux hate" @@
+@end example
+
 @node Installation
 @unnumbered Installation
 
@@ -191,6 +205,11 @@ optional fields:
   CommentsNotesRef: refs/notes/comments
   # Display link for comment writing, if email is set
   CommentsEmail: something@@example.com
+
+  # If that ref is set, then topics will be loaded from it
+  TopicsNotesRef: refs/notes/topics
+  # Optional file for topics state caching
+  TopicsCachePath: /path/to/sgblog-topics-cache.gob
 @}
 @end example
 
@@ -210,6 +229,10 @@ options:
   # Both are optional
   CommentsNotesRef: refs/notes/comments
   CommentsEmail: something@@example.com
+
+  # Both are optional too
+  TopicsNotesRef: refs/notes/topics
+  TopicsCachePath: /path/to/sgblog-topics-cache.gob
 @}
 @end example
 
diff --git a/cmd/sgblog/atom/atom.go b/cmd/sgblog/atom/atom.go
new file mode 100644 (file)
index 0000000..50ef9a3
--- /dev/null
@@ -0,0 +1,66 @@
+// Copyright 2009 The Go Authors.  All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Adapted from encoding/xml/read_test.go.
+
+// Package atom defines XML data structures for an Atom feed.
+package atom // import "golang.org/x/tools/blog/atom"
+
+import (
+       "encoding/xml"
+       "time"
+)
+
+type Feed struct {
+       XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
+       Title   string   `xml:"title"`
+       ID      string   `xml:"id"`
+       Link    []Link   `xml:"link"`
+       Updated TimeStr  `xml:"updated"`
+       Author  *Person  `xml:"author"`
+       Entry   []*Entry `xml:"entry"`
+}
+
+type Entry struct {
+       Title     string     `xml:"title"`
+       ID        string     `xml:"id"`
+       Link      []Link     `xml:"link"`
+       Published TimeStr    `xml:"published"`
+       Updated   TimeStr    `xml:"updated"`
+       Author    *Person    `xml:"author"`
+       Summary   *Text      `xml:"summary"`
+       Content   *Text      `xml:"content"`
+       Category  []Category `xml:"category,omitempty"`
+}
+
+type Link struct {
+       Rel      string `xml:"rel,attr,omitempty"`
+       Href     string `xml:"href,attr"`
+       Type     string `xml:"type,attr,omitempty"`
+       HrefLang string `xml:"hreflang,attr,omitempty"`
+       Title    string `xml:"title,attr,omitempty"`
+       Length   uint   `xml:"length,attr,omitempty"`
+}
+
+type Person struct {
+       Name     string `xml:"name"`
+       URI      string `xml:"uri,omitempty"`
+       Email    string `xml:"email,omitempty"`
+       InnerXML string `xml:",innerxml"`
+}
+
+type Text struct {
+       Type string `xml:"type,attr"`
+       Body string `xml:",chardata"`
+}
+
+type TimeStr string
+
+func Time(t time.Time) TimeStr {
+       return TimeStr(t.Format("2006-01-02T15:04:05-07:00"))
+}
+
+type Category struct {
+       Term string `xml:"term,attr"`
+}
index 3cc50e1a62d978a5422bb34c784f95233c9df7a6..86fb5692d81ea055c8b16cf98aac7979007c267e 100644 (file)
@@ -40,21 +40,25 @@ import (
 const (
        TmplGopherMenu = `{{$CR := printf "\r"}}{{$CRLF := printf "\r\n" -}}
 {{- define "domainPort" }}     {{.GopherDomain}}       70{{end}}{{$Cfg := .Cfg -}}
-i{{.Cfg.Title}} ({{.Offset}}-{{.OffsetNext}})  err{{template "domainPort" .Cfg}}{{$CRLF -}}
+i{{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})  err{{template "domainPort" .Cfg}}{{$CRLF -}}
 {{- if .Cfg.AboutURL}}hAbout   URL:{{.Cfg.AboutURL}}{{template "domainPort" .Cfg}}{{$CRLF}}{{end -}}
-{{- if .Offset}}1Prev  offset/{{.OffsetPrev}}{{template "domainPort" .Cfg}}{{$CRLF}}{{end -}}
-{{- if not .LogEnded}}1Next    offset/{{.OffsetNext}}{{template "domainPort" .Cfg}}{{$CRLF}}{{end -}}
+{{- if .Offset}}1Prev  {{if .Topic}}{{.Topic}}/{{end}}offset/{{.OffsetPrev}}{{template "domainPort" .Cfg}}{{$CRLF}}{{end -}}
+{{- if not .LogEnded}}1Next    {{if .Topic}}{{.Topic}}/{{end}}offset/{{.OffsetNext}}{{template "domainPort" .Cfg}}{{$CRLF}}{{end -}}
 {{- $datePrev := "0001-01-01" -}}
 {{- range .Entries -}}
 {{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
 {{- if ne $dateCur $datePrev}}{{$datePrev = $dateCur}}
 i{{$dateCur}}  err{{template "domainPort" $Cfg}}{{$CR}}{{end}}
-0[{{.Commit.Author.When.Format "15:04"}}] {{.Title}} ({{.LinesNum}}L){{with .CommentsNum}} ({{.}}C){{end}}     /{{.Commit.Hash.String}}{{template "domainPort" $Cfg}}{{$CR}}{{end}}
+0[{{.Commit.Author.When.Format "15:04"}}] {{.Title}} ({{.LinesNum}}L){{with .CommentsNum}} ({{.}}C){{end}}{{if .Topics}}{{range .Topics}} {{.}}{{end}}{{end}}  /{{.Commit.Hash.String}}{{template "domainPort" $Cfg}}{{$CR}}{{end}}
+{{range .Topics}}
+1Topic: {{.}}  {{.}}/offset/0{{template "domainPort" $Cfg}}{{$CR}}{{end}}
 iGenerated by: SGBlog {{.Version}}     err{{template "domainPort" .Cfg}}{{$CR}}
 .{{$CRLF}}`
        TmplGopherEntry = `What: {{.Commit.Hash.String}}
 When: {{.When}}
 ------------------------------------------------------------------------
+{{if .Topics}}Topics:{{range .Topics}} {{.}}{{end}}{{end}}
+------------------------------------------------------------------------
 {{.Commit.Message -}}
 {{- if .Note}}
 ------------------------------------------------------------------------
@@ -78,6 +82,7 @@ type TableMenuEntry struct {
        Title       string
        LinesNum    int
        CommentsNum int
+       Topics      []string
 }
 
 func serveGopher() {
@@ -115,8 +120,47 @@ func serveGopher() {
        if selector == "" {
                selector = "offset/0"
        }
-       if strings.HasPrefix(selector, "offset/") {
-               offset, err := strconv.Atoi(selector[len("offset/"):])
+       selectorParts := strings.Split(selector, "/")
+       if strings.HasPrefix(selector, "URL:") {
+               selector = selector[len("URL:"):]
+               fmt.Printf(`<html>
+<head>
+       <meta http-equiv="Refresh" content="1; url=%s" />
+       <title>Redirect to non-gopher URL</title>
+</head>
+<body>
+Redirecting to <a href="%s">%s</a>...
+</body>
+</html>
+`, selector, selector, selector)
+       } else if sha1DigestRe.MatchString(selector) {
+               commit, err := repo.CommitObject(plumbing.NewHash(selector[1:]))
+               if err != nil {
+                       log.Fatalln(err)
+               }
+               tmpl := template.Must(template.New("entry").Parse(TmplGopherEntry))
+               err = tmpl.Execute(os.Stdout, struct {
+                       Commit   *object.Commit
+                       When     string
+                       Cfg      *Cfg
+                       Note     string
+                       Comments []string
+                       Topics   []string
+                       Version  string
+               }{
+                       Commit:   commit,
+                       When:     commit.Author.When.Format(sgblog.WhenFmt),
+                       Cfg:      cfg,
+                       Note:     string(getNote(notesTree, commit.Hash)),
+                       Comments: parseComments(getNote(commentsTree, commit.Hash)),
+                       Topics:   parseTopics(getNote(topicsTree, commit.Hash)),
+                       Version:  sgblog.Version,
+               })
+               if err != nil {
+                       log.Fatalln(err)
+               }
+       } else if selectorParts[len(selectorParts)-2] == "offset" {
+               offset, err := strconv.Atoi(selectorParts[len(selectorParts)-1])
                if err != nil {
                        log.Fatalln(err)
                }
@@ -124,15 +168,42 @@ func serveGopher() {
                if err != nil {
                        log.Fatalln(err)
                }
-               for i := 0; i < offset; i++ {
-                       if _, err = repoLog.Next(); err != nil {
-                               break
+               topicsCache, err := getTopicsCache(cfg, repoLog)
+               if err != nil {
+                       log.Fatalln(err)
+               }
+               repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
+               if err != nil {
+                       log.Fatalln(err)
+               }
+
+               var topic string
+               if len(selectorParts) == 3 {
+                       topic = selectorParts[0]
+               }
+               var commits CommitIterNext
+               if topic == "" {
+                       for i := 0; i < offset; i++ {
+                               if _, err = repoLog.Next(); err != nil {
+                                       break
+                               }
                        }
+                       commits = repoLog
+               } else {
+                       hashes := topicsCache[topic]
+                       if hashes == nil {
+                               log.Fatalln(errors.New("no posts with that topic"))
+                       }
+                       if len(hashes) > offset {
+                               hashes = hashes[offset:]
+                       }
+                       commits = &HashesIter{hashes}
                }
+
                logEnded := false
                entries := make([]TableMenuEntry, 0, PageEntries)
                for i := 0; i < PageEntries; i++ {
-                       commit, err := repoLog.Next()
+                       commit, err := commits.Next()
                        if err != nil {
                                logEnded = true
                                break
@@ -143,65 +214,38 @@ func serveGopher() {
                                Title:       lines[0],
                                LinesNum:    len(lines) - 2,
                                CommentsNum: len(parseComments(getNote(commentsTree, commit.Hash))),
+                               Topics:      parseTopics(getNote(topicsTree, commit.Hash)),
                        })
                }
                tmpl := template.Must(template.New("menu").Parse(TmplGopherMenu))
+               offsetPrev := offset - PageEntries
+               if offsetPrev < 0 {
+                       offsetPrev = 0
+               }
                err = tmpl.Execute(os.Stdout, struct {
                        Cfg        *Cfg
+                       Topic      string
                        Offset     int
                        OffsetPrev int
                        OffsetNext int
                        LogEnded   bool
                        Entries    []TableMenuEntry
+                       Topics     []string
                        Version    string
                }{
                        Cfg:        cfg,
+                       Topic:      topic,
                        Offset:     offset,
-                       OffsetPrev: offset - PageEntries,
+                       OffsetPrev: offsetPrev,
                        OffsetNext: offset + PageEntries,
                        LogEnded:   logEnded,
                        Entries:    entries,
+                       Topics:     topicsCache.Topics(),
                        Version:    sgblog.Version,
                })
                if err != nil {
                        log.Fatalln(err)
                }
-       } else if strings.HasPrefix(selector, "URL:") {
-               selector = selector[len("URL:"):]
-               fmt.Printf(`<html>
-<head>
-       <meta http-equiv="Refresh" content="1; url=%s" />
-       <title>Redirect to non-gopher URL</title>
-</head>
-<body>
-Redirecting to <a href="%s">%s</a>...
-</body>
-</html>
-`, selector, selector, selector)
-       } else if sha1DigestRe.MatchString(selector) {
-               commit, err := repo.CommitObject(plumbing.NewHash(selector[1:]))
-               if err != nil {
-                       log.Fatalln(err)
-               }
-               tmpl := template.Must(template.New("entry").Parse(TmplGopherEntry))
-               err = tmpl.Execute(os.Stdout, struct {
-                       Commit   *object.Commit
-                       When     string
-                       Cfg      *Cfg
-                       Note     string
-                       Comments []string
-                       Version  string
-               }{
-                       Commit:   commit,
-                       When:     commit.Author.When.Format(sgblog.WhenFmt),
-                       Cfg:      cfg,
-                       Note:     string(getNote(notesTree, commit.Hash)),
-                       Comments: parseComments(getNote(commentsTree, commit.Hash)),
-                       Version:  sgblog.Version,
-               })
-               if err != nil {
-                       log.Fatalln(err)
-               }
        } else {
                log.Fatalln(errors.New("unknown selector"))
        }
index 6d094206acf844e96fa3276c999ca12c0cdd751f..854ebe6e7895ed9ffce26a1f0b7c084fa1a40fe3 100644 (file)
@@ -42,8 +42,8 @@ import (
        "github.com/go-git/go-git/v5/plumbing/object"
        "github.com/hjson/hjson-go"
        "go.stargrave.org/sgblog"
+       "go.stargrave.org/sgblog/cmd/sgblog/atom"
        "golang.org/x/crypto/blake2b"
-       "golang.org/x/tools/blog/atom"
 )
 
 const (
@@ -53,23 +53,29 @@ const (
 <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <meta name="generator" content="SGBlog {{.Version}}">
-       <title>{{.Cfg.Title}} ({{.Offset}}-{{.OffsetNext}})</title>
+       <title>{{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})</title>
        {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
        {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end}}
        {{range .Cfg.GitURLs}}<link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
        <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
-       <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}" type="application/atom+xml">
+       <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}{{if .Topic}}?topic={{.Topic}}{{end}}" type="application/atom+xml">
        {{if .CommentsEnabled}}<link rel="alternate" title="Comments feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomCommentsFeed}}" type="application/atom+xml">{{end}}
-       {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{if .OffsetPrev}}?offset={{.OffsetPrev}}{{end}}" title="prev">{{end}}
-       {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}" title="next">{{end}}
+       {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="prev">{{end}}
+       {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="next">{{end}}
 </head>
 <body>
 {{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
 {{block "links" .}}
-{{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/{{if .OffsetPrev}}?offset={{.OffsetPrev}}{{end}}">prev</a>]{{end}}
-{{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}">next</a>]{{end}}
+{{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}">prev</a>]{{end}}
+{{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}">next</a>]{{end}}
 {{end}}
 {{- $Cfg := .Cfg -}}
+{{if .Topics}}<hr/>
+Topics: [<tt><a href="{{$Cfg.URLPrefix}}/">ALL</a></tt>]
+{{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]
+{{end}}
+{{end}}
+{{- $TopicsEnabled := .TopicsEnabled -}}
 {{- $datePrev := "0001-01-01" -}}
 <table border=1>
 <tr>
@@ -77,11 +83,12 @@ const (
        <th size="5%"><a title="Lines">L</a></th>
        <th size="5%"><a title="Comments">C</a></th>
        <th>Linked to</th>
+       {{if .TopicsEnabled}}<th>Topics</th>{{end}}
 </tr>
 {{range .Entries -}}
 {{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
 {{- if ne $dateCur $datePrev -}}
-       <tr><td colspan=6><center><tt>{{$dateCur}}</tt></center></td></tr>
+       <tr><td colspan={{if $TopicsEnabled}}7{{else}}7{{end}}><center><tt>{{$dateCur}}</tt></center></td></tr>
        {{- $datePrev = $dateCur -}}
 {{- end -}}
 <tr>
@@ -91,6 +98,7 @@ const (
        <td>{{.LinesNum}}</td>
        <td>{{if .CommentsNum}}{{.CommentsNum}}{{else}}&nbsp;{{end}}</td>
        <td>{{if .DomainURLs}}{{range .DomainURLs}} {{.}} {{end}}{{else}}&nbsp;{{end}}</td>
+       {{if $TopicsEnabled}}<td>{{if .Topics}}{{range .Topics}} <a href="{{$Cfg.URLPrefix}}/?topic={{.}}">{{.}}</a> {{end}}{{else}}&nbsp;{{end}}</td>{{end}}
 </tr>
 {{end}}</table>
 {{template "links" .}}
@@ -117,6 +125,11 @@ const (
 [<tt><a title="When">{{.When}}</a></tt>]
 [<tt><a title="What">{{.Commit.Hash.String}}</a></tt>]
 
+{{if .Topics}}
+<hr/>
+Topics: {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]{{end}}
+{{end}}
+
 <hr/>
 <h2>{{.Title}}</h2>
 <pre>
@@ -157,11 +170,13 @@ var (
 type TableEntry struct {
        Commit      *object.Commit
        CommentsRaw []byte
+       TopicsRaw   []byte
        Num         int
        Title       string
        LinesNum    int
        CommentsNum int
        DomainURLs  []string
+       Topics      []string
 }
 
 type CommentEntry struct {
@@ -236,6 +251,10 @@ func bytes2uuid(b []byte) string {
        return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:])
 }
 
+type CommitIterNext interface {
+       Next() (*object.Commit, error)
+}
+
 func serveHTTP() {
        cfgPath := os.Getenv("SGBLOG_CFG")
        if cfgPath == "" {
@@ -349,12 +368,37 @@ func serveHTTP() {
                if err != nil {
                        makeErr(err)
                }
+               topicsCache, err := getTopicsCache(cfg, repoLog)
+               if err != nil {
+                       makeErr(err)
+               }
+               repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
+               if err != nil {
+                       makeErr(err)
+               }
+
                commitN := 0
-               for i := 0; i < offset; i++ {
-                       if _, err = repoLog.Next(); err != nil {
-                               break
+               var commits CommitIterNext
+               var topic string
+               if t, exists := queryValues["topic"]; exists {
+                       topic = t[0]
+                       hashes := topicsCache[topic]
+                       if hashes == nil {
+                               makeErr(errors.New("no posts with that topic"))
                        }
-                       commitN++
+                       if len(hashes) > offset {
+                               hashes = hashes[offset:]
+                               commitN += offset
+                       }
+                       commits = &HashesIter{hashes}
+               } else {
+                       for i := 0; i < offset; i++ {
+                               if _, err = repoLog.Next(); err != nil {
+                                       break
+                               }
+                               commitN++
+                       }
+                       commits = repoLog
                }
 
                entries := make([]TableEntry, 0, PageEntries)
@@ -363,8 +407,9 @@ func serveHTTP() {
                        etagHash.Write([]byte(data))
                }
                etagHash.Write([]byte("INDEX"))
+               etagHash.Write([]byte(topic))
                for i := 0; i < PageEntries; i++ {
-                       commit, err := repoLog.Next()
+                       commit, err := commits.Next()
                        if err != nil {
                                logEnded = true
                                break
@@ -372,9 +417,12 @@ func serveHTTP() {
                        etagHash.Write(commit.Hash[:])
                        commentsRaw := getNote(commentsTree, commit.Hash)
                        etagHash.Write(commentsRaw)
+                       topicsRaw := getNote(topicsTree, commit.Hash)
+                       etagHash.Write(topicsRaw)
                        entries = append(entries, TableEntry{
                                Commit:      commit,
                                CommentsRaw: commentsRaw,
+                               TopicsRaw:   topicsRaw,
                        })
                }
                checkETag(etagHash)
@@ -393,13 +441,21 @@ func serveHTTP() {
                                entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
                        }
                        entry.CommentsNum = len(parseComments(entry.CommentsRaw))
+                       entry.Topics = parseTopics(entry.TopicsRaw)
                        entries[i] = entry
                }
+               offsetPrev := offset - PageEntries
+               if offsetPrev < 0 {
+                       offsetPrev = 0
+               }
                tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
                os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
                err = tmpl.Execute(out, struct {
                        Version          string
                        Cfg              *Cfg
+                       Topic            string
+                       TopicsEnabled    bool
+                       Topics           []string
                        CommentsEnabled  bool
                        AtomPostsFeed    string
                        AtomCommentsFeed string
@@ -411,11 +467,14 @@ func serveHTTP() {
                }{
                        Version:          sgblog.Version,
                        Cfg:              cfg,
+                       Topic:            topic,
+                       TopicsEnabled:    topicsTree != nil,
+                       Topics:           topicsCache.Topics(),
                        CommentsEnabled:  commentsTree != nil,
                        AtomPostsFeed:    AtomPostsFeed,
                        AtomCommentsFeed: AtomCommentsFeed,
                        Offset:           offset,
-                       OffsetPrev:       offset - PageEntries,
+                       OffsetPrev:       offsetPrev,
                        OffsetNext:       offset + PageEntries,
                        LogEnded:         logEnded,
                        Entries:          entries,
@@ -428,12 +487,32 @@ func serveHTTP() {
                if err != nil {
                        makeErr(err)
                }
+
+               var topic string
+               if t, exists := queryValues["topic"]; exists {
+                       topic = t[0]
+               }
+
                etagHash.Write([]byte("ATOM POSTS"))
+               etagHash.Write([]byte(topic))
                etagHash.Write(commit.Hash[:])
                checkETag(etagHash)
+               var title string
+               if topic == "" {
+                       title = cfg.Title
+               } else {
+                       title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
+               }
+               idHasher, err := blake2b.New256(nil)
+               if err != nil {
+                       panic(err)
+               }
+               idHasher.Write([]byte("ATOM POSTS"))
+               idHasher.Write([]byte(cfg.AtomId))
+               idHasher.Write([]byte(topic))
                feed := atom.Feed{
-                       Title:   cfg.Title,
-                       ID:      cfg.AtomId,
+                       Title:   title,
+                       ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
                        Updated: atom.Time(commit.Author.When),
                        Link: []atom.Link{{
                                Rel:  "self",
@@ -441,16 +520,36 @@ func serveHTTP() {
                        }},
                        Author: &atom.Person{Name: cfg.AtomAuthor},
                }
+
                repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
                if err != nil {
                        makeErr(err)
                }
+               var commits CommitIterNext
+               if topic == "" {
+                       commits = repoLog
+               } else {
+                       topicsCache, err := getTopicsCache(cfg, repoLog)
+                       if err != nil {
+                               makeErr(err)
+                       }
+                       hashes := topicsCache[topic]
+                       if hashes == nil {
+                               makeErr(errors.New("no posts with that topic"))
+                       }
+                       commits = &HashesIter{hashes}
+               }
+
                for i := 0; i < PageEntries; i++ {
-                       commit, err = repoLog.Next()
+                       commit, err = commits.Next()
                        if err != nil {
                                break
                        }
                        lines := msgSplit(commit.Message)
+                       var categories []atom.Category
+                       for _, topic := range parseTopics(getNote(topicsTree, commit.Hash)) {
+                               categories = append(categories, atom.Category{Term: topic})
+                       }
                        feed.Entry = append(feed.Entry, &atom.Entry{
                                Title: lines[0],
                                ID:    "urn:uuid:" + bytes2uuid(commit.Hash[:]),
@@ -465,6 +564,7 @@ func serveHTTP() {
                                        Type: "text",
                                        Body: strings.Join(lines[2:], "\n"),
                                },
+                               Category: categories,
                        })
                }
                data, err := xml.MarshalIndent(&feed, "", "  ")
@@ -577,6 +677,8 @@ func serveHTTP() {
                }, "")
                commentsRaw := getNote(commentsTree, commit.Hash)
                etagHash.Write(commentsRaw)
+               topicsRaw := getNote(topicsTree, commit.Hash)
+               etagHash.Write(topicsRaw)
                if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
                        etagHash.Write([]byte("ATOM COMMENTS"))
                        checkETag(etagHash)
@@ -690,6 +792,7 @@ func serveHTTP() {
                        Lines           []string
                        NoteLines       []string
                        Comments        []CommentEntry
+                       Topics          []string
                }{
                        Version:         sgblog.Version,
                        Cfg:             cfg,
@@ -701,6 +804,7 @@ func serveHTTP() {
                        Lines:           lines[2:],
                        NoteLines:       notesLines,
                        Comments:        comments,
+                       Topics:          parseTopics(topicsRaw),
                })
                if err != nil {
                        makeErr(err)
index 68a3914e04c3240591c84e6ee74128f0f048dbfe..2e4a483e27d1683c0e064481578c61bf57b4558f 100644 (file)
@@ -25,7 +25,9 @@ import (
        "io/ioutil"
        "os"
        "regexp"
+       "sort"
        "strings"
+       "text/scanner"
 
        "github.com/go-git/go-git/v5"
        "github.com/go-git/go-git/v5/plumbing"
@@ -43,6 +45,8 @@ var (
        notesTree    *object.Tree
        commentsRef  *plumbing.Reference
        commentsTree *object.Tree
+       topicsRef    *plumbing.Reference
+       topicsTree   *object.Tree
 )
 
 type Cfg struct {
@@ -64,6 +68,9 @@ type Cfg struct {
        CommentsNotesRef string
        CommentsEmail    string
 
+       TopicsNotesRef  string
+       TopicsCachePath string
+
        GopherDomain string
 }
 
@@ -124,6 +131,17 @@ func parseComments(data []byte) []string {
        return comments
 }
 
+func parseTopics(data []byte) []string {
+       var s scanner.Scanner
+       s.Init(bytes.NewBuffer(data))
+       topics := []string{}
+       for tok := s.Scan(); tok != scanner.EOF; tok = s.Scan() {
+               topics = append(topics, s.TokenText())
+       }
+       sort.Strings(topics)
+       return topics
+}
+
 func initRepo(cfg *Cfg) (*plumbing.Hash, error) {
        var err error
        repo, err = git.PlainOpen(cfg.GitPath)
@@ -143,6 +161,8 @@ func initRepo(cfg *Cfg) (*plumbing.Hash, error) {
                                notesRef = ref
                        case cfg.CommentsNotesRef:
                                commentsRef = ref
+                       case cfg.TopicsNotesRef:
+                               topicsRef = ref
                        }
                        return nil
                })
@@ -156,6 +176,11 @@ func initRepo(cfg *Cfg) (*plumbing.Hash, error) {
                                commentsTree, _ = commentsCommit.Tree()
                        }
                }
+               if topicsRef != nil {
+                       if topicsCommit, err := repo.CommitObject(topicsRef.Hash()); err == nil {
+                               topicsTree, _ = topicsCommit.Tree()
+                       }
+               }
        }
        return &headHash, nil
 }
diff --git a/cmd/sgblog/topics.go b/cmd/sgblog/topics.go
new file mode 100644 (file)
index 0000000..907b3b3
--- /dev/null
@@ -0,0 +1,131 @@
+/*
+SGBlog -- Git-backed CGI/inetd blogging/phlogging engine
+Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, version 3 of the License.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package main
+
+import (
+       "encoding/gob"
+       "io"
+       "os"
+       "sort"
+       "strconv"
+       "time"
+
+       "github.com/go-git/go-git/v5/plumbing"
+       "github.com/go-git/go-git/v5/plumbing/object"
+)
+
+type TopicsCache map[string][]plumbing.Hash
+
+type TopicsCacheState struct {
+       Top   plumbing.Hash
+       Cache TopicsCache
+}
+
+func (tc TopicsCache) Topics() []string {
+       topics := make([]string, 0, len(tc))
+       for t, _ := range tc {
+               topics = append(topics, t)
+       }
+       sort.Strings(topics)
+       return topics
+}
+
+func getTopicsCache(cfg *Cfg, repoLog object.CommitIter) (TopicsCache, error) {
+       cache := TopicsCache(make(map[string][]plumbing.Hash))
+       if topicsTree == nil {
+               return cache, nil
+       }
+       top := topicsRef.Hash()
+
+       if cfg.TopicsCachePath != "" {
+               fd, err := os.Open(cfg.TopicsCachePath)
+               if err != nil {
+                       goto NoCache
+               }
+               dec := gob.NewDecoder(fd)
+               var cacheState TopicsCacheState
+               err = dec.Decode(&cacheState)
+               fd.Close()
+               if err != nil {
+                       goto NoCache
+               }
+               if cacheState.Top == top {
+                       return cacheState.Cache, nil
+               }
+       }
+
+NoCache:
+       for {
+               commit, err := repoLog.Next()
+               if err != nil {
+                       break
+               }
+               for _, topic := range parseTopics(getNote(topicsTree, commit.Hash)) {
+                       cache[topic] = append(cache[topic], commit.Hash)
+               }
+       }
+
+       if cfg.TopicsCachePath != "" {
+               // Assume that probability of suffix collision is negligible
+               suffix := strconv.FormatInt(time.Now().UnixNano()+int64(os.Getpid()), 16)
+               tmpPath := cfg.TopicsCachePath + suffix
+               fd, err := os.OpenFile(
+                       tmpPath,
+                       os.O_RDWR|os.O_CREATE|os.O_EXCL,
+                       os.FileMode(0666),
+               )
+               if err != nil {
+                       return cache, err
+               }
+               enc := gob.NewEncoder(fd)
+               err = enc.Encode(&TopicsCacheState{top, cache})
+               if err != nil {
+                       os.Remove(tmpPath)
+                       fd.Close()
+                       return cache, err
+               }
+               if err = fd.Sync(); err != nil {
+                       os.Remove(tmpPath)
+                       fd.Close()
+                       return cache, err
+               }
+               if err = fd.Close(); err != nil {
+                       os.Remove(tmpPath)
+                       return cache, err
+               }
+               if err = os.Rename(tmpPath, cfg.TopicsCachePath); err != nil {
+                       os.Remove(tmpPath)
+                       return cache, err
+               }
+       }
+
+       return cache, nil
+}
+
+type HashesIter struct {
+       hashes []plumbing.Hash
+}
+
+func (s *HashesIter) Next() (*object.Commit, error) {
+       if len(s.hashes) == 0 {
+               return nil, io.EOF
+       }
+       var h plumbing.Hash
+       h, s.hashes = s.hashes[0], s.hashes[1:]
+       return repo.CommitObject(h)
+}
index 5f80a471a8faedde7a4dfa81c6b5fca8159523c5..6476388518d1e7b8e8ddb534fe984325a104e061 100644 (file)
--- a/common.go
+++ b/common.go
@@ -2,6 +2,6 @@
 package sgblog
 
 const (
-       Version = "0.7.1"
+       Version = "0.8.0"
        WhenFmt = "2006-01-02 15:04:05Z07:00"
 )