]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/gemini.go
Experimental gemini support
[sgblog.git] / cmd / sgblog / gemini.go
1 /*
2 SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
3 Copyright (C) 2020-2021 Sergey Matveev <stargrave@stargrave.org>
4
5 This program is free software: you can redistribute it and/or modify
6 it under the terms of the GNU Affero General Public License as
7 published by the Free Software Foundation, version 3 of the License.
8
9 This program is distributed in the hope that it will be useful,
10 but WITHOUT ANY WARRANTY; without even the implied warranty of
11 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 GNU General Public License for more details.
13
14 You should have received a copy of the GNU Affero General Public License
15 along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 */
17
18 package main
19
20 import (
21         "bufio"
22         "errors"
23         "fmt"
24         "io"
25         "log"
26         "net/url"
27         "os"
28         "strconv"
29         "text/template"
30
31         "github.com/go-git/go-git/v5"
32         "github.com/go-git/go-git/v5/plumbing"
33         "github.com/go-git/go-git/v5/plumbing/object"
34         "go.stargrave.org/sgblog"
35 )
36
37 const (
38         TmplGemMenu = `{{$CR := printf "\r"}}20 text/gemini{{$CR}}
39 # {{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})
40 {{if .Cfg.AboutURL}}=> {{.Cfg.AboutURL}} About{{end}}
41 {{if .Offset}}=> /?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}} Prev{{end}}
42 {{if not .LogEnded}}=> /?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}} Next{{end}}
43
44 {{$datePrev := "0001-01-01" -}}
45 {{- range .Entries -}}
46 {{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
47 {{- if ne $dateCur $datePrev}}{{$datePrev = $dateCur}}## {{$dateCur}}
48 {{end -}}
49 => /{{.Commit.Hash.String}} [{{.Commit.Author.When.Format "15:04"}}] {{.Title}} ({{.LinesNum}}L){{with .CommentsNum}} ({{.}}C){{end}}{{if .Topics}}{{range .Topics}} {{.}}{{end}}{{end}}
50 {{end}}
51
52 {{range .Topics -}}=> /?topic={{.}} Topic: {{.}}
53 {{end}}
54 Generated by: SGBlog {{.Version}}
55 `
56         TmplGemEntry = `{{$CR := printf "\r"}}20 text/gemini{{$CR}}
57 # {{.Title}}
58 What: {{.Commit.Hash.String}}
59 When: {{.When}}
60 {{if .Topics}}Topics:{{range .Topics}} {{.}}{{end}}{{end}}
61 ` + "```" + `
62 {{.Commit.Message}}` + "```" + `
63 {{- if .Note}}
64 ## Note:
65 ` + "```" + `
66 {{.Note}}
67 ` + "```" + `
68 {{end -}}
69 {{- if .Cfg.CommentsEmail}}
70 => mailto:{{.Cfg.CommentsEmail}}?subject={{.TitleEscaped}} leave comment
71 {{end}}{{range $idx, $comment := .Comments}}
72 ## comment {{$idx}}:
73 ` + "```" + `
74 {{$comment}}
75 ` + "```" + `
76 {{end}}
77 Generated by: SGBlog {{.Version}}
78 `
79 )
80
81 func makeGemErr(err error) {
82         fmt.Print("59 " + err.Error() + "\r\n")
83         log.Fatalln(err)
84 }
85
86 func serveGemini(cfgPath string) {
87         cfg, err := readCfg(cfgPath)
88         if err != nil {
89                 log.Fatalln(err)
90         }
91
92         headHash, err := initRepo(cfg)
93         if err != nil {
94                 log.Fatalln(err)
95         }
96
97         scanner := bufio.NewScanner(io.LimitReader(os.Stdin, 2+1<<10))
98         if !scanner.Scan() {
99                 makeGemErr(errors.New("no CRLF found"))
100         }
101         urlRaw := scanner.Text()
102         u, err := url.Parse(urlRaw)
103         if err != nil {
104                 makeGemErr(err)
105         }
106         if u.Scheme != "gemini" {
107                 makeGemErr(errors.New("only gemini:// is supported" + u.String()))
108         }
109
110         if u.Path == "/" {
111                 offset := 0
112                 if offsetRaw, exists := u.Query()["offset"]; exists {
113                         offset, err = strconv.Atoi(offsetRaw[0])
114                         if err != nil {
115                                 makeGemErr(err)
116                         }
117                 }
118                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
119                 if err != nil {
120                         makeGemErr(err)
121                 }
122                 topicsCache, err := getTopicsCache(cfg, repoLog)
123                 if err != nil {
124                         makeGemErr(err)
125                 }
126                 repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
127                 if err != nil {
128                         makeGemErr(err)
129                 }
130
131                 commitN := 0
132                 var commits CommitIterNext
133                 var topic string
134                 if t, exists := u.Query()["topic"]; exists {
135                         topic = t[0]
136                         hashes := topicsCache[topic]
137                         if hashes == nil {
138                                 makeGemErr(errors.New("no posts with that topic"))
139                         }
140                         if len(hashes) > offset {
141                                 hashes = hashes[offset:]
142                                 commitN += offset
143                         }
144                         commits = &HashesIter{hashes}
145                 } else {
146                         for i := 0; i < offset; i++ {
147                                 if _, err = repoLog.Next(); err != nil {
148                                         break
149                                 }
150                                 commitN++
151                         }
152                         commits = repoLog
153                 }
154
155                 logEnded := false
156                 entries := make([]TableMenuEntry, 0, PageEntries)
157                 for i := 0; i < PageEntries; i++ {
158                         commit, err := commits.Next()
159                         if err != nil {
160                                 logEnded = true
161                                 break
162                         }
163                         lines := msgSplit(commit.Message)
164                         entries = append(entries, TableMenuEntry{
165                                 Commit:   commit,
166                                 Title:    lines[0],
167                                 LinesNum: len(lines) - 2,
168                                 CommentsNum: len(sgblog.ParseComments(sgblog.GetNote(
169                                         repo, commentsTree, commit.Hash,
170                                 ))),
171                                 Topics: sgblog.ParseTopics(sgblog.GetNote(
172                                         repo, topicsTree, commit.Hash,
173                                 )),
174                         })
175                 }
176                 tmpl := template.Must(template.New("menu").Parse(TmplGemMenu))
177                 offsetPrev := offset - PageEntries
178                 if offsetPrev < 0 {
179                         offsetPrev = 0
180                 }
181                 err = tmpl.Execute(os.Stdout, struct {
182                         Cfg        *Cfg
183                         Topic      string
184                         Offset     int
185                         OffsetPrev int
186                         OffsetNext int
187                         LogEnded   bool
188                         Entries    []TableMenuEntry
189                         Topics     []string
190                         Version    string
191                 }{
192                         Cfg:        cfg,
193                         Topic:      topic,
194                         Offset:     offset,
195                         OffsetPrev: offsetPrev,
196                         OffsetNext: offset + PageEntries,
197                         LogEnded:   logEnded,
198                         Entries:    entries,
199                         Topics:     topicsCache.Topics(),
200                         Version:    sgblog.Version,
201                 })
202                 if err != nil {
203                         log.Fatalln(err)
204                 }
205         } else if sha1DigestRe.MatchString(u.Path[1:]) {
206                 commit, err := repo.CommitObject(plumbing.NewHash(u.Path[1:]))
207                 if err != nil {
208                         log.Fatalln(err)
209                 }
210                 tmpl := template.Must(template.New("entry").Parse(TmplGemEntry))
211                 title := msgSplit(commit.Message)[0]
212                 err = tmpl.Execute(os.Stdout, struct {
213                         Title        string
214                         Commit       *object.Commit
215                         When         string
216                         Cfg          *Cfg
217                         Note         string
218                         Comments     []string
219                         Topics       []string
220                         Version      string
221                         TitleEscaped string
222                 }{
223                         Title:    title,
224                         Commit:   commit,
225                         When:     commit.Author.When.Format(sgblog.WhenFmt),
226                         Cfg:      cfg,
227                         Note:     string(sgblog.GetNote(repo, notesTree, commit.Hash)),
228                         Comments: sgblog.ParseComments(sgblog.GetNote(repo, commentsTree, commit.Hash)),
229                         Topics:   sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)),
230                         Version:  sgblog.Version,
231                         TitleEscaped: url.PathEscape(fmt.Sprintf(
232                                 "Re: %s (%s)", title, commit.Hash,
233                         )),
234                 })
235                 if err != nil {
236                         log.Fatalln(err)
237                 }
238         } else {
239                 makeGemErr(errors.New("unknown URL action"))
240         }
241 }