@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
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
@menu
* Comments::
+* Topics::
* Installation::
* Configuration::
@end menu
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
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
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
# 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
--- /dev/null
+// 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"`
+}
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}}
------------------------------------------------------------------------
Title string
LinesNum int
CommentsNum int
+ Topics []string
}
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)
}
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
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"))
}
"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 (
<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>
<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>
<td>{{.LinesNum}}</td>
<td>{{if .CommentsNum}}{{.CommentsNum}}{{else}} {{end}}</td>
<td>{{if .DomainURLs}}{{range .DomainURLs}} {{.}} {{end}}{{else}} {{end}}</td>
+ {{if $TopicsEnabled}}<td>{{if .Topics}}{{range .Topics}} <a href="{{$Cfg.URLPrefix}}/?topic={{.}}">{{.}}</a> {{end}}{{else}} {{end}}</td>{{end}}
</tr>
{{end}}</table>
{{template "links" .}}
[<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>
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 {
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 == "" {
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)
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
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)
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
}{
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,
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",
}},
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[:]),
Type: "text",
Body: strings.Join(lines[2:], "\n"),
},
+ Category: categories,
})
}
data, err := xml.MarshalIndent(&feed, "", " ")
}, "")
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)
Lines []string
NoteLines []string
Comments []CommentEntry
+ Topics []string
}{
Version: sgblog.Version,
Cfg: cfg,
Lines: lines[2:],
NoteLines: notesLines,
Comments: comments,
+ Topics: parseTopics(topicsRaw),
})
if err != nil {
makeErr(err)
"io/ioutil"
"os"
"regexp"
+ "sort"
"strings"
+ "text/scanner"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
notesTree *object.Tree
commentsRef *plumbing.Reference
commentsTree *object.Tree
+ topicsRef *plumbing.Reference
+ topicsTree *object.Tree
)
type Cfg struct {
CommentsNotesRef string
CommentsEmail string
+ TopicsNotesRef string
+ TopicsCachePath string
+
GopherDomain 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)
notesRef = ref
case cfg.CommentsNotesRef:
commentsRef = ref
+ case cfg.TopicsNotesRef:
+ topicsRef = ref
}
return nil
})
commentsTree, _ = commentsCommit.Tree()
}
}
+ if topicsRef != nil {
+ if topicsCommit, err := repo.CommitObject(topicsRef.Hash()); err == nil {
+ topicsTree, _ = topicsCommit.Tree()
+ }
+ }
}
return &headHash, nil
}
--- /dev/null
+/*
+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)
+}
package sgblog
const (
- Version = "0.7.1"
+ Version = "0.8.0"
WhenFmt = "2006-01-02 15:04:05Z07:00"
)