diff options
author | tjpcc <tjp@ctrl-c.club> | 2023-08-31 20:16:16 -0600 |
---|---|---|
committer | tjpcc <tjp@ctrl-c.club> | 2023-08-31 20:16:16 -0600 |
commit | 0fe9ec0c90bfe82f637f1897e47fec07f90805ec (patch) | |
tree | c483fd2b4834a9ba281c92220bcf8adb9e13fb46 /gemini/gemtext/atomconv/convert.go | |
parent | d3d5d0df7fcc353318feadb57e1775f10778d505 (diff) |
move gemtext->atom conversion into package atomconv like the other converters
Diffstat (limited to 'gemini/gemtext/atomconv/convert.go')
-rw-r--r-- | gemini/gemtext/atomconv/convert.go | 181 |
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:])) |