summaryrefslogtreecommitdiff
path: root/gemini/gemtext/atomconv/convert.go
diff options
context:
space:
mode:
Diffstat (limited to 'gemini/gemtext/atomconv/convert.go')
-rw-r--r--gemini/gemtext/atomconv/convert.go181
1 files changed, 181 insertions, 0 deletions
diff --git a/gemini/gemtext/atomconv/convert.go b/gemini/gemtext/atomconv/convert.go
new file mode 100644
index 0000000..30228f3
--- /dev/null
+++ b/gemini/gemtext/atomconv/convert.go
@@ -0,0 +1,181 @@
+package atomconv
+
+import (
+ "bytes"
+ "context"
+ "html/template"
+ "io"
+ "mime"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "tildegit.org/tjp/sliderule/gemini"
+ "tildegit.org/tjp/sliderule/gemini/gemtext"
+ "tildegit.org/tjp/sliderule/internal/types"
+)
+
+// Convert turns a gemini document to Atom format.
+//
+// It identifies feed fields and entries according to the specification at
+// gemini://gemini.circumlunar.space/docs/companion/subscription.gmi
+func Convert(wr io.Writer, doc gemtext.Document, location *url.URL) error {
+ if location == nil {
+ panic("atomconv.Convert: provided location was nil")
+ }
+
+ if _, err := wr.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>`)); err != nil {
+ return err
+ }
+ if _, err := wr.Write([]byte{'\n'}); err != nil {
+ return err
+ }
+ return atomTmpl.Execute(wr, parseGemSub(doc, location))
+}
+
+// Auto is a middleware which builds atom feeds for any gemtext pages.
+//
+// It looks for requests ending with the '.atom' extension, passes through the request
+// with the extension clipped off, then if the response is in gemtext it converts it to
+// an Atom feed according to the gmisub spec at
+// gemini://gemini.circumlunar.space/docs/companion/subscription.gmi
+var Auto = types.Middleware(func(h types.Handler) types.Handler {
+ return types.HandlerFunc(func(ctx context.Context, request *types.Request) *types.Response {
+ if request.Scheme != "gemini" || !strings.HasSuffix(request.Path, ".atom") {
+ return h.Handle(ctx, request)
+ }
+
+ r := *request
+ u := *request.URL
+ u.Path = u.Path[:len(u.Path)-5]
+ r.URL = &u
+
+ response := h.Handle(ctx, &r)
+ if response.Status != gemini.StatusSuccess {
+ return response
+ }
+
+ mtype, _, err := mime.ParseMediaType(response.Meta.(string))
+ if err != nil || mtype != "text/gemini" {
+ return response
+ }
+
+ defer func() {
+ _ = response.Close()
+ }()
+
+ doc, err := gemtext.Parse(response.Body)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ buf := &bytes.Buffer{}
+ if err := Convert(buf, doc, request.URL); err != nil {
+ return gemini.Failure(err)
+ }
+ return gemini.Success("application/atom+xml; charset=utf-8", buf)
+ })
+})
+
+type gmiSub struct {
+ ID template.URL
+ Title string
+ Subtitle string
+ Updated string
+
+ Entries []gmiSubEntry
+}
+
+type gmiSubEntry struct {
+ ID template.URL
+ Updated string
+ Title string
+}
+
+var linkElemRE = regexp.MustCompile(`(\d{4})-([0-1]\d)-([0-3]\d)`)
+
+func parseGemSub(doc gemtext.Document, location *url.URL) *gmiSub {
+ sub := &gmiSub{ID: template.URL(location.String())}
+ updated := time.Time{}
+
+ for i, line := range doc {
+ switch line.Type() {
+ case gemtext.LineTypeHeading1:
+ if sub.Title != "" {
+ continue
+ }
+
+ sub.Title = line.(gemtext.HeadingLine).Body()
+
+ for { // skip any empty lines
+ i += 1
+ if i >= len(doc) || strings.TrimPrefix(doc[i].String(), "\r") != "\n" {
+ break
+ }
+ }
+ if i < len(doc) && doc[i].Type() == gemtext.LineTypeHeading2 {
+ sub.Subtitle = doc[i].(gemtext.HeadingLine).Body()
+ }
+ case gemtext.LineTypeLink:
+ label := line.(gemtext.LinkLine).Label()
+ if len(label) < 10 {
+ continue
+ }
+ match := linkElemRE.FindStringSubmatch(label[:10])
+ if match == nil {
+ continue
+ }
+
+ year, err := strconv.Atoi(match[1])
+ if err != nil {
+ continue
+ }
+ month, err := strconv.Atoi(match[2])
+ if err != nil || month > 12 {
+ continue
+ }
+ day, err := strconv.Atoi(match[3])
+ if err != nil || day > 31 {
+ continue
+ }
+
+ entryUpdated := time.Date(year, time.Month(month), day, 12, 0, 0, 0, time.UTC)
+ entryTitle := strings.TrimLeft(strings.TrimPrefix(strings.TrimLeft(label[10:], " \t"), "-"), " \t")
+
+ sub.Entries = append(sub.Entries, gmiSubEntry{
+ ID: template.URL(line.(gemtext.LinkLine).URL()),
+ Updated: entryUpdated.Format(time.RFC3339),
+ Title: entryTitle,
+ })
+
+ if entryUpdated.After(updated) {
+ updated = entryUpdated
+ sub.Updated = updated.Format(time.RFC3339)
+ }
+ }
+ }
+
+ return sub
+}
+
+var atomTmpl = template.Must(template.New("atom").Parse(`
+<feed xmlns="http://www.w3.org/2005/Atom">
+ <id>{{.ID}}</id>
+ <link href="{{.ID}}"/>
+ <title>{{.Title}}</title>
+ {{- if .Subtitle }}
+ <subtitle>{{.Subtitle}}</subtitle>
+ {{- end }}
+ <updated>{{.Updated}}</updated>
+{{- range .Entries }}
+ <entry>
+ <id>{{.ID}}</id>
+ <link rel="alternate" href="{{.ID}}"/>
+ <title>{{.Title}}</title>
+ <updated>{{.Updated}}</updated>
+ </entry>
+{{- end }}
+</feed>
+`[1:]))